github-actions[bot] commited on
Commit
4808b80
·
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 +96 -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. benchmarks/bench_metrics_api.py +200 -0
  11. biome.json +47 -0
  12. examples/generate_random_runs.py +228 -0
  13. examples/slow_metrics_writer.py +85 -0
  14. icons.config.json +82 -0
  15. mkdocs.yml +89 -0
  16. package.json +46 -0
  17. playwright.config.js +68 -0
  18. pnpm-lock.yaml +2528 -0
  19. pyproject.toml +118 -0
  20. scripts/build-icons.js +119 -0
  21. space_README.md +27 -0
  22. src/aspara/__init__.py +31 -0
  23. src/aspara/catalog/__init__.py +18 -0
  24. src/aspara/catalog/project_catalog.py +242 -0
  25. src/aspara/catalog/run_catalog.py +764 -0
  26. src/aspara/catalog/watcher.py +558 -0
  27. src/aspara/cli.py +568 -0
  28. src/aspara/config.py +266 -0
  29. src/aspara/dashboard/__init__.py +5 -0
  30. src/aspara/dashboard/dependencies.py +120 -0
  31. src/aspara/dashboard/main.py +157 -0
  32. src/aspara/dashboard/models/__init__.py +3 -0
  33. src/aspara/dashboard/models/metrics.py +49 -0
  34. src/aspara/dashboard/router.py +25 -0
  35. src/aspara/dashboard/routes/__init__.py +14 -0
  36. src/aspara/dashboard/routes/api_routes.py +458 -0
  37. src/aspara/dashboard/routes/html_routes.py +327 -0
  38. src/aspara/dashboard/routes/sse_routes.py +226 -0
  39. src/aspara/dashboard/services/__init__.py +9 -0
  40. src/aspara/dashboard/services/template_service.py +167 -0
  41. src/aspara/dashboard/static/css/input.css +217 -0
  42. src/aspara/dashboard/static/css/tagger.css +79 -0
  43. src/aspara/dashboard/static/favicon.ico +0 -0
  44. src/aspara/dashboard/static/images/aspara-icon.png +0 -0
  45. src/aspara/dashboard/static/js/api/delete-api.js +89 -0
  46. src/aspara/dashboard/static/js/chart.js +454 -0
  47. src/aspara/dashboard/static/js/chart/color-palette.js +198 -0
  48. src/aspara/dashboard/static/js/chart/controls.js +467 -0
  49. src/aspara/dashboard/static/js/chart/export-utils.js +74 -0
  50. src/aspara/dashboard/static/js/chart/export.js +140 -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,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 --cov=src/aspara --cov-report=xml --cov-report=term-missing
59
+ - uses: codecov/codecov-action@v4
60
+ with:
61
+ files: ./coverage.xml
62
+ flags: python
63
+ fail_ci_if_error: false
64
+
65
+ js-test:
66
+ runs-on: ubuntu-latest
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ - uses: pnpm/action-setup@v4
70
+ with:
71
+ version: 10.6.3
72
+ - uses: actions/setup-node@v4
73
+ with:
74
+ node-version: '22'
75
+ cache: 'pnpm'
76
+ - run: pnpm install --frozen-lockfile
77
+ - run: pnpm test:ci
78
+ - uses: codecov/codecov-action@v4
79
+ with:
80
+ files: ./coverage/coverage-final.json
81
+ flags: javascript
82
+ fail_ci_if_error: false
83
+
84
+ build:
85
+ runs-on: ubuntu-latest
86
+ steps:
87
+ - uses: actions/checkout@v4
88
+ - uses: pnpm/action-setup@v4
89
+ with:
90
+ version: 10.6.3
91
+ - uses: actions/setup-node@v4
92
+ with:
93
+ node-version: '22'
94
+ cache: 'pnpm'
95
+ - run: pnpm install --frozen-lockfile
96
+ - 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)
benchmarks/bench_metrics_api.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark for the /api/projects/{project}/runs/metrics endpoint.
2
+
3
+ Measures response time and size for both JSON and msgpack formats.
4
+ Uses httpx AsyncClient with ASGITransport so no server process is needed.
5
+
6
+ Usage:
7
+ uv run python benchmarks/bench_metrics_api.py
8
+ uv run python benchmarks/bench_metrics_api.py --runs 10 --metrics 20 --steps 2000
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import json
16
+ import statistics
17
+ import tempfile
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import httpx
22
+ import msgpack
23
+
24
+ from aspara.dashboard.dependencies import configure_data_dir
25
+ from aspara.dashboard.main import app
26
+
27
+
28
+ def create_test_data(data_dir: Path, n_runs: int, n_metrics: int, n_steps: int) -> str:
29
+ """Create test JSONL data files.
30
+
31
+ Returns:
32
+ Project name used for the test data.
33
+ """
34
+ project = "bench_project"
35
+ project_dir = data_dir / project
36
+ project_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ base_ts = 1700000000000 # Fixed base timestamp in ms
39
+
40
+ for r in range(n_runs):
41
+ run_name = f"run_{r}"
42
+ run_file = project_dir / f"{run_name}.jsonl"
43
+ meta_file = project_dir / f"{run_name}.meta.json"
44
+
45
+ # Write metrics JSONL
46
+ with run_file.open("w") as f:
47
+ for s in range(n_steps):
48
+ entry = {
49
+ "timestamp": base_ts + s * 1000,
50
+ "step": s,
51
+ "metrics": {f"metric_{m}": s * 0.1 + m for m in range(n_metrics)},
52
+ }
53
+ f.write(json.dumps(entry) + "\n")
54
+
55
+ # Write minimal metadata
56
+ meta = {
57
+ "run_id": run_name,
58
+ "tags": [],
59
+ "notes": "",
60
+ "params": {},
61
+ "config": {},
62
+ "artifacts": [],
63
+ "summary": {},
64
+ "is_finished": True,
65
+ "exit_code": 0,
66
+ "start_time": base_ts,
67
+ "finish_time": base_ts + n_steps * 1000,
68
+ }
69
+ meta_file.write_text(json.dumps(meta))
70
+
71
+ return project
72
+
73
+
74
+ async def bench_endpoint(
75
+ client: httpx.AsyncClient,
76
+ url: str,
77
+ n_warmup: int = 3,
78
+ n_iterations: int = 20,
79
+ ) -> tuple[list[float], int]:
80
+ """Benchmark a single endpoint.
81
+
82
+ Returns:
83
+ (list of response times in ms, response size in bytes)
84
+ """
85
+ # Warmup
86
+ for _ in range(n_warmup):
87
+ resp = await client.get(url, headers={"X-Requested-With": "benchmark"})
88
+ assert resp.status_code == 200, f"Status {resp.status_code}: {resp.text[:200]}"
89
+
90
+ # Measure
91
+ times: list[float] = []
92
+ size = 0
93
+ for _ in range(n_iterations):
94
+ start = time.perf_counter()
95
+ resp = await client.get(url, headers={"X-Requested-With": "benchmark"})
96
+ elapsed = (time.perf_counter() - start) * 1000 # ms
97
+ times.append(elapsed)
98
+ size = len(resp.content)
99
+
100
+ return times, size
101
+
102
+
103
+ def bench_serialization_only(data: dict, n_iterations: int = 100) -> dict[str, list[float]]:
104
+ """Benchmark raw serialization of the metrics dict.
105
+
106
+ Returns:
107
+ Dict mapping format name to list of times in ms.
108
+ """
109
+ results: dict[str, list[float]] = {}
110
+
111
+ # JSON via stdlib
112
+ json_times: list[float] = []
113
+ for _ in range(n_iterations):
114
+ start = time.perf_counter()
115
+ json.dumps(data)
116
+ elapsed = (time.perf_counter() - start) * 1000
117
+ json_times.append(elapsed)
118
+ results["json (stdlib)"] = json_times
119
+
120
+ # msgpack
121
+ msgpack_times: list[float] = []
122
+ for _ in range(n_iterations):
123
+ start = time.perf_counter()
124
+ msgpack.packb(data, use_single_float=True)
125
+ elapsed = (time.perf_counter() - start) * 1000
126
+ msgpack_times.append(elapsed)
127
+ results["msgpack"] = msgpack_times
128
+
129
+ return results
130
+
131
+
132
+ def print_stats(label: str, times: list[float], size_kb: float | None = None) -> None:
133
+ """Print timing statistics."""
134
+ median = statistics.median(times)
135
+ p95 = sorted(times)[int(len(times) * 0.95)]
136
+ mean = statistics.mean(times)
137
+ size_str = f" size={size_kb:.1f}KB" if size_kb is not None else ""
138
+ print(f" {label:30s} median={median:7.2f}ms p95={p95:7.2f}ms mean={mean:7.2f}ms{size_str}")
139
+
140
+
141
+ async def main() -> None:
142
+ parser = argparse.ArgumentParser(description="Benchmark metrics API endpoint")
143
+ parser.add_argument("--runs", type=int, default=5, help="Number of runs (default: 5)")
144
+ parser.add_argument("--metrics", type=int, default=10, help="Number of metrics per run (default: 10)")
145
+ parser.add_argument("--steps", type=int, default=1000, help="Number of steps per metric (default: 1000)")
146
+ parser.add_argument("--iterations", type=int, default=20, help="Number of benchmark iterations (default: 20)")
147
+ args = parser.parse_args()
148
+
149
+ print(f"Benchmark config: {args.runs} runs x {args.metrics} metrics x {args.steps} steps")
150
+ print(f"Iterations: {args.iterations}")
151
+ print()
152
+
153
+ with tempfile.TemporaryDirectory() as tmp:
154
+ data_dir = Path(tmp)
155
+ project = create_test_data(data_dir, args.runs, args.metrics, args.steps)
156
+ configure_data_dir(str(data_dir))
157
+
158
+ try:
159
+ run_names = ",".join(f"run_{r}" for r in range(args.runs))
160
+
161
+ transport = httpx.ASGITransport(app=app)
162
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
163
+ url_json = f"/api/projects/{project}/runs/metrics?runs={run_names}&format=json"
164
+ url_msgpack = f"/api/projects/{project}/runs/metrics?runs={run_names}&format=msgpack"
165
+
166
+ print("--- Endpoint response time ---")
167
+ json_times, json_size = await bench_endpoint(client, url_json, n_iterations=args.iterations)
168
+ msgpack_times, msgpack_size = await bench_endpoint(client, url_msgpack, n_iterations=args.iterations)
169
+
170
+ print_stats("JSON (Pydantic+Rust)", json_times, json_size / 1024)
171
+ print_stats("msgpack", msgpack_times, msgpack_size / 1024)
172
+
173
+ json_median = statistics.median(json_times)
174
+ msgpack_median = statistics.median(msgpack_times)
175
+ print(f"\n JSON/msgpack ratio: {json_median / msgpack_median:.2f}x")
176
+
177
+ # Serialization-only benchmark
178
+ print("\n--- Serialization only (raw dict -> bytes) ---")
179
+ # Build a representative data dict
180
+ sample_data: dict = {"project": project, "metrics": {}}
181
+ for m in range(args.metrics):
182
+ metric_dict: dict = {}
183
+ for r in range(args.runs):
184
+ metric_dict[f"run_{r}"] = {
185
+ "steps": list(range(args.steps)),
186
+ "values": [i * 0.1 for i in range(args.steps)],
187
+ "timestamps": [1700000000000 + i * 1000 for i in range(args.steps)],
188
+ }
189
+ sample_data["metrics"][f"metric_{m}"] = metric_dict
190
+
191
+ ser_results = bench_serialization_only(sample_data, n_iterations=args.iterations)
192
+ for label, times in ser_results.items():
193
+ size = len(json.dumps(sample_data).encode()) / 1024 if "json" in label else len(msgpack.packb(sample_data, use_single_float=True)) / 1024
194
+ print_stats(label, times, size)
195
+ finally:
196
+ configure_data_dir(None)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ asyncio.run(main())
biome.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "overrides": [
32
+ {
33
+ "include": ["tests/fixtures.js"],
34
+ "linter": {
35
+ "rules": {
36
+ "correctness": {
37
+ "noEmptyPattern": "off"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ],
43
+ "files": {
44
+ "include": ["src/**/*.js", "tests/**/*.js", "*.js"],
45
+ "ignore": ["node_modules/**", "dist/**", "build/**", "docs/**", "site/**", "site.old/**", "playwright-report/**", ".venv", "coverage/**"]
46
+ }
47
+ }
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.1.0",
37
+ "@vitest/ui": ">=4.1.0",
38
+ "canvas": "npm:@napi-rs/canvas@^0.1.95",
39
+ "happy-dom": "^20.8.9",
40
+ "heroicons": "^2.2.0",
41
+ "jsdom": "^26.1.0",
42
+ "tailwindcss": "^4.2.1",
43
+ "vite": "^7.3.5",
44
+ "vitest": ">=4.1.0"
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
+ // Target only E2E tests (avoid conflicts with Vitest)
11
+ testDir: './tests/e2e',
12
+
13
+ // Number of parallel workers
14
+ // 2 workers in CI, unlimited locally
15
+ workers: process.env.CI ? 2 : undefined,
16
+
17
+ // Test execution timeout
18
+ timeout: 30 * 1000,
19
+
20
+ // Test expectations
21
+ expect: {
22
+ // Maximum wait time for an element to become visible
23
+ timeout: 5000,
24
+ },
25
+
26
+ // Take screenshots of failed tests
27
+ use: {
28
+ // Base URL
29
+ baseURL: `http://localhost:${BASE_PORT}`,
30
+
31
+ // Take screenshots
32
+ screenshot: 'only-on-failure',
33
+
34
+ // Record traces
35
+ trace: 'on-first-retry',
36
+
37
+ // Allow downloads
38
+ acceptDownloads: true,
39
+ },
40
+
41
+ // Test report format
42
+ // 'list' only outputs to the console and does not generate an HTML report
43
+ reporter: process.env.CI ? 'github' : 'list',
44
+
45
+ // Automatically start the server before tests
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
+ // Project settings
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,2528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.1.0'
35
+ version: 4.1.9(vitest@4.1.9)
36
+ '@vitest/ui':
37
+ specifier: '>=4.1.0'
38
+ version: 4.1.9(vitest@4.1.9)
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.8.9
44
+ version: 20.8.9
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.5
56
+ version: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)
57
+ vitest:
58
+ specifier: '>=4.1.0'
59
+ version: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(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.27.3':
177
+ resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
178
+ engines: {node: '>=18'}
179
+ cpu: [ppc64]
180
+ os: [aix]
181
+
182
+ '@esbuild/android-arm64@0.27.3':
183
+ resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
184
+ engines: {node: '>=18'}
185
+ cpu: [arm64]
186
+ os: [android]
187
+
188
+ '@esbuild/android-arm@0.27.3':
189
+ resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
190
+ engines: {node: '>=18'}
191
+ cpu: [arm]
192
+ os: [android]
193
+
194
+ '@esbuild/android-x64@0.27.3':
195
+ resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
196
+ engines: {node: '>=18'}
197
+ cpu: [x64]
198
+ os: [android]
199
+
200
+ '@esbuild/darwin-arm64@0.27.3':
201
+ resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
202
+ engines: {node: '>=18'}
203
+ cpu: [arm64]
204
+ os: [darwin]
205
+
206
+ '@esbuild/darwin-x64@0.27.3':
207
+ resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
208
+ engines: {node: '>=18'}
209
+ cpu: [x64]
210
+ os: [darwin]
211
+
212
+ '@esbuild/freebsd-arm64@0.27.3':
213
+ resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
214
+ engines: {node: '>=18'}
215
+ cpu: [arm64]
216
+ os: [freebsd]
217
+
218
+ '@esbuild/freebsd-x64@0.27.3':
219
+ resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
220
+ engines: {node: '>=18'}
221
+ cpu: [x64]
222
+ os: [freebsd]
223
+
224
+ '@esbuild/linux-arm64@0.27.3':
225
+ resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
226
+ engines: {node: '>=18'}
227
+ cpu: [arm64]
228
+ os: [linux]
229
+
230
+ '@esbuild/linux-arm@0.27.3':
231
+ resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
232
+ engines: {node: '>=18'}
233
+ cpu: [arm]
234
+ os: [linux]
235
+
236
+ '@esbuild/linux-ia32@0.27.3':
237
+ resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
238
+ engines: {node: '>=18'}
239
+ cpu: [ia32]
240
+ os: [linux]
241
+
242
+ '@esbuild/linux-loong64@0.27.3':
243
+ resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
244
+ engines: {node: '>=18'}
245
+ cpu: [loong64]
246
+ os: [linux]
247
+
248
+ '@esbuild/linux-mips64el@0.27.3':
249
+ resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
250
+ engines: {node: '>=18'}
251
+ cpu: [mips64el]
252
+ os: [linux]
253
+
254
+ '@esbuild/linux-ppc64@0.27.3':
255
+ resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
256
+ engines: {node: '>=18'}
257
+ cpu: [ppc64]
258
+ os: [linux]
259
+
260
+ '@esbuild/linux-riscv64@0.27.3':
261
+ resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
262
+ engines: {node: '>=18'}
263
+ cpu: [riscv64]
264
+ os: [linux]
265
+
266
+ '@esbuild/linux-s390x@0.27.3':
267
+ resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
268
+ engines: {node: '>=18'}
269
+ cpu: [s390x]
270
+ os: [linux]
271
+
272
+ '@esbuild/linux-x64@0.27.3':
273
+ resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
274
+ engines: {node: '>=18'}
275
+ cpu: [x64]
276
+ os: [linux]
277
+
278
+ '@esbuild/netbsd-arm64@0.27.3':
279
+ resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
280
+ engines: {node: '>=18'}
281
+ cpu: [arm64]
282
+ os: [netbsd]
283
+
284
+ '@esbuild/netbsd-x64@0.27.3':
285
+ resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
286
+ engines: {node: '>=18'}
287
+ cpu: [x64]
288
+ os: [netbsd]
289
+
290
+ '@esbuild/openbsd-arm64@0.27.3':
291
+ resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
292
+ engines: {node: '>=18'}
293
+ cpu: [arm64]
294
+ os: [openbsd]
295
+
296
+ '@esbuild/openbsd-x64@0.27.3':
297
+ resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
298
+ engines: {node: '>=18'}
299
+ cpu: [x64]
300
+ os: [openbsd]
301
+
302
+ '@esbuild/openharmony-arm64@0.27.3':
303
+ resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
304
+ engines: {node: '>=18'}
305
+ cpu: [arm64]
306
+ os: [openharmony]
307
+
308
+ '@esbuild/sunos-x64@0.27.3':
309
+ resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
310
+ engines: {node: '>=18'}
311
+ cpu: [x64]
312
+ os: [sunos]
313
+
314
+ '@esbuild/win32-arm64@0.27.3':
315
+ resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
316
+ engines: {node: '>=18'}
317
+ cpu: [arm64]
318
+ os: [win32]
319
+
320
+ '@esbuild/win32-ia32@0.27.3':
321
+ resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
322
+ engines: {node: '>=18'}
323
+ cpu: [ia32]
324
+ os: [win32]
325
+
326
+ '@esbuild/win32-x64@0.27.3':
327
+ resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
328
+ engines: {node: '>=18'}
329
+ cpu: [x64]
330
+ os: [win32]
331
+
332
+ '@jcubic/tagger@0.6.2':
333
+ resolution: {integrity: sha512-Pcs/cx8+GXRUuAyxDLKGE+NutXVOaqixTMZhde40R8gMg+paLzdfO3LmmXZ1IYmkm8Nb3a2RyG2N8ZLxcIR3fg==}
334
+
335
+ '@jridgewell/gen-mapping@0.3.13':
336
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
337
+
338
+ '@jridgewell/remapping@2.3.5':
339
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
340
+
341
+ '@jridgewell/resolve-uri@3.1.2':
342
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
343
+ engines: {node: '>=6.0.0'}
344
+
345
+ '@jridgewell/sourcemap-codec@1.5.5':
346
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
347
+
348
+ '@jridgewell/trace-mapping@0.3.31':
349
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
350
+
351
+ '@msgpack/msgpack@3.1.3':
352
+ resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
353
+ engines: {node: '>= 18'}
354
+
355
+ '@napi-rs/canvas-android-arm64@0.1.95':
356
+ resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
357
+ engines: {node: '>= 10'}
358
+ cpu: [arm64]
359
+ os: [android]
360
+
361
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
362
+ resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
363
+ engines: {node: '>= 10'}
364
+ cpu: [arm64]
365
+ os: [darwin]
366
+
367
+ '@napi-rs/canvas-darwin-x64@0.1.95':
368
+ resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
369
+ engines: {node: '>= 10'}
370
+ cpu: [x64]
371
+ os: [darwin]
372
+
373
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
374
+ resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
375
+ engines: {node: '>= 10'}
376
+ cpu: [arm]
377
+ os: [linux]
378
+
379
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
380
+ resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
381
+ engines: {node: '>= 10'}
382
+ cpu: [arm64]
383
+ os: [linux]
384
+
385
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
386
+ resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
387
+ engines: {node: '>= 10'}
388
+ cpu: [arm64]
389
+ os: [linux]
390
+
391
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
392
+ resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
393
+ engines: {node: '>= 10'}
394
+ cpu: [riscv64]
395
+ os: [linux]
396
+
397
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
398
+ resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
399
+ engines: {node: '>= 10'}
400
+ cpu: [x64]
401
+ os: [linux]
402
+
403
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
404
+ resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
405
+ engines: {node: '>= 10'}
406
+ cpu: [x64]
407
+ os: [linux]
408
+
409
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
410
+ resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
411
+ engines: {node: '>= 10'}
412
+ cpu: [arm64]
413
+ os: [win32]
414
+
415
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
416
+ resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
417
+ engines: {node: '>= 10'}
418
+ cpu: [x64]
419
+ os: [win32]
420
+
421
+ '@napi-rs/canvas@0.1.95':
422
+ resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
423
+ engines: {node: '>= 10'}
424
+
425
+ '@parcel/watcher-android-arm64@2.5.6':
426
+ resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
427
+ engines: {node: '>= 10.0.0'}
428
+ cpu: [arm64]
429
+ os: [android]
430
+
431
+ '@parcel/watcher-darwin-arm64@2.5.6':
432
+ resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
433
+ engines: {node: '>= 10.0.0'}
434
+ cpu: [arm64]
435
+ os: [darwin]
436
+
437
+ '@parcel/watcher-darwin-x64@2.5.6':
438
+ resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
439
+ engines: {node: '>= 10.0.0'}
440
+ cpu: [x64]
441
+ os: [darwin]
442
+
443
+ '@parcel/watcher-freebsd-x64@2.5.6':
444
+ resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
445
+ engines: {node: '>= 10.0.0'}
446
+ cpu: [x64]
447
+ os: [freebsd]
448
+
449
+ '@parcel/watcher-linux-arm-glibc@2.5.6':
450
+ resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
451
+ engines: {node: '>= 10.0.0'}
452
+ cpu: [arm]
453
+ os: [linux]
454
+
455
+ '@parcel/watcher-linux-arm-musl@2.5.6':
456
+ resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
457
+ engines: {node: '>= 10.0.0'}
458
+ cpu: [arm]
459
+ os: [linux]
460
+
461
+ '@parcel/watcher-linux-arm64-glibc@2.5.6':
462
+ resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
463
+ engines: {node: '>= 10.0.0'}
464
+ cpu: [arm64]
465
+ os: [linux]
466
+
467
+ '@parcel/watcher-linux-arm64-musl@2.5.6':
468
+ resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
469
+ engines: {node: '>= 10.0.0'}
470
+ cpu: [arm64]
471
+ os: [linux]
472
+
473
+ '@parcel/watcher-linux-x64-glibc@2.5.6':
474
+ resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
475
+ engines: {node: '>= 10.0.0'}
476
+ cpu: [x64]
477
+ os: [linux]
478
+
479
+ '@parcel/watcher-linux-x64-musl@2.5.6':
480
+ resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
481
+ engines: {node: '>= 10.0.0'}
482
+ cpu: [x64]
483
+ os: [linux]
484
+
485
+ '@parcel/watcher-win32-arm64@2.5.6':
486
+ resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
487
+ engines: {node: '>= 10.0.0'}
488
+ cpu: [arm64]
489
+ os: [win32]
490
+
491
+ '@parcel/watcher-win32-ia32@2.5.6':
492
+ resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
493
+ engines: {node: '>= 10.0.0'}
494
+ cpu: [ia32]
495
+ os: [win32]
496
+
497
+ '@parcel/watcher-win32-x64@2.5.6':
498
+ resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
499
+ engines: {node: '>= 10.0.0'}
500
+ cpu: [x64]
501
+ os: [win32]
502
+
503
+ '@parcel/watcher@2.5.6':
504
+ resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
505
+ engines: {node: '>= 10.0.0'}
506
+
507
+ '@playwright/test@1.58.2':
508
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
509
+ engines: {node: '>=18'}
510
+ hasBin: true
511
+
512
+ '@polka/url@1.0.0-next.29':
513
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
514
+
515
+ '@rollup/rollup-android-arm-eabi@4.59.0':
516
+ resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
517
+ cpu: [arm]
518
+ os: [android]
519
+
520
+ '@rollup/rollup-android-arm64@4.59.0':
521
+ resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
522
+ cpu: [arm64]
523
+ os: [android]
524
+
525
+ '@rollup/rollup-darwin-arm64@4.59.0':
526
+ resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
527
+ cpu: [arm64]
528
+ os: [darwin]
529
+
530
+ '@rollup/rollup-darwin-x64@4.59.0':
531
+ resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
532
+ cpu: [x64]
533
+ os: [darwin]
534
+
535
+ '@rollup/rollup-freebsd-arm64@4.59.0':
536
+ resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
537
+ cpu: [arm64]
538
+ os: [freebsd]
539
+
540
+ '@rollup/rollup-freebsd-x64@4.59.0':
541
+ resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
542
+ cpu: [x64]
543
+ os: [freebsd]
544
+
545
+ '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
546
+ resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
547
+ cpu: [arm]
548
+ os: [linux]
549
+
550
+ '@rollup/rollup-linux-arm-musleabihf@4.59.0':
551
+ resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
552
+ cpu: [arm]
553
+ os: [linux]
554
+
555
+ '@rollup/rollup-linux-arm64-gnu@4.59.0':
556
+ resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
557
+ cpu: [arm64]
558
+ os: [linux]
559
+
560
+ '@rollup/rollup-linux-arm64-musl@4.59.0':
561
+ resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
562
+ cpu: [arm64]
563
+ os: [linux]
564
+
565
+ '@rollup/rollup-linux-loong64-gnu@4.59.0':
566
+ resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
567
+ cpu: [loong64]
568
+ os: [linux]
569
+
570
+ '@rollup/rollup-linux-loong64-musl@4.59.0':
571
+ resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
572
+ cpu: [loong64]
573
+ os: [linux]
574
+
575
+ '@rollup/rollup-linux-ppc64-gnu@4.59.0':
576
+ resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
577
+ cpu: [ppc64]
578
+ os: [linux]
579
+
580
+ '@rollup/rollup-linux-ppc64-musl@4.59.0':
581
+ resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
582
+ cpu: [ppc64]
583
+ os: [linux]
584
+
585
+ '@rollup/rollup-linux-riscv64-gnu@4.59.0':
586
+ resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
587
+ cpu: [riscv64]
588
+ os: [linux]
589
+
590
+ '@rollup/rollup-linux-riscv64-musl@4.59.0':
591
+ resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
592
+ cpu: [riscv64]
593
+ os: [linux]
594
+
595
+ '@rollup/rollup-linux-s390x-gnu@4.59.0':
596
+ resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
597
+ cpu: [s390x]
598
+ os: [linux]
599
+
600
+ '@rollup/rollup-linux-x64-gnu@4.59.0':
601
+ resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
602
+ cpu: [x64]
603
+ os: [linux]
604
+
605
+ '@rollup/rollup-linux-x64-musl@4.59.0':
606
+ resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
607
+ cpu: [x64]
608
+ os: [linux]
609
+
610
+ '@rollup/rollup-openbsd-x64@4.59.0':
611
+ resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
612
+ cpu: [x64]
613
+ os: [openbsd]
614
+
615
+ '@rollup/rollup-openharmony-arm64@4.59.0':
616
+ resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
617
+ cpu: [arm64]
618
+ os: [openharmony]
619
+
620
+ '@rollup/rollup-win32-arm64-msvc@4.59.0':
621
+ resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
622
+ cpu: [arm64]
623
+ os: [win32]
624
+
625
+ '@rollup/rollup-win32-ia32-msvc@4.59.0':
626
+ resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
627
+ cpu: [ia32]
628
+ os: [win32]
629
+
630
+ '@rollup/rollup-win32-x64-gnu@4.59.0':
631
+ resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
632
+ cpu: [x64]
633
+ os: [win32]
634
+
635
+ '@rollup/rollup-win32-x64-msvc@4.59.0':
636
+ resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
637
+ cpu: [x64]
638
+ os: [win32]
639
+
640
+ '@standard-schema/spec@1.1.0':
641
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
642
+
643
+ '@swc/core-darwin-arm64@1.15.17':
644
+ resolution: {integrity: sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w==}
645
+ engines: {node: '>=10'}
646
+ cpu: [arm64]
647
+ os: [darwin]
648
+
649
+ '@swc/core-darwin-x64@1.15.17':
650
+ resolution: {integrity: sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q==}
651
+ engines: {node: '>=10'}
652
+ cpu: [x64]
653
+ os: [darwin]
654
+
655
+ '@swc/core-linux-arm-gnueabihf@1.15.17':
656
+ resolution: {integrity: sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ==}
657
+ engines: {node: '>=10'}
658
+ cpu: [arm]
659
+ os: [linux]
660
+
661
+ '@swc/core-linux-arm64-gnu@1.15.17':
662
+ resolution: {integrity: sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A==}
663
+ engines: {node: '>=10'}
664
+ cpu: [arm64]
665
+ os: [linux]
666
+
667
+ '@swc/core-linux-arm64-musl@1.15.17':
668
+ resolution: {integrity: sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA==}
669
+ engines: {node: '>=10'}
670
+ cpu: [arm64]
671
+ os: [linux]
672
+
673
+ '@swc/core-linux-x64-gnu@1.15.17':
674
+ resolution: {integrity: sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ==}
675
+ engines: {node: '>=10'}
676
+ cpu: [x64]
677
+ os: [linux]
678
+
679
+ '@swc/core-linux-x64-musl@1.15.17':
680
+ resolution: {integrity: sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg==}
681
+ engines: {node: '>=10'}
682
+ cpu: [x64]
683
+ os: [linux]
684
+
685
+ '@swc/core-win32-arm64-msvc@1.15.17':
686
+ resolution: {integrity: sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg==}
687
+ engines: {node: '>=10'}
688
+ cpu: [arm64]
689
+ os: [win32]
690
+
691
+ '@swc/core-win32-ia32-msvc@1.15.17':
692
+ resolution: {integrity: sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ==}
693
+ engines: {node: '>=10'}
694
+ cpu: [ia32]
695
+ os: [win32]
696
+
697
+ '@swc/core-win32-x64-msvc@1.15.17':
698
+ resolution: {integrity: sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ==}
699
+ engines: {node: '>=10'}
700
+ cpu: [x64]
701
+ os: [win32]
702
+
703
+ '@swc/core@1.15.17':
704
+ resolution: {integrity: sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw==}
705
+ engines: {node: '>=10'}
706
+ peerDependencies:
707
+ '@swc/helpers': '>=0.5.17'
708
+ peerDependenciesMeta:
709
+ '@swc/helpers':
710
+ optional: true
711
+
712
+ '@swc/counter@0.1.3':
713
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
714
+
715
+ '@swc/types@0.1.25':
716
+ resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
717
+
718
+ '@tailwindcss/cli@4.2.1':
719
+ resolution: {integrity: sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==}
720
+ hasBin: true
721
+
722
+ '@tailwindcss/node@4.2.1':
723
+ resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
724
+
725
+ '@tailwindcss/oxide-android-arm64@4.2.1':
726
+ resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
727
+ engines: {node: '>= 20'}
728
+ cpu: [arm64]
729
+ os: [android]
730
+
731
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
732
+ resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
733
+ engines: {node: '>= 20'}
734
+ cpu: [arm64]
735
+ os: [darwin]
736
+
737
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
738
+ resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
739
+ engines: {node: '>= 20'}
740
+ cpu: [x64]
741
+ os: [darwin]
742
+
743
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
744
+ resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
745
+ engines: {node: '>= 20'}
746
+ cpu: [x64]
747
+ os: [freebsd]
748
+
749
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
750
+ resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
751
+ engines: {node: '>= 20'}
752
+ cpu: [arm]
753
+ os: [linux]
754
+
755
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
756
+ resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
757
+ engines: {node: '>= 20'}
758
+ cpu: [arm64]
759
+ os: [linux]
760
+
761
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
762
+ resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
763
+ engines: {node: '>= 20'}
764
+ cpu: [arm64]
765
+ os: [linux]
766
+
767
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
768
+ resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
769
+ engines: {node: '>= 20'}
770
+ cpu: [x64]
771
+ os: [linux]
772
+
773
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
774
+ resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
775
+ engines: {node: '>= 20'}
776
+ cpu: [x64]
777
+ os: [linux]
778
+
779
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
780
+ resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
781
+ engines: {node: '>=14.0.0'}
782
+ cpu: [wasm32]
783
+ bundledDependencies:
784
+ - '@napi-rs/wasm-runtime'
785
+ - '@emnapi/core'
786
+ - '@emnapi/runtime'
787
+ - '@tybys/wasm-util'
788
+ - '@emnapi/wasi-threads'
789
+ - tslib
790
+
791
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
792
+ resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
793
+ engines: {node: '>= 20'}
794
+ cpu: [arm64]
795
+ os: [win32]
796
+
797
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
798
+ resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
799
+ engines: {node: '>= 20'}
800
+ cpu: [x64]
801
+ os: [win32]
802
+
803
+ '@tailwindcss/oxide@4.2.1':
804
+ resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
805
+ engines: {node: '>= 20'}
806
+
807
+ '@testing-library/dom@10.4.1':
808
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
809
+ engines: {node: '>=18'}
810
+
811
+ '@types/aria-query@5.0.4':
812
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
813
+
814
+ '@types/chai@5.2.3':
815
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
816
+
817
+ '@types/deep-eql@4.0.2':
818
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
819
+
820
+ '@types/estree@1.0.8':
821
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
822
+
823
+ '@types/node@26.0.1':
824
+ resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
825
+
826
+ '@types/whatwg-mimetype@3.0.2':
827
+ resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
828
+
829
+ '@types/ws@8.18.1':
830
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
831
+
832
+ '@vitest/coverage-v8@4.1.9':
833
+ resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==}
834
+ peerDependencies:
835
+ '@vitest/browser': 4.1.9
836
+ vitest: 4.1.9
837
+ peerDependenciesMeta:
838
+ '@vitest/browser':
839
+ optional: true
840
+
841
+ '@vitest/expect@4.1.9':
842
+ resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
843
+
844
+ '@vitest/mocker@4.1.9':
845
+ resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
846
+ peerDependencies:
847
+ msw: ^2.4.9
848
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
849
+ peerDependenciesMeta:
850
+ msw:
851
+ optional: true
852
+ vite:
853
+ optional: true
854
+
855
+ '@vitest/pretty-format@4.1.9':
856
+ resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
857
+
858
+ '@vitest/runner@4.1.9':
859
+ resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
860
+
861
+ '@vitest/snapshot@4.1.9':
862
+ resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
863
+
864
+ '@vitest/spy@4.1.9':
865
+ resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
866
+
867
+ '@vitest/ui@4.1.9':
868
+ resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==}
869
+ peerDependencies:
870
+ vitest: 4.1.9
871
+
872
+ '@vitest/utils@4.1.9':
873
+ resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
874
+
875
+ agent-base@7.1.4:
876
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
877
+ engines: {node: '>= 14'}
878
+
879
+ ansi-regex@5.0.1:
880
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
881
+ engines: {node: '>=8'}
882
+
883
+ ansi-styles@5.2.0:
884
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
885
+ engines: {node: '>=10'}
886
+
887
+ aria-query@5.3.0:
888
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
889
+
890
+ assertion-error@2.0.1:
891
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
892
+ engines: {node: '>=12'}
893
+
894
+ ast-v8-to-istanbul@1.0.4:
895
+ resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
896
+
897
+ chai@6.2.2:
898
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
899
+ engines: {node: '>=18'}
900
+
901
+ convert-source-map@2.0.0:
902
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
903
+
904
+ cssstyle@4.6.0:
905
+ resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
906
+ engines: {node: '>=18'}
907
+
908
+ data-urls@5.0.0:
909
+ resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
910
+ engines: {node: '>=18'}
911
+
912
+ debug@4.4.3:
913
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
914
+ engines: {node: '>=6.0'}
915
+ peerDependencies:
916
+ supports-color: '*'
917
+ peerDependenciesMeta:
918
+ supports-color:
919
+ optional: true
920
+
921
+ decimal.js@10.6.0:
922
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
923
+
924
+ dequal@2.0.3:
925
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
926
+ engines: {node: '>=6'}
927
+
928
+ detect-libc@2.1.2:
929
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
930
+ engines: {node: '>=8'}
931
+
932
+ dom-accessibility-api@0.5.16:
933
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
934
+
935
+ enhanced-resolve@5.20.0:
936
+ resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
937
+ engines: {node: '>=10.13.0'}
938
+
939
+ entities@6.0.1:
940
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
941
+ engines: {node: '>=0.12'}
942
+
943
+ entities@7.0.1:
944
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
945
+ engines: {node: '>=0.12'}
946
+
947
+ es-module-lexer@2.1.0:
948
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
949
+
950
+ esbuild@0.27.3:
951
+ resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
952
+ engines: {node: '>=18'}
953
+ hasBin: true
954
+
955
+ estree-walker@3.0.3:
956
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
957
+
958
+ expect-type@1.3.0:
959
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
960
+ engines: {node: '>=12.0.0'}
961
+
962
+ fdir@6.5.0:
963
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
964
+ engines: {node: '>=12.0.0'}
965
+ peerDependencies:
966
+ picomatch: ^3 || ^4
967
+ peerDependenciesMeta:
968
+ picomatch:
969
+ optional: true
970
+
971
+ fflate@0.8.2:
972
+ resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
973
+
974
+ flatted@3.4.2:
975
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
976
+
977
+ fsevents@2.3.2:
978
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
979
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
980
+ os: [darwin]
981
+
982
+ fsevents@2.3.3:
983
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
984
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
985
+ os: [darwin]
986
+
987
+ graceful-fs@4.2.11:
988
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
989
+
990
+ happy-dom@20.8.9:
991
+ resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==}
992
+ engines: {node: '>=20.0.0'}
993
+
994
+ has-flag@4.0.0:
995
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
996
+ engines: {node: '>=8'}
997
+
998
+ heroicons@2.2.0:
999
+ resolution: {integrity: sha512-yOwvztmNiBWqR946t+JdgZmyzEmnRMC2nxvHFC90bF1SUttwB6yJKYeme1JeEcBfobdOs827nCyiWBS2z/brog==}
1000
+
1001
+ html-encoding-sniffer@4.0.0:
1002
+ resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
1003
+ engines: {node: '>=18'}
1004
+
1005
+ html-escaper@2.0.2:
1006
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
1007
+
1008
+ http-proxy-agent@7.0.2:
1009
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
1010
+ engines: {node: '>= 14'}
1011
+
1012
+ https-proxy-agent@7.0.6:
1013
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
1014
+ engines: {node: '>= 14'}
1015
+
1016
+ iconv-lite@0.6.3:
1017
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
1018
+ engines: {node: '>=0.10.0'}
1019
+
1020
+ is-extglob@2.1.1:
1021
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1022
+ engines: {node: '>=0.10.0'}
1023
+
1024
+ is-glob@4.0.3:
1025
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1026
+ engines: {node: '>=0.10.0'}
1027
+
1028
+ is-potential-custom-element-name@1.0.1:
1029
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
1030
+
1031
+ istanbul-lib-coverage@3.2.2:
1032
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
1033
+ engines: {node: '>=8'}
1034
+
1035
+ istanbul-lib-report@3.0.1:
1036
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
1037
+ engines: {node: '>=10'}
1038
+
1039
+ istanbul-reports@3.2.0:
1040
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
1041
+ engines: {node: '>=8'}
1042
+
1043
+ jiti@2.6.1:
1044
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
1045
+ hasBin: true
1046
+
1047
+ js-tokens@10.0.0:
1048
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
1049
+
1050
+ js-tokens@4.0.0:
1051
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1052
+
1053
+ jsdom@26.1.0:
1054
+ resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
1055
+ engines: {node: '>=18'}
1056
+ peerDependencies:
1057
+ canvas: ^3.0.0
1058
+ peerDependenciesMeta:
1059
+ canvas:
1060
+ optional: true
1061
+
1062
+ lightningcss-android-arm64@1.31.1:
1063
+ resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
1064
+ engines: {node: '>= 12.0.0'}
1065
+ cpu: [arm64]
1066
+ os: [android]
1067
+
1068
+ lightningcss-darwin-arm64@1.31.1:
1069
+ resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
1070
+ engines: {node: '>= 12.0.0'}
1071
+ cpu: [arm64]
1072
+ os: [darwin]
1073
+
1074
+ lightningcss-darwin-x64@1.31.1:
1075
+ resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
1076
+ engines: {node: '>= 12.0.0'}
1077
+ cpu: [x64]
1078
+ os: [darwin]
1079
+
1080
+ lightningcss-freebsd-x64@1.31.1:
1081
+ resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
1082
+ engines: {node: '>= 12.0.0'}
1083
+ cpu: [x64]
1084
+ os: [freebsd]
1085
+
1086
+ lightningcss-linux-arm-gnueabihf@1.31.1:
1087
+ resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
1088
+ engines: {node: '>= 12.0.0'}
1089
+ cpu: [arm]
1090
+ os: [linux]
1091
+
1092
+ lightningcss-linux-arm64-gnu@1.31.1:
1093
+ resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
1094
+ engines: {node: '>= 12.0.0'}
1095
+ cpu: [arm64]
1096
+ os: [linux]
1097
+
1098
+ lightningcss-linux-arm64-musl@1.31.1:
1099
+ resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
1100
+ engines: {node: '>= 12.0.0'}
1101
+ cpu: [arm64]
1102
+ os: [linux]
1103
+
1104
+ lightningcss-linux-x64-gnu@1.31.1:
1105
+ resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
1106
+ engines: {node: '>= 12.0.0'}
1107
+ cpu: [x64]
1108
+ os: [linux]
1109
+
1110
+ lightningcss-linux-x64-musl@1.31.1:
1111
+ resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
1112
+ engines: {node: '>= 12.0.0'}
1113
+ cpu: [x64]
1114
+ os: [linux]
1115
+
1116
+ lightningcss-win32-arm64-msvc@1.31.1:
1117
+ resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
1118
+ engines: {node: '>= 12.0.0'}
1119
+ cpu: [arm64]
1120
+ os: [win32]
1121
+
1122
+ lightningcss-win32-x64-msvc@1.31.1:
1123
+ resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
1124
+ engines: {node: '>= 12.0.0'}
1125
+ cpu: [x64]
1126
+ os: [win32]
1127
+
1128
+ lightningcss@1.31.1:
1129
+ resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
1130
+ engines: {node: '>= 12.0.0'}
1131
+
1132
+ lru-cache@10.4.3:
1133
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
1134
+
1135
+ lz-string@1.5.0:
1136
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
1137
+ hasBin: true
1138
+
1139
+ magic-string@0.30.21:
1140
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1141
+
1142
+ magicast@0.5.2:
1143
+ resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
1144
+
1145
+ make-dir@4.0.0:
1146
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
1147
+ engines: {node: '>=10'}
1148
+
1149
+ mri@1.2.0:
1150
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
1151
+ engines: {node: '>=4'}
1152
+
1153
+ mrmime@2.0.1:
1154
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
1155
+ engines: {node: '>=10'}
1156
+
1157
+ ms@2.1.3:
1158
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1159
+
1160
+ nanoid@3.3.11:
1161
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
1162
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1163
+ hasBin: true
1164
+
1165
+ node-addon-api@7.1.1:
1166
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
1167
+
1168
+ nwsapi@2.2.23:
1169
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
1170
+
1171
+ obug@2.1.1:
1172
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
1173
+
1174
+ parse5@7.3.0:
1175
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
1176
+
1177
+ pathe@2.0.3:
1178
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
1179
+
1180
+ picocolors@1.1.1:
1181
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1182
+
1183
+ picomatch@4.0.3:
1184
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
1185
+ engines: {node: '>=12'}
1186
+
1187
+ playwright-core@1.58.2:
1188
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
1189
+ engines: {node: '>=18'}
1190
+ hasBin: true
1191
+
1192
+ playwright@1.58.2:
1193
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
1194
+ engines: {node: '>=18'}
1195
+ hasBin: true
1196
+
1197
+ postcss@8.5.6:
1198
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
1199
+ engines: {node: ^10 || ^12 || >=14}
1200
+
1201
+ pretty-format@27.5.1:
1202
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
1203
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
1204
+
1205
+ punycode@2.3.1:
1206
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1207
+ engines: {node: '>=6'}
1208
+
1209
+ react-is@17.0.2:
1210
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
1211
+
1212
+ rollup@4.59.0:
1213
+ resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
1214
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1215
+ hasBin: true
1216
+
1217
+ rrweb-cssom@0.8.0:
1218
+ resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
1219
+
1220
+ safer-buffer@2.1.2:
1221
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
1222
+
1223
+ saxes@6.0.0:
1224
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
1225
+ engines: {node: '>=v12.22.7'}
1226
+
1227
+ semver@7.7.4:
1228
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
1229
+ engines: {node: '>=10'}
1230
+ hasBin: true
1231
+
1232
+ siginfo@2.0.0:
1233
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1234
+
1235
+ sirv@3.0.2:
1236
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
1237
+ engines: {node: '>=18'}
1238
+
1239
+ source-map-js@1.2.1:
1240
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1241
+ engines: {node: '>=0.10.0'}
1242
+
1243
+ stackback@0.0.2:
1244
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
1245
+
1246
+ std-env@4.1.0:
1247
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
1248
+
1249
+ supports-color@7.2.0:
1250
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
1251
+ engines: {node: '>=8'}
1252
+
1253
+ symbol-tree@3.2.4:
1254
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
1255
+
1256
+ tailwindcss@4.2.1:
1257
+ resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
1258
+
1259
+ tapable@2.3.0:
1260
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
1261
+ engines: {node: '>=6'}
1262
+
1263
+ tinybench@2.9.0:
1264
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
1265
+
1266
+ tinyexec@1.0.2:
1267
+ resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
1268
+ engines: {node: '>=18'}
1269
+
1270
+ tinyglobby@0.2.15:
1271
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
1272
+ engines: {node: '>=12.0.0'}
1273
+
1274
+ tinyrainbow@3.1.0:
1275
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
1276
+ engines: {node: '>=14.0.0'}
1277
+
1278
+ tldts-core@6.1.86:
1279
+ resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
1280
+
1281
+ tldts@6.1.86:
1282
+ resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
1283
+ hasBin: true
1284
+
1285
+ totalist@3.0.1:
1286
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
1287
+ engines: {node: '>=6'}
1288
+
1289
+ tough-cookie@5.1.2:
1290
+ resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
1291
+ engines: {node: '>=16'}
1292
+
1293
+ tr46@5.1.1:
1294
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
1295
+ engines: {node: '>=18'}
1296
+
1297
+ undici-types@8.3.0:
1298
+ resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
1299
+
1300
+ vite@7.3.6:
1301
+ resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
1302
+ engines: {node: ^20.19.0 || >=22.12.0}
1303
+ hasBin: true
1304
+ peerDependencies:
1305
+ '@types/node': ^20.19.0 || >=22.12.0
1306
+ jiti: '>=1.21.0'
1307
+ less: ^4.0.0
1308
+ lightningcss: ^1.21.0
1309
+ sass: ^1.70.0
1310
+ sass-embedded: ^1.70.0
1311
+ stylus: '>=0.54.8'
1312
+ sugarss: ^5.0.0
1313
+ terser: ^5.16.0
1314
+ tsx: ^4.8.1
1315
+ yaml: ^2.4.2
1316
+ peerDependenciesMeta:
1317
+ '@types/node':
1318
+ optional: true
1319
+ jiti:
1320
+ optional: true
1321
+ less:
1322
+ optional: true
1323
+ lightningcss:
1324
+ optional: true
1325
+ sass:
1326
+ optional: true
1327
+ sass-embedded:
1328
+ optional: true
1329
+ stylus:
1330
+ optional: true
1331
+ sugarss:
1332
+ optional: true
1333
+ terser:
1334
+ optional: true
1335
+ tsx:
1336
+ optional: true
1337
+ yaml:
1338
+ optional: true
1339
+
1340
+ vitest@4.1.9:
1341
+ resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
1342
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
1343
+ hasBin: true
1344
+ peerDependencies:
1345
+ '@edge-runtime/vm': '*'
1346
+ '@opentelemetry/api': ^1.9.0
1347
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
1348
+ '@vitest/browser-playwright': 4.1.9
1349
+ '@vitest/browser-preview': 4.1.9
1350
+ '@vitest/browser-webdriverio': 4.1.9
1351
+ '@vitest/coverage-istanbul': 4.1.9
1352
+ '@vitest/coverage-v8': 4.1.9
1353
+ '@vitest/ui': 4.1.9
1354
+ happy-dom: '*'
1355
+ jsdom: '*'
1356
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
1357
+ peerDependenciesMeta:
1358
+ '@edge-runtime/vm':
1359
+ optional: true
1360
+ '@opentelemetry/api':
1361
+ optional: true
1362
+ '@types/node':
1363
+ optional: true
1364
+ '@vitest/browser-playwright':
1365
+ optional: true
1366
+ '@vitest/browser-preview':
1367
+ optional: true
1368
+ '@vitest/browser-webdriverio':
1369
+ optional: true
1370
+ '@vitest/coverage-istanbul':
1371
+ optional: true
1372
+ '@vitest/coverage-v8':
1373
+ optional: true
1374
+ '@vitest/ui':
1375
+ optional: true
1376
+ happy-dom:
1377
+ optional: true
1378
+ jsdom:
1379
+ optional: true
1380
+
1381
+ w3c-xmlserializer@5.0.0:
1382
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
1383
+ engines: {node: '>=18'}
1384
+
1385
+ webidl-conversions@7.0.0:
1386
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
1387
+ engines: {node: '>=12'}
1388
+
1389
+ whatwg-encoding@3.1.1:
1390
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
1391
+ engines: {node: '>=18'}
1392
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
1393
+
1394
+ whatwg-mimetype@3.0.0:
1395
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
1396
+ engines: {node: '>=12'}
1397
+
1398
+ whatwg-mimetype@4.0.0:
1399
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
1400
+ engines: {node: '>=18'}
1401
+
1402
+ whatwg-url@14.2.0:
1403
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
1404
+ engines: {node: '>=18'}
1405
+
1406
+ why-is-node-running@2.3.0:
1407
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
1408
+ engines: {node: '>=8'}
1409
+ hasBin: true
1410
+
1411
+ ws@8.19.0:
1412
+ resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
1413
+ engines: {node: '>=10.0.0'}
1414
+ peerDependencies:
1415
+ bufferutil: ^4.0.1
1416
+ utf-8-validate: '>=5.0.2'
1417
+ peerDependenciesMeta:
1418
+ bufferutil:
1419
+ optional: true
1420
+ utf-8-validate:
1421
+ optional: true
1422
+
1423
+ ws@8.21.0:
1424
+ resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
1425
+ engines: {node: '>=10.0.0'}
1426
+ peerDependencies:
1427
+ bufferutil: ^4.0.1
1428
+ utf-8-validate: '>=5.0.2'
1429
+ peerDependenciesMeta:
1430
+ bufferutil:
1431
+ optional: true
1432
+ utf-8-validate:
1433
+ optional: true
1434
+
1435
+ xml-name-validator@5.0.0:
1436
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
1437
+ engines: {node: '>=18'}
1438
+
1439
+ xmlchars@2.2.0:
1440
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
1441
+
1442
+ snapshots:
1443
+
1444
+ '@asamuzakjp/css-color@3.2.0':
1445
+ dependencies:
1446
+ '@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)
1447
+ '@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)
1448
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1449
+ '@csstools/css-tokenizer': 3.0.4
1450
+ lru-cache: 10.4.3
1451
+
1452
+ '@babel/code-frame@7.29.0':
1453
+ dependencies:
1454
+ '@babel/helper-validator-identifier': 7.28.5
1455
+ js-tokens: 4.0.0
1456
+ picocolors: 1.1.1
1457
+
1458
+ '@babel/helper-string-parser@7.27.1': {}
1459
+
1460
+ '@babel/helper-validator-identifier@7.28.5': {}
1461
+
1462
+ '@babel/parser@7.29.0':
1463
+ dependencies:
1464
+ '@babel/types': 7.29.0
1465
+
1466
+ '@babel/runtime@7.28.6': {}
1467
+
1468
+ '@babel/types@7.29.0':
1469
+ dependencies:
1470
+ '@babel/helper-string-parser': 7.27.1
1471
+ '@babel/helper-validator-identifier': 7.28.5
1472
+
1473
+ '@bcoe/v8-coverage@1.0.2': {}
1474
+
1475
+ '@biomejs/biome@1.9.4':
1476
+ optionalDependencies:
1477
+ '@biomejs/cli-darwin-arm64': 1.9.4
1478
+ '@biomejs/cli-darwin-x64': 1.9.4
1479
+ '@biomejs/cli-linux-arm64': 1.9.4
1480
+ '@biomejs/cli-linux-arm64-musl': 1.9.4
1481
+ '@biomejs/cli-linux-x64': 1.9.4
1482
+ '@biomejs/cli-linux-x64-musl': 1.9.4
1483
+ '@biomejs/cli-win32-arm64': 1.9.4
1484
+ '@biomejs/cli-win32-x64': 1.9.4
1485
+
1486
+ '@biomejs/cli-darwin-arm64@1.9.4':
1487
+ optional: true
1488
+
1489
+ '@biomejs/cli-darwin-x64@1.9.4':
1490
+ optional: true
1491
+
1492
+ '@biomejs/cli-linux-arm64-musl@1.9.4':
1493
+ optional: true
1494
+
1495
+ '@biomejs/cli-linux-arm64@1.9.4':
1496
+ optional: true
1497
+
1498
+ '@biomejs/cli-linux-x64-musl@1.9.4':
1499
+ optional: true
1500
+
1501
+ '@biomejs/cli-linux-x64@1.9.4':
1502
+ optional: true
1503
+
1504
+ '@biomejs/cli-win32-arm64@1.9.4':
1505
+ optional: true
1506
+
1507
+ '@biomejs/cli-win32-x64@1.9.4':
1508
+ optional: true
1509
+
1510
+ '@csstools/color-helpers@5.1.0': {}
1511
+
1512
+ '@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)':
1513
+ dependencies:
1514
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1515
+ '@csstools/css-tokenizer': 3.0.4
1516
+
1517
+ '@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)':
1518
+ dependencies:
1519
+ '@csstools/color-helpers': 5.1.0
1520
+ '@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)
1521
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
1522
+ '@csstools/css-tokenizer': 3.0.4
1523
+
1524
+ '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
1525
+ dependencies:
1526
+ '@csstools/css-tokenizer': 3.0.4
1527
+
1528
+ '@csstools/css-tokenizer@3.0.4': {}
1529
+
1530
+ '@esbuild/aix-ppc64@0.27.3':
1531
+ optional: true
1532
+
1533
+ '@esbuild/android-arm64@0.27.3':
1534
+ optional: true
1535
+
1536
+ '@esbuild/android-arm@0.27.3':
1537
+ optional: true
1538
+
1539
+ '@esbuild/android-x64@0.27.3':
1540
+ optional: true
1541
+
1542
+ '@esbuild/darwin-arm64@0.27.3':
1543
+ optional: true
1544
+
1545
+ '@esbuild/darwin-x64@0.27.3':
1546
+ optional: true
1547
+
1548
+ '@esbuild/freebsd-arm64@0.27.3':
1549
+ optional: true
1550
+
1551
+ '@esbuild/freebsd-x64@0.27.3':
1552
+ optional: true
1553
+
1554
+ '@esbuild/linux-arm64@0.27.3':
1555
+ optional: true
1556
+
1557
+ '@esbuild/linux-arm@0.27.3':
1558
+ optional: true
1559
+
1560
+ '@esbuild/linux-ia32@0.27.3':
1561
+ optional: true
1562
+
1563
+ '@esbuild/linux-loong64@0.27.3':
1564
+ optional: true
1565
+
1566
+ '@esbuild/linux-mips64el@0.27.3':
1567
+ optional: true
1568
+
1569
+ '@esbuild/linux-ppc64@0.27.3':
1570
+ optional: true
1571
+
1572
+ '@esbuild/linux-riscv64@0.27.3':
1573
+ optional: true
1574
+
1575
+ '@esbuild/linux-s390x@0.27.3':
1576
+ optional: true
1577
+
1578
+ '@esbuild/linux-x64@0.27.3':
1579
+ optional: true
1580
+
1581
+ '@esbuild/netbsd-arm64@0.27.3':
1582
+ optional: true
1583
+
1584
+ '@esbuild/netbsd-x64@0.27.3':
1585
+ optional: true
1586
+
1587
+ '@esbuild/openbsd-arm64@0.27.3':
1588
+ optional: true
1589
+
1590
+ '@esbuild/openbsd-x64@0.27.3':
1591
+ optional: true
1592
+
1593
+ '@esbuild/openharmony-arm64@0.27.3':
1594
+ optional: true
1595
+
1596
+ '@esbuild/sunos-x64@0.27.3':
1597
+ optional: true
1598
+
1599
+ '@esbuild/win32-arm64@0.27.3':
1600
+ optional: true
1601
+
1602
+ '@esbuild/win32-ia32@0.27.3':
1603
+ optional: true
1604
+
1605
+ '@esbuild/win32-x64@0.27.3':
1606
+ optional: true
1607
+
1608
+ '@jcubic/tagger@0.6.2': {}
1609
+
1610
+ '@jridgewell/gen-mapping@0.3.13':
1611
+ dependencies:
1612
+ '@jridgewell/sourcemap-codec': 1.5.5
1613
+ '@jridgewell/trace-mapping': 0.3.31
1614
+
1615
+ '@jridgewell/remapping@2.3.5':
1616
+ dependencies:
1617
+ '@jridgewell/gen-mapping': 0.3.13
1618
+ '@jridgewell/trace-mapping': 0.3.31
1619
+
1620
+ '@jridgewell/resolve-uri@3.1.2': {}
1621
+
1622
+ '@jridgewell/sourcemap-codec@1.5.5': {}
1623
+
1624
+ '@jridgewell/trace-mapping@0.3.31':
1625
+ dependencies:
1626
+ '@jridgewell/resolve-uri': 3.1.2
1627
+ '@jridgewell/sourcemap-codec': 1.5.5
1628
+
1629
+ '@msgpack/msgpack@3.1.3': {}
1630
+
1631
+ '@napi-rs/canvas-android-arm64@0.1.95':
1632
+ optional: true
1633
+
1634
+ '@napi-rs/canvas-darwin-arm64@0.1.95':
1635
+ optional: true
1636
+
1637
+ '@napi-rs/canvas-darwin-x64@0.1.95':
1638
+ optional: true
1639
+
1640
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
1641
+ optional: true
1642
+
1643
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.95':
1644
+ optional: true
1645
+
1646
+ '@napi-rs/canvas-linux-arm64-musl@0.1.95':
1647
+ optional: true
1648
+
1649
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
1650
+ optional: true
1651
+
1652
+ '@napi-rs/canvas-linux-x64-gnu@0.1.95':
1653
+ optional: true
1654
+
1655
+ '@napi-rs/canvas-linux-x64-musl@0.1.95':
1656
+ optional: true
1657
+
1658
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.95':
1659
+ optional: true
1660
+
1661
+ '@napi-rs/canvas-win32-x64-msvc@0.1.95':
1662
+ optional: true
1663
+
1664
+ '@napi-rs/canvas@0.1.95':
1665
+ optionalDependencies:
1666
+ '@napi-rs/canvas-android-arm64': 0.1.95
1667
+ '@napi-rs/canvas-darwin-arm64': 0.1.95
1668
+ '@napi-rs/canvas-darwin-x64': 0.1.95
1669
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
1670
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.95
1671
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.95
1672
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
1673
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.95
1674
+ '@napi-rs/canvas-linux-x64-musl': 0.1.95
1675
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.95
1676
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.95
1677
+
1678
+ '@parcel/watcher-android-arm64@2.5.6':
1679
+ optional: true
1680
+
1681
+ '@parcel/watcher-darwin-arm64@2.5.6':
1682
+ optional: true
1683
+
1684
+ '@parcel/watcher-darwin-x64@2.5.6':
1685
+ optional: true
1686
+
1687
+ '@parcel/watcher-freebsd-x64@2.5.6':
1688
+ optional: true
1689
+
1690
+ '@parcel/watcher-linux-arm-glibc@2.5.6':
1691
+ optional: true
1692
+
1693
+ '@parcel/watcher-linux-arm-musl@2.5.6':
1694
+ optional: true
1695
+
1696
+ '@parcel/watcher-linux-arm64-glibc@2.5.6':
1697
+ optional: true
1698
+
1699
+ '@parcel/watcher-linux-arm64-musl@2.5.6':
1700
+ optional: true
1701
+
1702
+ '@parcel/watcher-linux-x64-glibc@2.5.6':
1703
+ optional: true
1704
+
1705
+ '@parcel/watcher-linux-x64-musl@2.5.6':
1706
+ optional: true
1707
+
1708
+ '@parcel/watcher-win32-arm64@2.5.6':
1709
+ optional: true
1710
+
1711
+ '@parcel/watcher-win32-ia32@2.5.6':
1712
+ optional: true
1713
+
1714
+ '@parcel/watcher-win32-x64@2.5.6':
1715
+ optional: true
1716
+
1717
+ '@parcel/watcher@2.5.6':
1718
+ dependencies:
1719
+ detect-libc: 2.1.2
1720
+ is-glob: 4.0.3
1721
+ node-addon-api: 7.1.1
1722
+ picomatch: 4.0.3
1723
+ optionalDependencies:
1724
+ '@parcel/watcher-android-arm64': 2.5.6
1725
+ '@parcel/watcher-darwin-arm64': 2.5.6
1726
+ '@parcel/watcher-darwin-x64': 2.5.6
1727
+ '@parcel/watcher-freebsd-x64': 2.5.6
1728
+ '@parcel/watcher-linux-arm-glibc': 2.5.6
1729
+ '@parcel/watcher-linux-arm-musl': 2.5.6
1730
+ '@parcel/watcher-linux-arm64-glibc': 2.5.6
1731
+ '@parcel/watcher-linux-arm64-musl': 2.5.6
1732
+ '@parcel/watcher-linux-x64-glibc': 2.5.6
1733
+ '@parcel/watcher-linux-x64-musl': 2.5.6
1734
+ '@parcel/watcher-win32-arm64': 2.5.6
1735
+ '@parcel/watcher-win32-ia32': 2.5.6
1736
+ '@parcel/watcher-win32-x64': 2.5.6
1737
+
1738
+ '@playwright/test@1.58.2':
1739
+ dependencies:
1740
+ playwright: 1.58.2
1741
+
1742
+ '@polka/url@1.0.0-next.29': {}
1743
+
1744
+ '@rollup/rollup-android-arm-eabi@4.59.0':
1745
+ optional: true
1746
+
1747
+ '@rollup/rollup-android-arm64@4.59.0':
1748
+ optional: true
1749
+
1750
+ '@rollup/rollup-darwin-arm64@4.59.0':
1751
+ optional: true
1752
+
1753
+ '@rollup/rollup-darwin-x64@4.59.0':
1754
+ optional: true
1755
+
1756
+ '@rollup/rollup-freebsd-arm64@4.59.0':
1757
+ optional: true
1758
+
1759
+ '@rollup/rollup-freebsd-x64@4.59.0':
1760
+ optional: true
1761
+
1762
+ '@rollup/rollup-linux-arm-gnueabihf@4.59.0':
1763
+ optional: true
1764
+
1765
+ '@rollup/rollup-linux-arm-musleabihf@4.59.0':
1766
+ optional: true
1767
+
1768
+ '@rollup/rollup-linux-arm64-gnu@4.59.0':
1769
+ optional: true
1770
+
1771
+ '@rollup/rollup-linux-arm64-musl@4.59.0':
1772
+ optional: true
1773
+
1774
+ '@rollup/rollup-linux-loong64-gnu@4.59.0':
1775
+ optional: true
1776
+
1777
+ '@rollup/rollup-linux-loong64-musl@4.59.0':
1778
+ optional: true
1779
+
1780
+ '@rollup/rollup-linux-ppc64-gnu@4.59.0':
1781
+ optional: true
1782
+
1783
+ '@rollup/rollup-linux-ppc64-musl@4.59.0':
1784
+ optional: true
1785
+
1786
+ '@rollup/rollup-linux-riscv64-gnu@4.59.0':
1787
+ optional: true
1788
+
1789
+ '@rollup/rollup-linux-riscv64-musl@4.59.0':
1790
+ optional: true
1791
+
1792
+ '@rollup/rollup-linux-s390x-gnu@4.59.0':
1793
+ optional: true
1794
+
1795
+ '@rollup/rollup-linux-x64-gnu@4.59.0':
1796
+ optional: true
1797
+
1798
+ '@rollup/rollup-linux-x64-musl@4.59.0':
1799
+ optional: true
1800
+
1801
+ '@rollup/rollup-openbsd-x64@4.59.0':
1802
+ optional: true
1803
+
1804
+ '@rollup/rollup-openharmony-arm64@4.59.0':
1805
+ optional: true
1806
+
1807
+ '@rollup/rollup-win32-arm64-msvc@4.59.0':
1808
+ optional: true
1809
+
1810
+ '@rollup/rollup-win32-ia32-msvc@4.59.0':
1811
+ optional: true
1812
+
1813
+ '@rollup/rollup-win32-x64-gnu@4.59.0':
1814
+ optional: true
1815
+
1816
+ '@rollup/rollup-win32-x64-msvc@4.59.0':
1817
+ optional: true
1818
+
1819
+ '@standard-schema/spec@1.1.0': {}
1820
+
1821
+ '@swc/core-darwin-arm64@1.15.17':
1822
+ optional: true
1823
+
1824
+ '@swc/core-darwin-x64@1.15.17':
1825
+ optional: true
1826
+
1827
+ '@swc/core-linux-arm-gnueabihf@1.15.17':
1828
+ optional: true
1829
+
1830
+ '@swc/core-linux-arm64-gnu@1.15.17':
1831
+ optional: true
1832
+
1833
+ '@swc/core-linux-arm64-musl@1.15.17':
1834
+ optional: true
1835
+
1836
+ '@swc/core-linux-x64-gnu@1.15.17':
1837
+ optional: true
1838
+
1839
+ '@swc/core-linux-x64-musl@1.15.17':
1840
+ optional: true
1841
+
1842
+ '@swc/core-win32-arm64-msvc@1.15.17':
1843
+ optional: true
1844
+
1845
+ '@swc/core-win32-ia32-msvc@1.15.17':
1846
+ optional: true
1847
+
1848
+ '@swc/core-win32-x64-msvc@1.15.17':
1849
+ optional: true
1850
+
1851
+ '@swc/core@1.15.17':
1852
+ dependencies:
1853
+ '@swc/counter': 0.1.3
1854
+ '@swc/types': 0.1.25
1855
+ optionalDependencies:
1856
+ '@swc/core-darwin-arm64': 1.15.17
1857
+ '@swc/core-darwin-x64': 1.15.17
1858
+ '@swc/core-linux-arm-gnueabihf': 1.15.17
1859
+ '@swc/core-linux-arm64-gnu': 1.15.17
1860
+ '@swc/core-linux-arm64-musl': 1.15.17
1861
+ '@swc/core-linux-x64-gnu': 1.15.17
1862
+ '@swc/core-linux-x64-musl': 1.15.17
1863
+ '@swc/core-win32-arm64-msvc': 1.15.17
1864
+ '@swc/core-win32-ia32-msvc': 1.15.17
1865
+ '@swc/core-win32-x64-msvc': 1.15.17
1866
+
1867
+ '@swc/counter@0.1.3': {}
1868
+
1869
+ '@swc/types@0.1.25':
1870
+ dependencies:
1871
+ '@swc/counter': 0.1.3
1872
+
1873
+ '@tailwindcss/cli@4.2.1':
1874
+ dependencies:
1875
+ '@parcel/watcher': 2.5.6
1876
+ '@tailwindcss/node': 4.2.1
1877
+ '@tailwindcss/oxide': 4.2.1
1878
+ enhanced-resolve: 5.20.0
1879
+ mri: 1.2.0
1880
+ picocolors: 1.1.1
1881
+ tailwindcss: 4.2.1
1882
+
1883
+ '@tailwindcss/node@4.2.1':
1884
+ dependencies:
1885
+ '@jridgewell/remapping': 2.3.5
1886
+ enhanced-resolve: 5.20.0
1887
+ jiti: 2.6.1
1888
+ lightningcss: 1.31.1
1889
+ magic-string: 0.30.21
1890
+ source-map-js: 1.2.1
1891
+ tailwindcss: 4.2.1
1892
+
1893
+ '@tailwindcss/oxide-android-arm64@4.2.1':
1894
+ optional: true
1895
+
1896
+ '@tailwindcss/oxide-darwin-arm64@4.2.1':
1897
+ optional: true
1898
+
1899
+ '@tailwindcss/oxide-darwin-x64@4.2.1':
1900
+ optional: true
1901
+
1902
+ '@tailwindcss/oxide-freebsd-x64@4.2.1':
1903
+ optional: true
1904
+
1905
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
1906
+ optional: true
1907
+
1908
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
1909
+ optional: true
1910
+
1911
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
1912
+ optional: true
1913
+
1914
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
1915
+ optional: true
1916
+
1917
+ '@tailwindcss/oxide-linux-x64-musl@4.2.1':
1918
+ optional: true
1919
+
1920
+ '@tailwindcss/oxide-wasm32-wasi@4.2.1':
1921
+ optional: true
1922
+
1923
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
1924
+ optional: true
1925
+
1926
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
1927
+ optional: true
1928
+
1929
+ '@tailwindcss/oxide@4.2.1':
1930
+ optionalDependencies:
1931
+ '@tailwindcss/oxide-android-arm64': 4.2.1
1932
+ '@tailwindcss/oxide-darwin-arm64': 4.2.1
1933
+ '@tailwindcss/oxide-darwin-x64': 4.2.1
1934
+ '@tailwindcss/oxide-freebsd-x64': 4.2.1
1935
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
1936
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
1937
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
1938
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
1939
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.1
1940
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.1
1941
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
1942
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
1943
+
1944
+ '@testing-library/dom@10.4.1':
1945
+ dependencies:
1946
+ '@babel/code-frame': 7.29.0
1947
+ '@babel/runtime': 7.28.6
1948
+ '@types/aria-query': 5.0.4
1949
+ aria-query: 5.3.0
1950
+ dom-accessibility-api: 0.5.16
1951
+ lz-string: 1.5.0
1952
+ picocolors: 1.1.1
1953
+ pretty-format: 27.5.1
1954
+
1955
+ '@types/aria-query@5.0.4': {}
1956
+
1957
+ '@types/chai@5.2.3':
1958
+ dependencies:
1959
+ '@types/deep-eql': 4.0.2
1960
+ assertion-error: 2.0.1
1961
+
1962
+ '@types/deep-eql@4.0.2': {}
1963
+
1964
+ '@types/estree@1.0.8': {}
1965
+
1966
+ '@types/node@26.0.1':
1967
+ dependencies:
1968
+ undici-types: 8.3.0
1969
+
1970
+ '@types/whatwg-mimetype@3.0.2': {}
1971
+
1972
+ '@types/ws@8.18.1':
1973
+ dependencies:
1974
+ '@types/node': 26.0.1
1975
+
1976
+ '@vitest/coverage-v8@4.1.9(vitest@4.1.9)':
1977
+ dependencies:
1978
+ '@bcoe/v8-coverage': 1.0.2
1979
+ '@vitest/utils': 4.1.9
1980
+ ast-v8-to-istanbul: 1.0.4
1981
+ istanbul-lib-coverage: 3.2.2
1982
+ istanbul-lib-report: 3.0.1
1983
+ istanbul-reports: 3.2.0
1984
+ magicast: 0.5.2
1985
+ obug: 2.1.1
1986
+ std-env: 4.1.0
1987
+ tinyrainbow: 3.1.0
1988
+ vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1))
1989
+
1990
+ '@vitest/expect@4.1.9':
1991
+ dependencies:
1992
+ '@standard-schema/spec': 1.1.0
1993
+ '@types/chai': 5.2.3
1994
+ '@vitest/spy': 4.1.9
1995
+ '@vitest/utils': 4.1.9
1996
+ chai: 6.2.2
1997
+ tinyrainbow: 3.1.0
1998
+
1999
+ '@vitest/mocker@4.1.9(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1))':
2000
+ dependencies:
2001
+ '@vitest/spy': 4.1.9
2002
+ estree-walker: 3.0.3
2003
+ magic-string: 0.30.21
2004
+ optionalDependencies:
2005
+ vite: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)
2006
+
2007
+ '@vitest/pretty-format@4.1.9':
2008
+ dependencies:
2009
+ tinyrainbow: 3.1.0
2010
+
2011
+ '@vitest/runner@4.1.9':
2012
+ dependencies:
2013
+ '@vitest/utils': 4.1.9
2014
+ pathe: 2.0.3
2015
+
2016
+ '@vitest/snapshot@4.1.9':
2017
+ dependencies:
2018
+ '@vitest/pretty-format': 4.1.9
2019
+ '@vitest/utils': 4.1.9
2020
+ magic-string: 0.30.21
2021
+ pathe: 2.0.3
2022
+
2023
+ '@vitest/spy@4.1.9': {}
2024
+
2025
+ '@vitest/ui@4.1.9(vitest@4.1.9)':
2026
+ dependencies:
2027
+ '@vitest/utils': 4.1.9
2028
+ fflate: 0.8.2
2029
+ flatted: 3.4.2
2030
+ pathe: 2.0.3
2031
+ sirv: 3.0.2
2032
+ tinyglobby: 0.2.15
2033
+ tinyrainbow: 3.1.0
2034
+ vitest: 4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1))
2035
+
2036
+ '@vitest/utils@4.1.9':
2037
+ dependencies:
2038
+ '@vitest/pretty-format': 4.1.9
2039
+ convert-source-map: 2.0.0
2040
+ tinyrainbow: 3.1.0
2041
+
2042
+ agent-base@7.1.4: {}
2043
+
2044
+ ansi-regex@5.0.1: {}
2045
+
2046
+ ansi-styles@5.2.0: {}
2047
+
2048
+ aria-query@5.3.0:
2049
+ dependencies:
2050
+ dequal: 2.0.3
2051
+
2052
+ assertion-error@2.0.1: {}
2053
+
2054
+ ast-v8-to-istanbul@1.0.4:
2055
+ dependencies:
2056
+ '@jridgewell/trace-mapping': 0.3.31
2057
+ estree-walker: 3.0.3
2058
+ js-tokens: 10.0.0
2059
+
2060
+ chai@6.2.2: {}
2061
+
2062
+ convert-source-map@2.0.0: {}
2063
+
2064
+ cssstyle@4.6.0:
2065
+ dependencies:
2066
+ '@asamuzakjp/css-color': 3.2.0
2067
+ rrweb-cssom: 0.8.0
2068
+
2069
+ data-urls@5.0.0:
2070
+ dependencies:
2071
+ whatwg-mimetype: 4.0.0
2072
+ whatwg-url: 14.2.0
2073
+
2074
+ debug@4.4.3:
2075
+ dependencies:
2076
+ ms: 2.1.3
2077
+
2078
+ decimal.js@10.6.0: {}
2079
+
2080
+ dequal@2.0.3: {}
2081
+
2082
+ detect-libc@2.1.2: {}
2083
+
2084
+ dom-accessibility-api@0.5.16: {}
2085
+
2086
+ enhanced-resolve@5.20.0:
2087
+ dependencies:
2088
+ graceful-fs: 4.2.11
2089
+ tapable: 2.3.0
2090
+
2091
+ entities@6.0.1: {}
2092
+
2093
+ entities@7.0.1: {}
2094
+
2095
+ es-module-lexer@2.1.0: {}
2096
+
2097
+ esbuild@0.27.3:
2098
+ optionalDependencies:
2099
+ '@esbuild/aix-ppc64': 0.27.3
2100
+ '@esbuild/android-arm': 0.27.3
2101
+ '@esbuild/android-arm64': 0.27.3
2102
+ '@esbuild/android-x64': 0.27.3
2103
+ '@esbuild/darwin-arm64': 0.27.3
2104
+ '@esbuild/darwin-x64': 0.27.3
2105
+ '@esbuild/freebsd-arm64': 0.27.3
2106
+ '@esbuild/freebsd-x64': 0.27.3
2107
+ '@esbuild/linux-arm': 0.27.3
2108
+ '@esbuild/linux-arm64': 0.27.3
2109
+ '@esbuild/linux-ia32': 0.27.3
2110
+ '@esbuild/linux-loong64': 0.27.3
2111
+ '@esbuild/linux-mips64el': 0.27.3
2112
+ '@esbuild/linux-ppc64': 0.27.3
2113
+ '@esbuild/linux-riscv64': 0.27.3
2114
+ '@esbuild/linux-s390x': 0.27.3
2115
+ '@esbuild/linux-x64': 0.27.3
2116
+ '@esbuild/netbsd-arm64': 0.27.3
2117
+ '@esbuild/netbsd-x64': 0.27.3
2118
+ '@esbuild/openbsd-arm64': 0.27.3
2119
+ '@esbuild/openbsd-x64': 0.27.3
2120
+ '@esbuild/openharmony-arm64': 0.27.3
2121
+ '@esbuild/sunos-x64': 0.27.3
2122
+ '@esbuild/win32-arm64': 0.27.3
2123
+ '@esbuild/win32-ia32': 0.27.3
2124
+ '@esbuild/win32-x64': 0.27.3
2125
+
2126
+ estree-walker@3.0.3:
2127
+ dependencies:
2128
+ '@types/estree': 1.0.8
2129
+
2130
+ expect-type@1.3.0: {}
2131
+
2132
+ fdir@6.5.0(picomatch@4.0.3):
2133
+ optionalDependencies:
2134
+ picomatch: 4.0.3
2135
+
2136
+ fflate@0.8.2: {}
2137
+
2138
+ flatted@3.4.2: {}
2139
+
2140
+ fsevents@2.3.2:
2141
+ optional: true
2142
+
2143
+ fsevents@2.3.3:
2144
+ optional: true
2145
+
2146
+ graceful-fs@4.2.11: {}
2147
+
2148
+ happy-dom@20.8.9:
2149
+ dependencies:
2150
+ '@types/node': 26.0.1
2151
+ '@types/whatwg-mimetype': 3.0.2
2152
+ '@types/ws': 8.18.1
2153
+ entities: 7.0.1
2154
+ whatwg-mimetype: 3.0.0
2155
+ ws: 8.21.0
2156
+ transitivePeerDependencies:
2157
+ - bufferutil
2158
+ - utf-8-validate
2159
+
2160
+ has-flag@4.0.0: {}
2161
+
2162
+ heroicons@2.2.0: {}
2163
+
2164
+ html-encoding-sniffer@4.0.0:
2165
+ dependencies:
2166
+ whatwg-encoding: 3.1.1
2167
+
2168
+ html-escaper@2.0.2: {}
2169
+
2170
+ http-proxy-agent@7.0.2:
2171
+ dependencies:
2172
+ agent-base: 7.1.4
2173
+ debug: 4.4.3
2174
+ transitivePeerDependencies:
2175
+ - supports-color
2176
+
2177
+ https-proxy-agent@7.0.6:
2178
+ dependencies:
2179
+ agent-base: 7.1.4
2180
+ debug: 4.4.3
2181
+ transitivePeerDependencies:
2182
+ - supports-color
2183
+
2184
+ iconv-lite@0.6.3:
2185
+ dependencies:
2186
+ safer-buffer: 2.1.2
2187
+
2188
+ is-extglob@2.1.1: {}
2189
+
2190
+ is-glob@4.0.3:
2191
+ dependencies:
2192
+ is-extglob: 2.1.1
2193
+
2194
+ is-potential-custom-element-name@1.0.1: {}
2195
+
2196
+ istanbul-lib-coverage@3.2.2: {}
2197
+
2198
+ istanbul-lib-report@3.0.1:
2199
+ dependencies:
2200
+ istanbul-lib-coverage: 3.2.2
2201
+ make-dir: 4.0.0
2202
+ supports-color: 7.2.0
2203
+
2204
+ istanbul-reports@3.2.0:
2205
+ dependencies:
2206
+ html-escaper: 2.0.2
2207
+ istanbul-lib-report: 3.0.1
2208
+
2209
+ jiti@2.6.1: {}
2210
+
2211
+ js-tokens@10.0.0: {}
2212
+
2213
+ js-tokens@4.0.0: {}
2214
+
2215
+ jsdom@26.1.0(@napi-rs/canvas@0.1.95):
2216
+ dependencies:
2217
+ cssstyle: 4.6.0
2218
+ data-urls: 5.0.0
2219
+ decimal.js: 10.6.0
2220
+ html-encoding-sniffer: 4.0.0
2221
+ http-proxy-agent: 7.0.2
2222
+ https-proxy-agent: 7.0.6
2223
+ is-potential-custom-element-name: 1.0.1
2224
+ nwsapi: 2.2.23
2225
+ parse5: 7.3.0
2226
+ rrweb-cssom: 0.8.0
2227
+ saxes: 6.0.0
2228
+ symbol-tree: 3.2.4
2229
+ tough-cookie: 5.1.2
2230
+ w3c-xmlserializer: 5.0.0
2231
+ webidl-conversions: 7.0.0
2232
+ whatwg-encoding: 3.1.1
2233
+ whatwg-mimetype: 4.0.0
2234
+ whatwg-url: 14.2.0
2235
+ ws: 8.19.0
2236
+ xml-name-validator: 5.0.0
2237
+ optionalDependencies:
2238
+ canvas: '@napi-rs/canvas@0.1.95'
2239
+ transitivePeerDependencies:
2240
+ - bufferutil
2241
+ - supports-color
2242
+ - utf-8-validate
2243
+
2244
+ lightningcss-android-arm64@1.31.1:
2245
+ optional: true
2246
+
2247
+ lightningcss-darwin-arm64@1.31.1:
2248
+ optional: true
2249
+
2250
+ lightningcss-darwin-x64@1.31.1:
2251
+ optional: true
2252
+
2253
+ lightningcss-freebsd-x64@1.31.1:
2254
+ optional: true
2255
+
2256
+ lightningcss-linux-arm-gnueabihf@1.31.1:
2257
+ optional: true
2258
+
2259
+ lightningcss-linux-arm64-gnu@1.31.1:
2260
+ optional: true
2261
+
2262
+ lightningcss-linux-arm64-musl@1.31.1:
2263
+ optional: true
2264
+
2265
+ lightningcss-linux-x64-gnu@1.31.1:
2266
+ optional: true
2267
+
2268
+ lightningcss-linux-x64-musl@1.31.1:
2269
+ optional: true
2270
+
2271
+ lightningcss-win32-arm64-msvc@1.31.1:
2272
+ optional: true
2273
+
2274
+ lightningcss-win32-x64-msvc@1.31.1:
2275
+ optional: true
2276
+
2277
+ lightningcss@1.31.1:
2278
+ dependencies:
2279
+ detect-libc: 2.1.2
2280
+ optionalDependencies:
2281
+ lightningcss-android-arm64: 1.31.1
2282
+ lightningcss-darwin-arm64: 1.31.1
2283
+ lightningcss-darwin-x64: 1.31.1
2284
+ lightningcss-freebsd-x64: 1.31.1
2285
+ lightningcss-linux-arm-gnueabihf: 1.31.1
2286
+ lightningcss-linux-arm64-gnu: 1.31.1
2287
+ lightningcss-linux-arm64-musl: 1.31.1
2288
+ lightningcss-linux-x64-gnu: 1.31.1
2289
+ lightningcss-linux-x64-musl: 1.31.1
2290
+ lightningcss-win32-arm64-msvc: 1.31.1
2291
+ lightningcss-win32-x64-msvc: 1.31.1
2292
+
2293
+ lru-cache@10.4.3: {}
2294
+
2295
+ lz-string@1.5.0: {}
2296
+
2297
+ magic-string@0.30.21:
2298
+ dependencies:
2299
+ '@jridgewell/sourcemap-codec': 1.5.5
2300
+
2301
+ magicast@0.5.2:
2302
+ dependencies:
2303
+ '@babel/parser': 7.29.0
2304
+ '@babel/types': 7.29.0
2305
+ source-map-js: 1.2.1
2306
+
2307
+ make-dir@4.0.0:
2308
+ dependencies:
2309
+ semver: 7.7.4
2310
+
2311
+ mri@1.2.0: {}
2312
+
2313
+ mrmime@2.0.1: {}
2314
+
2315
+ ms@2.1.3: {}
2316
+
2317
+ nanoid@3.3.11: {}
2318
+
2319
+ node-addon-api@7.1.1: {}
2320
+
2321
+ nwsapi@2.2.23: {}
2322
+
2323
+ obug@2.1.1: {}
2324
+
2325
+ parse5@7.3.0:
2326
+ dependencies:
2327
+ entities: 6.0.1
2328
+
2329
+ pathe@2.0.3: {}
2330
+
2331
+ picocolors@1.1.1: {}
2332
+
2333
+ picomatch@4.0.3: {}
2334
+
2335
+ playwright-core@1.58.2: {}
2336
+
2337
+ playwright@1.58.2:
2338
+ dependencies:
2339
+ playwright-core: 1.58.2
2340
+ optionalDependencies:
2341
+ fsevents: 2.3.2
2342
+
2343
+ postcss@8.5.6:
2344
+ dependencies:
2345
+ nanoid: 3.3.11
2346
+ picocolors: 1.1.1
2347
+ source-map-js: 1.2.1
2348
+
2349
+ pretty-format@27.5.1:
2350
+ dependencies:
2351
+ ansi-regex: 5.0.1
2352
+ ansi-styles: 5.2.0
2353
+ react-is: 17.0.2
2354
+
2355
+ punycode@2.3.1: {}
2356
+
2357
+ react-is@17.0.2: {}
2358
+
2359
+ rollup@4.59.0:
2360
+ dependencies:
2361
+ '@types/estree': 1.0.8
2362
+ optionalDependencies:
2363
+ '@rollup/rollup-android-arm-eabi': 4.59.0
2364
+ '@rollup/rollup-android-arm64': 4.59.0
2365
+ '@rollup/rollup-darwin-arm64': 4.59.0
2366
+ '@rollup/rollup-darwin-x64': 4.59.0
2367
+ '@rollup/rollup-freebsd-arm64': 4.59.0
2368
+ '@rollup/rollup-freebsd-x64': 4.59.0
2369
+ '@rollup/rollup-linux-arm-gnueabihf': 4.59.0
2370
+ '@rollup/rollup-linux-arm-musleabihf': 4.59.0
2371
+ '@rollup/rollup-linux-arm64-gnu': 4.59.0
2372
+ '@rollup/rollup-linux-arm64-musl': 4.59.0
2373
+ '@rollup/rollup-linux-loong64-gnu': 4.59.0
2374
+ '@rollup/rollup-linux-loong64-musl': 4.59.0
2375
+ '@rollup/rollup-linux-ppc64-gnu': 4.59.0
2376
+ '@rollup/rollup-linux-ppc64-musl': 4.59.0
2377
+ '@rollup/rollup-linux-riscv64-gnu': 4.59.0
2378
+ '@rollup/rollup-linux-riscv64-musl': 4.59.0
2379
+ '@rollup/rollup-linux-s390x-gnu': 4.59.0
2380
+ '@rollup/rollup-linux-x64-gnu': 4.59.0
2381
+ '@rollup/rollup-linux-x64-musl': 4.59.0
2382
+ '@rollup/rollup-openbsd-x64': 4.59.0
2383
+ '@rollup/rollup-openharmony-arm64': 4.59.0
2384
+ '@rollup/rollup-win32-arm64-msvc': 4.59.0
2385
+ '@rollup/rollup-win32-ia32-msvc': 4.59.0
2386
+ '@rollup/rollup-win32-x64-gnu': 4.59.0
2387
+ '@rollup/rollup-win32-x64-msvc': 4.59.0
2388
+ fsevents: 2.3.3
2389
+
2390
+ rrweb-cssom@0.8.0: {}
2391
+
2392
+ safer-buffer@2.1.2: {}
2393
+
2394
+ saxes@6.0.0:
2395
+ dependencies:
2396
+ xmlchars: 2.2.0
2397
+
2398
+ semver@7.7.4: {}
2399
+
2400
+ siginfo@2.0.0: {}
2401
+
2402
+ sirv@3.0.2:
2403
+ dependencies:
2404
+ '@polka/url': 1.0.0-next.29
2405
+ mrmime: 2.0.1
2406
+ totalist: 3.0.1
2407
+
2408
+ source-map-js@1.2.1: {}
2409
+
2410
+ stackback@0.0.2: {}
2411
+
2412
+ std-env@4.1.0: {}
2413
+
2414
+ supports-color@7.2.0:
2415
+ dependencies:
2416
+ has-flag: 4.0.0
2417
+
2418
+ symbol-tree@3.2.4: {}
2419
+
2420
+ tailwindcss@4.2.1: {}
2421
+
2422
+ tapable@2.3.0: {}
2423
+
2424
+ tinybench@2.9.0: {}
2425
+
2426
+ tinyexec@1.0.2: {}
2427
+
2428
+ tinyglobby@0.2.15:
2429
+ dependencies:
2430
+ fdir: 6.5.0(picomatch@4.0.3)
2431
+ picomatch: 4.0.3
2432
+
2433
+ tinyrainbow@3.1.0: {}
2434
+
2435
+ tldts-core@6.1.86: {}
2436
+
2437
+ tldts@6.1.86:
2438
+ dependencies:
2439
+ tldts-core: 6.1.86
2440
+
2441
+ totalist@3.0.1: {}
2442
+
2443
+ tough-cookie@5.1.2:
2444
+ dependencies:
2445
+ tldts: 6.1.86
2446
+
2447
+ tr46@5.1.1:
2448
+ dependencies:
2449
+ punycode: 2.3.1
2450
+
2451
+ undici-types@8.3.0: {}
2452
+
2453
+ vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1):
2454
+ dependencies:
2455
+ esbuild: 0.27.3
2456
+ fdir: 6.5.0(picomatch@4.0.3)
2457
+ picomatch: 4.0.3
2458
+ postcss: 8.5.6
2459
+ rollup: 4.59.0
2460
+ tinyglobby: 0.2.15
2461
+ optionalDependencies:
2462
+ '@types/node': 26.0.1
2463
+ fsevents: 2.3.3
2464
+ jiti: 2.6.1
2465
+ lightningcss: 1.31.1
2466
+
2467
+ vitest@4.1.9(@types/node@26.0.1)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(happy-dom@20.8.9)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)):
2468
+ dependencies:
2469
+ '@vitest/expect': 4.1.9
2470
+ '@vitest/mocker': 4.1.9(vite@7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1))
2471
+ '@vitest/pretty-format': 4.1.9
2472
+ '@vitest/runner': 4.1.9
2473
+ '@vitest/snapshot': 4.1.9
2474
+ '@vitest/spy': 4.1.9
2475
+ '@vitest/utils': 4.1.9
2476
+ es-module-lexer: 2.1.0
2477
+ expect-type: 1.3.0
2478
+ magic-string: 0.30.21
2479
+ obug: 2.1.1
2480
+ pathe: 2.0.3
2481
+ picomatch: 4.0.3
2482
+ std-env: 4.1.0
2483
+ tinybench: 2.9.0
2484
+ tinyexec: 1.0.2
2485
+ tinyglobby: 0.2.15
2486
+ tinyrainbow: 3.1.0
2487
+ vite: 7.3.6(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.31.1)
2488
+ why-is-node-running: 2.3.0
2489
+ optionalDependencies:
2490
+ '@types/node': 26.0.1
2491
+ '@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
2492
+ '@vitest/ui': 4.1.9(vitest@4.1.9)
2493
+ happy-dom: 20.8.9
2494
+ jsdom: 26.1.0(@napi-rs/canvas@0.1.95)
2495
+ transitivePeerDependencies:
2496
+ - msw
2497
+
2498
+ w3c-xmlserializer@5.0.0:
2499
+ dependencies:
2500
+ xml-name-validator: 5.0.0
2501
+
2502
+ webidl-conversions@7.0.0: {}
2503
+
2504
+ whatwg-encoding@3.1.1:
2505
+ dependencies:
2506
+ iconv-lite: 0.6.3
2507
+
2508
+ whatwg-mimetype@3.0.0: {}
2509
+
2510
+ whatwg-mimetype@4.0.0: {}
2511
+
2512
+ whatwg-url@14.2.0:
2513
+ dependencies:
2514
+ tr46: 5.1.1
2515
+ webidl-conversions: 7.0.0
2516
+
2517
+ why-is-node-running@2.3.0:
2518
+ dependencies:
2519
+ siginfo: 2.0.0
2520
+ stackback: 0.0.2
2521
+
2522
+ ws@8.19.0: {}
2523
+
2524
+ ws@8.21.0: {}
2525
+
2526
+ xml-name-validator@5.0.0: {}
2527
+
2528
+ xmlchars@2.2.0: {}
pyproject.toml ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "starlette>=1.3.1",
25
+ "python-multipart>=0.0.31",
26
+ ]
27
+
28
+ dashboard = [
29
+ "uvicorn>=0.27.0",
30
+ "sse-starlette>=1.8.0",
31
+ "starlette>=1.3.1",
32
+ "aiofiles>=23.2.0",
33
+ "watchfiles>=1.1.1",
34
+ "pystache>=0.6.8",
35
+ "fastapi>=0.115.12",
36
+ "lttb>=0.3.2",
37
+ "numpy>=1.24.0",
38
+ "msgpack>=1.2.1",
39
+ ]
40
+
41
+ remote = [
42
+ "requests>=2.31.0",
43
+ "urllib3>=2.7.0",
44
+ "idna>=3.15",
45
+ ]
46
+
47
+ tui = [
48
+ "textual>=0.47.0",
49
+ "textual-plotext>=0.2.0",
50
+ ]
51
+
52
+ all = [
53
+ "aspara[tracker]",
54
+ "aspara[dashboard]",
55
+ "aspara[remote]",
56
+ "aspara[tui]",
57
+ "aspara[docs]",
58
+ ]
59
+
60
+ docs = [
61
+ "mkdocs>=1.6.0",
62
+ "mkdocs-material>=9.6.0",
63
+ "mkdocstrings[python]>=0.24.0",
64
+ "mkdocs-autorefs>=0.5.0",
65
+ ]
66
+
67
+ [project.scripts]
68
+ aspara = "aspara.cli:main"
69
+
70
+ [dependency-groups]
71
+ dev = [
72
+ "aspara[all]",
73
+ "pytest>=9.0.3",
74
+ "pytest-asyncio>=1.0.0",
75
+ "pytest-cov>=7.0.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
+ "httpx>=0.28.1",
83
+ ]
84
+
85
+ [tool.ruff]
86
+ line-length = 160
87
+ indent-width = 4
88
+ target-version = "py310"
89
+ extend-exclude = [".venv", "build", "dist"]
90
+ src = ["src"]
91
+
92
+ [tool.ruff.lint]
93
+ select = ["E", "F", "W", "B", "I"]
94
+ extend-select = [
95
+ "C4", # flake8-comprehensions
96
+ "SIM", # flake8-simplify
97
+ "ERA", # eradicate
98
+ "UP", # pyupgrade
99
+ ]
100
+ extend-ignore = ["SIM108"]
101
+
102
+ [tool.pyrefly]
103
+ project-includes = [
104
+ "src/**/*.py*",
105
+ "tests/**/*.py*",
106
+ ]
107
+
108
+
109
+ [tool.ruff.format]
110
+ quote-style = "double"
111
+ indent-style = "space"
112
+ preview = true
113
+ line-ending = "auto"
114
+ docstring-code-format = true
115
+
116
+ [tool.ruff.lint.isort]
117
+ known-first-party = ["aspara"]
118
+ section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
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,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 os
10
+ import shutil
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from aspara.exceptions import ProjectNotFoundError
18
+ from aspara.storage import ProjectMetadataStorage
19
+ from aspara.utils.validators import validate_name, validate_safe_path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ProjectInfo(BaseModel):
25
+ """Project information."""
26
+
27
+ name: str
28
+ run_count: int
29
+ last_update: datetime
30
+
31
+
32
+ # Run data file suffixes used when discovering and counting runs.
33
+ _RUN_DATA_SUFFIXES = (".jsonl", ".db", ".wal")
34
+ _RUN_EXCLUDED_SUFFIXES = (".wal.jsonl", ".meta.jsonl")
35
+
36
+
37
+ class ProjectCatalog:
38
+ """Catalog for discovering and managing projects.
39
+
40
+ This class provides methods to list, get, and delete projects
41
+ in the data directory. It does not handle metrics data directly;
42
+ that responsibility belongs to MetricsStorage.
43
+ """
44
+
45
+ def __init__(self, data_dir: str | Path) -> None:
46
+ """Initialize the project catalog.
47
+
48
+ Args:
49
+ data_dir: Base directory for data storage
50
+ """
51
+ self.data_dir = Path(data_dir)
52
+
53
+ def _scan_projects(self, *, include_metadata: bool = False) -> list[tuple[ProjectInfo, dict[str, Any] | None]]:
54
+ """Scan the data directory and build project information.
55
+
56
+ Args:
57
+ include_metadata: Whether to load project metadata.json during the scan.
58
+
59
+ Returns:
60
+ List of (ProjectInfo, metadata) tuples. ``metadata`` is ``None`` when
61
+ ``include_metadata`` is ``False``.
62
+ """
63
+ results: list[tuple[ProjectInfo, dict[str, Any] | None]] = []
64
+ if not self.data_dir.exists():
65
+ return results
66
+
67
+ try:
68
+ # Use scandir for efficient iteration with cached stat info
69
+ with os.scandir(self.data_dir) as project_entries:
70
+ for project_entry in project_entries:
71
+ if not project_entry.is_dir():
72
+ continue
73
+
74
+ # Skip hidden/reserved directories (e.g. .queue)
75
+ if project_entry.name.startswith("."):
76
+ continue
77
+
78
+ # Collect run files and optionally metadata in a single pass
79
+ run_files_mtime: list[float] = []
80
+ metadata: dict[str, Any] | None = None
81
+ with os.scandir(project_entry.path) as file_entries:
82
+ for file_entry in file_entries:
83
+ if include_metadata and file_entry.name == "metadata.json":
84
+ metadata = ProjectMetadataStorage.load_metadata_file(Path(file_entry.path))
85
+ elif file_entry.name.endswith(_RUN_DATA_SUFFIXES) and not file_entry.name.endswith(_RUN_EXCLUDED_SUFFIXES):
86
+ # stat() result is cached by scandir
87
+ run_files_mtime.append(file_entry.stat().st_mtime)
88
+
89
+ if include_metadata and metadata is None:
90
+ metadata = ProjectMetadataStorage.default_metadata()
91
+
92
+ run_count = len(run_files_mtime)
93
+
94
+ # Find last update time - use cached stat from scandir
95
+ if run_files_mtime:
96
+ last_update = datetime.fromtimestamp(max(run_files_mtime), tz=timezone.utc)
97
+ else:
98
+ last_update = datetime.fromtimestamp(project_entry.stat().st_mtime, tz=timezone.utc)
99
+
100
+ results.append((
101
+ ProjectInfo(
102
+ name=project_entry.name,
103
+ run_count=run_count,
104
+ last_update=last_update,
105
+ ),
106
+ metadata,
107
+ ))
108
+ except (OSError, PermissionError):
109
+ pass
110
+
111
+ return sorted(results, key=lambda item: item[0].name)
112
+
113
+ def get_projects(self) -> list[ProjectInfo]:
114
+ """List all projects in the data directory.
115
+
116
+ Uses os.scandir() for efficient directory iteration with cached stat info.
117
+
118
+ Returns:
119
+ List of ProjectInfo objects sorted by name
120
+ """
121
+ return [project for project, _ in self._scan_projects(include_metadata=False)]
122
+
123
+ def get_projects_with_metadata(self) -> list[tuple[ProjectInfo, dict[str, Any]]]:
124
+ """List all projects with their metadata in a single directory pass.
125
+
126
+ Returns:
127
+ List of (ProjectInfo, metadata) tuples sorted by project name.
128
+ """
129
+ return [(project, metadata) for project, metadata in self._scan_projects(include_metadata=True) if metadata is not None]
130
+
131
+ def get(self, name: str) -> ProjectInfo:
132
+ """Get a specific project by name.
133
+
134
+ Args:
135
+ name: Project name
136
+
137
+ Returns:
138
+ ProjectInfo object
139
+
140
+ Raises:
141
+ ValueError: If project name is invalid
142
+ ProjectNotFoundError: If project does not exist
143
+ """
144
+ validate_name(name, "project name")
145
+
146
+ project_dir = self.data_dir / name
147
+ validate_safe_path(project_dir, self.data_dir)
148
+
149
+ if not project_dir.exists() or not project_dir.is_dir():
150
+ raise ProjectNotFoundError(f"Project '{name}' not found")
151
+
152
+ # Count runs and collect mtimes in a single scandir pass, using the same
153
+ # suffix logic as get_projects() for consistency and reduced I/O.
154
+ run_files_mtime: list[float] = []
155
+ try:
156
+ with os.scandir(project_dir) as file_entries:
157
+ for file_entry in file_entries:
158
+ if file_entry.name.endswith(_RUN_DATA_SUFFIXES) and not file_entry.name.endswith(_RUN_EXCLUDED_SUFFIXES):
159
+ run_files_mtime.append(file_entry.stat().st_mtime)
160
+ except (OSError, PermissionError):
161
+ pass
162
+
163
+ run_count = len(run_files_mtime)
164
+
165
+ # Get last update time from run files
166
+ last_update = datetime.fromtimestamp(project_dir.stat().st_mtime, tz=timezone.utc)
167
+ if run_files_mtime:
168
+ last_update = datetime.fromtimestamp(max(run_files_mtime), tz=timezone.utc)
169
+
170
+ return ProjectInfo(
171
+ name=name,
172
+ run_count=run_count,
173
+ last_update=last_update,
174
+ )
175
+
176
+ def exists(self, name: str) -> bool:
177
+ """Check if a project exists.
178
+
179
+ Args:
180
+ name: Project name
181
+
182
+ Returns:
183
+ True if project exists, False otherwise
184
+ """
185
+ try:
186
+ validate_name(name, "project name")
187
+ project_dir = self.data_dir / name
188
+ validate_safe_path(project_dir, self.data_dir)
189
+ return project_dir.exists() and project_dir.is_dir()
190
+ except ValueError:
191
+ return False
192
+
193
+ def delete(self, name: str) -> None:
194
+ """Delete a project and all its runs.
195
+
196
+ Args:
197
+ name: Project name to delete
198
+
199
+ Raises:
200
+ ValueError: If project name is empty or invalid
201
+ ProjectNotFoundError: If project does not exist
202
+ PermissionError: If deletion is not permitted
203
+ """
204
+ if not name:
205
+ raise ValueError("Project name cannot be empty")
206
+
207
+ validate_name(name, "project name")
208
+
209
+ project_dir = self.data_dir / name
210
+ validate_safe_path(project_dir, self.data_dir)
211
+
212
+ if not project_dir.exists():
213
+ raise ProjectNotFoundError(f"Project '{name}' does not exist")
214
+
215
+ try:
216
+ shutil.rmtree(project_dir)
217
+ logger.info(f"Successfully deleted project: {name}")
218
+ except (PermissionError, OSError) as e:
219
+ logger.error(f"Error deleting project {name}: {type(e).__name__}")
220
+ raise
221
+
222
+ def get_metadata(self, name: str) -> dict[str, Any]:
223
+ """Get project-level metadata.json for a project.
224
+
225
+ Returns a dictionary with notes, tags, created_at, updated_at fields.
226
+ """
227
+ storage = ProjectMetadataStorage(self.data_dir, name)
228
+ return storage.get_metadata()
229
+
230
+ def update_metadata(self, name: str, metadata: dict[str, Any]) -> dict[str, Any]:
231
+ """Update project-level metadata.json for a project.
232
+
233
+ The metadata dict may contain partial fields (notes, tags).
234
+ Validation and timestamp handling is delegated to ProjectMetadataStorage.
235
+ """
236
+ storage = ProjectMetadataStorage(self.data_dir, name)
237
+ return storage.update_metadata(metadata)
238
+
239
+ def delete_metadata(self, name: str) -> bool:
240
+ """Delete project-level metadata.json for a project."""
241
+ storage = ProjectMetadataStorage(self.data_dir, name)
242
+ return storage.delete_metadata()
src/aspara/catalog/run_catalog.py ADDED
@@ -0,0 +1,764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 or names are invalid
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
+ run_name = filename[:-10]
274
+ file_type = "wal"
275
+ elif filename.endswith(".meta.json"):
276
+ run_name = filename[:-10]
277
+ file_type = "meta"
278
+ elif filename.endswith(".jsonl"):
279
+ run_name = filename[:-6]
280
+ file_type = "metrics"
281
+ else:
282
+ return None
283
+
284
+ # Validate project and run names so that reserved/hidden directories
285
+ # (e.g. .queue) or names with path-traversal characters are rejected
286
+ # at parse time rather than leaking into downstream consumers.
287
+ try:
288
+ validate_name(project, "project name")
289
+ validate_name(run_name, "run name")
290
+ except ValueError:
291
+ return None
292
+
293
+ return (project, run_name, file_type)
294
+
295
+ def _read_run_info(self, project: str, run_name: str, run_file: Path) -> RunInfo:
296
+ """Read run information from JSONL metrics file and metadata file.
297
+
298
+ Supports both JSONL and Polars backends.
299
+ Optimization: Avoids loading full DataFrame when metadata provides sufficient info.
300
+
301
+ Args:
302
+ project: Project name
303
+ run_name: Run name
304
+ run_file: Path to the JSONL metrics file
305
+
306
+ Returns:
307
+ RunInfo object with metadata from both files
308
+ """
309
+ metadata_file = run_file.parent / f"{run_name}.meta.json"
310
+
311
+ # Read metadata
312
+ metadata = _read_metadata_file(metadata_file)
313
+ run_id = metadata.get("run_id")
314
+ tags = metadata.get("tags", [])
315
+ is_finished = metadata.get("is_finished", False)
316
+ exit_code = metadata.get("exit_code")
317
+
318
+ # Read params count
319
+ params = metadata.get("params", {})
320
+ params_count = len(params) if isinstance(params, dict) else 0
321
+
322
+ # Parse status
323
+ status_value = metadata.get("status", RunStatus.WIP.value)
324
+ try:
325
+ status = RunStatus(status_value)
326
+ except ValueError:
327
+ status = RunStatus.from_is_finished_and_exit_code(is_finished, exit_code)
328
+
329
+ # Parse start_time from metadata
330
+ start_time = None
331
+ start_time_value = metadata.get("start_time")
332
+ if start_time_value is not None:
333
+ with contextlib.suppress(ValueError):
334
+ start_time = parse_to_datetime(start_time_value)
335
+
336
+ # Infer stale status
337
+ status = _infer_stale_status(status, start_time, is_finished)
338
+
339
+ # Lightweight corruption check: file exists and is not empty.
340
+ # Use try/except instead of exists()→stat() to avoid TOCTOU race
341
+ # when the file is deleted between the two calls (e.g. watcher and
342
+ # delete API running concurrently).
343
+ is_corrupted = False
344
+ error_message = None
345
+ last_update = None
346
+
347
+ run_exists = False
348
+ run_size = 0
349
+ try:
350
+ run_stat = run_file.stat()
351
+ run_exists = True
352
+ run_size = run_stat.st_size
353
+ last_update = datetime.fromtimestamp(run_stat.st_mtime, tz=timezone.utc)
354
+ except FileNotFoundError:
355
+ pass
356
+
357
+ metadata_exists = metadata_file.exists()
358
+
359
+ if not run_exists and not metadata_exists:
360
+ is_corrupted = True
361
+ error_message = "Run file not found"
362
+ elif run_exists and run_size == 0 and not metadata_exists:
363
+ is_corrupted = True
364
+ error_message = "Empty file! No data found!"
365
+
366
+ return RunInfo(
367
+ name=run_name,
368
+ run_id=run_id,
369
+ start_time=start_time,
370
+ last_update=last_update,
371
+ param_count=params_count,
372
+ artifact_count=0,
373
+ tags=tags,
374
+ is_corrupted=is_corrupted,
375
+ error_message=error_message,
376
+ is_finished=is_finished,
377
+ exit_code=exit_code,
378
+ status=status,
379
+ )
380
+
381
+ def get_runs(self, project: str) -> list[RunInfo]:
382
+ """List all runs in a project.
383
+
384
+ Args:
385
+ project: Project name
386
+
387
+ Returns:
388
+ List of RunInfo objects sorted by name
389
+
390
+ Raises:
391
+ ValueError: If project name is invalid
392
+ ProjectNotFoundError: If project does not exist
393
+ """
394
+ validate_name(project, "project name")
395
+
396
+ project_dir = self.data_dir / project
397
+ validate_safe_path(project_dir, self.data_dir)
398
+
399
+ if not project_dir.exists():
400
+ raise ProjectNotFoundError(f"Project '{project}' not found")
401
+
402
+ runs = []
403
+ seen_run_names: set[str] = set()
404
+
405
+ # Process .jsonl files (including .wal.jsonl for Polars backend).
406
+ # Iterate the glob generator directly without list() to avoid
407
+ # materialising all paths upfront. TOCTOU races (file deleted
408
+ # between discovery and read) are handled by _read_run_info,
409
+ # which catches FileNotFoundError on stat().
410
+ for run_file in project_dir.glob("*.jsonl"):
411
+ # Determine run name from file
412
+ if run_file.name.endswith(".wal.jsonl"):
413
+ # Skip WAL files - they're handled by metadata
414
+ continue
415
+ else:
416
+ run_name = run_file.stem
417
+
418
+ # Skip if we've already processed this run
419
+ if run_name in seen_run_names:
420
+ continue
421
+ seen_run_names.add(run_name)
422
+
423
+ # Handle plain JSONL files
424
+ run = self._read_run_info(project, run_name, run_file)
425
+ runs.append(run)
426
+
427
+ return sorted(runs, key=lambda r: r.name)
428
+
429
+ def get(self, project: str, run: str) -> RunInfo:
430
+ """Get a specific run.
431
+
432
+ Args:
433
+ project: Project name
434
+ run: Run name
435
+
436
+ Returns:
437
+ RunInfo object
438
+
439
+ Raises:
440
+ ValueError: If project or run name is invalid
441
+ ProjectNotFoundError: If project does not exist
442
+ RunNotFoundError: If run does not exist
443
+ """
444
+ validate_name(project, "project name")
445
+ validate_name(run, "run name")
446
+
447
+ project_dir = self.data_dir / project
448
+ validate_safe_path(project_dir, self.data_dir)
449
+
450
+ if not project_dir.exists():
451
+ raise ProjectNotFoundError(f"Project '{project}' not found")
452
+
453
+ # Check for JSONL file
454
+ jsonl_file = project_dir / f"{run}.jsonl"
455
+
456
+ if jsonl_file.exists():
457
+ return self._read_run_info(project, run, jsonl_file)
458
+ else:
459
+ raise RunNotFoundError(f"Run '{run}' not found in project '{project}'")
460
+
461
+ def delete(self, project: str, run: str) -> None:
462
+ """Delete a run and its artifacts.
463
+
464
+ Args:
465
+ project: Project name
466
+ run: Run name to delete
467
+
468
+ Raises:
469
+ ValueError: If project or run name is empty or invalid
470
+ ProjectNotFoundError: If project does not exist
471
+ RunNotFoundError: If run does not exist
472
+ PermissionError: If deletion is not permitted
473
+ """
474
+ if not project:
475
+ raise ValueError("Project name cannot be empty")
476
+ if not run:
477
+ raise ValueError("Run name cannot be empty")
478
+
479
+ validate_name(project, "project name")
480
+ validate_name(run, "run name")
481
+
482
+ project_dir = self.data_dir / project
483
+ validate_safe_path(project_dir, self.data_dir)
484
+
485
+ if not project_dir.exists():
486
+ raise ProjectNotFoundError(f"Project '{project}' does not exist")
487
+
488
+ # Check for any run files
489
+ wal_file = project_dir / f"{run}.wal.jsonl"
490
+ jsonl_file = project_dir / f"{run}.jsonl"
491
+
492
+ if not wal_file.exists() and not jsonl_file.exists():
493
+ raise RunNotFoundError(f"Run '{run}' does not exist in project '{project}'")
494
+
495
+ try:
496
+ # Delete all run-related files
497
+ metadata_file = project_dir / f"{run}.meta.json"
498
+ for file_path in [wal_file, jsonl_file, metadata_file]:
499
+ if file_path.exists():
500
+ file_path.unlink()
501
+ logger.debug(f"Deleted file: {file_path}")
502
+
503
+ # Delete artifacts directory if it exists
504
+ artifacts_dir = project_dir / run / "artifacts"
505
+ run_dir = project_dir / run
506
+
507
+ if artifacts_dir.exists():
508
+ shutil.rmtree(artifacts_dir)
509
+ logger.debug(f"Deleted artifacts for {project}/{run}")
510
+
511
+ # Delete run directory if it exists and is empty
512
+ if run_dir.exists():
513
+ try:
514
+ run_dir.rmdir()
515
+ logger.debug(f"Deleted run directory for {project}/{run}")
516
+ except OSError:
517
+ pass
518
+
519
+ logger.info(f"Successfully deleted run: {project}/{run}")
520
+ except (PermissionError, OSError) as e:
521
+ logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}")
522
+ raise
523
+
524
+ def exists(self, project: str, run: str) -> bool:
525
+ """Check if a run exists.
526
+
527
+ Args:
528
+ project: Project name
529
+ run: Run name
530
+
531
+ Returns:
532
+ True if run exists, False otherwise
533
+ """
534
+ try:
535
+ validate_name(project, "project name")
536
+ validate_name(run, "run name")
537
+
538
+ project_dir = self.data_dir / project
539
+ validate_safe_path(project_dir, self.data_dir)
540
+
541
+ wal_file = project_dir / f"{run}.wal.jsonl"
542
+ jsonl_file = project_dir / f"{run}.jsonl"
543
+
544
+ return wal_file.exists() or jsonl_file.exists()
545
+ except ValueError:
546
+ return False
547
+
548
+ async def subscribe(
549
+ self,
550
+ targets: Mapping[str, list[str] | None],
551
+ since: datetime,
552
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
553
+ """Subscribe to file changes for specified targets using DataDirWatcher.
554
+
555
+ This method uses a singleton DataDirWatcher instance to minimize inotify
556
+ file descriptor usage. Multiple SSE connections share the same watcher.
557
+
558
+ Args:
559
+ targets: Dictionary mapping project names to list of run names.
560
+ If run list is None, all runs in the project are watched.
561
+ since: Filter to only yield records with timestamp >= since
562
+
563
+ Yields:
564
+ MetricRecord or StatusRecord as files are updated
565
+ """
566
+ from aspara.catalog.watcher import DataDirWatcher
567
+
568
+ watcher = await DataDirWatcher.get_instance(self.data_dir)
569
+ async for record in watcher.subscribe(targets, since):
570
+ yield record
571
+
572
+ def get_artifacts(self, project: str, run: str) -> list[dict]:
573
+ """Get artifacts for a run from metadata file.
574
+
575
+ Args:
576
+ project: Project name
577
+ run: Run name
578
+
579
+ Returns:
580
+ List of artifact dictionaries
581
+ """
582
+ validate_name(project, "project name")
583
+ validate_name(run, "run name")
584
+
585
+ # Read from metadata file
586
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
587
+ validate_safe_path(metadata_file, self.data_dir)
588
+
589
+ if metadata_file.exists():
590
+ try:
591
+ with open(metadata_file) as f:
592
+ metadata = json.load(f)
593
+ return metadata.get("artifacts", [])
594
+ except Exception as e:
595
+ logger.warning(f"Error reading artifacts from metadata file for {run}: {e}")
596
+
597
+ return []
598
+
599
+ def get_metadata(self, project: str, run: str) -> dict:
600
+ """Get run metadata from .meta.json file.
601
+
602
+ Args:
603
+ project: Project name
604
+ run: Run name
605
+
606
+ Returns:
607
+ Dictionary containing run metadata
608
+ """
609
+ storage = RunMetadataStorage(self.data_dir, project, run)
610
+ return storage.get_metadata()
611
+
612
+ def update_metadata(self, project: str, run: str, metadata: dict) -> dict:
613
+ """Update run metadata in .meta.json file.
614
+
615
+ Args:
616
+ project: Project name
617
+ run: Run name
618
+ metadata: Dictionary with fields to update (notes, tags)
619
+
620
+ Returns:
621
+ Updated complete metadata dictionary
622
+ """
623
+ storage = RunMetadataStorage(self.data_dir, project, run)
624
+ return storage.update_metadata(metadata)
625
+
626
+ def delete_metadata(self, project: str, run: str) -> bool:
627
+ """Delete run metadata file.
628
+
629
+ Args:
630
+ project: Project name
631
+ run: Run name
632
+
633
+ Returns:
634
+ True if file was deleted, False if it didn't exist
635
+ """
636
+ storage = RunMetadataStorage(self.data_dir, project, run)
637
+ return storage.delete_metadata()
638
+
639
+ def _guess_artifact_category(self, filename: str) -> str:
640
+ """Guess artifact category from file extension.
641
+
642
+ Args:
643
+ filename: Name of the artifact file
644
+
645
+ Returns:
646
+ Category string
647
+ """
648
+ ext = filename.lower().split(".")[-1] if "." in filename else ""
649
+
650
+ if ext in ["py", "js", "ts", "jsx", "tsx", "cpp", "c", "h", "java", "go", "rs", "rb", "php"]:
651
+ return "code"
652
+ if ext in ["yaml", "yml", "json", "toml", "ini", "cfg", "conf", "env"]:
653
+ return "config"
654
+ if ext in ["pt", "pth", "pkl", "pickle", "h5", "hdf5", "onnx", "pb", "tflite", "joblib"]:
655
+ return "model"
656
+ if ext in ["csv", "tsv", "parquet", "feather", "xlsx", "xls", "hdf", "npy", "npz"]:
657
+ return "data"
658
+
659
+ return "other"
660
+
661
+ def load_metrics(
662
+ self,
663
+ project: str,
664
+ run: str,
665
+ start_time: datetime | None = None,
666
+ ) -> pl.DataFrame:
667
+ """Load metrics for a run in wide format (auto-detects storage backend).
668
+
669
+ Args:
670
+ project: Project name
671
+ run: Run name
672
+ start_time: Optional start time to filter metrics from
673
+
674
+ Returns:
675
+ Polars DataFrame in wide format with columns:
676
+ - timestamp: Datetime
677
+ - step: Int64
678
+ - _<metric_name>: Float64 for each metric (underscore-prefixed)
679
+
680
+ Raises:
681
+ ValueError: If project or run name is invalid
682
+ RunNotFoundError: If run does not exist
683
+ """
684
+ validate_name(project, "project name")
685
+ validate_name(run, "run name")
686
+
687
+ # Create storage using factory function and load metrics
688
+ storage = _open_metrics_storage(self.data_dir, project, run)
689
+
690
+ try:
691
+ df = storage.load()
692
+ except Exception as e:
693
+ logger.warning(f"Failed to load metrics for {project}/{run}: {e}")
694
+ return pl.DataFrame(
695
+ schema={
696
+ "timestamp": pl.Datetime,
697
+ "step": pl.Int64,
698
+ }
699
+ )
700
+
701
+ # Apply start_time filter if specified
702
+ if start_time is not None and len(df) > 0:
703
+ df = df.filter(pl.col("timestamp") >= start_time)
704
+
705
+ return df
706
+
707
+ def get_run_config(self, project: str, run: str) -> dict[str, Any]:
708
+ """Get run config from .meta.json file.
709
+
710
+ This reads the .meta.json file which contains params, config, status, etc.
711
+ Different from get_metadata which uses ProjectMetadataStorage for notes/tags.
712
+
713
+ Args:
714
+ project: Project name
715
+ run: Run name
716
+
717
+ Returns:
718
+ Dictionary containing run config (params, config, status, etc.)
719
+ """
720
+ validate_name(project, "project name")
721
+ validate_name(run, "run name")
722
+
723
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
724
+ validate_safe_path(metadata_file, self.data_dir)
725
+
726
+ return _read_metadata_file(metadata_file)
727
+
728
+ async def get_run_config_async(self, project: str, run: str) -> dict[str, Any]:
729
+ """Get run config asynchronously using run_in_executor.
730
+
731
+ This reads the .meta.json file which contains params, config, status, etc.
732
+
733
+ Args:
734
+ project: Project name
735
+ run: Run name
736
+
737
+ Returns:
738
+ Dictionary containing run config (params, config, status, etc.)
739
+ """
740
+ return await asyncio.to_thread(self.get_run_config, project, run)
741
+
742
+ async def get_metadata_async(self, project: str, run: str) -> dict[str, Any]:
743
+ """Get run metadata asynchronously using run_in_executor.
744
+
745
+ Args:
746
+ project: Project name
747
+ run: Run name
748
+
749
+ Returns:
750
+ Dictionary containing run metadata (tags, notes, params, etc.)
751
+ """
752
+ return await asyncio.to_thread(self.get_metadata, project, run)
753
+
754
+ async def get_artifacts_async(self, project: str, run: str) -> list[dict[str, Any]]:
755
+ """Get artifacts for a run asynchronously using run_in_executor.
756
+
757
+ Args:
758
+ project: Project name
759
+ run: Run name
760
+
761
+ Returns:
762
+ List of artifact dictionaries
763
+ """
764
+ return await asyncio.to_thread(self.get_artifacts, project, run)
src/aspara/catalog/watcher.py ADDED
@@ -0,0 +1,558 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ @classmethod
99
+ async def shutdown(cls) -> None:
100
+ """Properly shut down the singleton instance.
101
+
102
+ Cancels the running dispatch task (which closes the underlying
103
+ awatch/inotify file descriptor) and then clears the singleton
104
+ state via reset_instance(). Call this from the application
105
+ lifespan shutdown so that a subsequent reload does not reuse a
106
+ stale watcher — which would leak inotify FDs and deliver
107
+ duplicate events.
108
+ """
109
+ instance = cls._instance
110
+ if instance is not None and instance._task is not None and not instance._task.done():
111
+ logger.info("[Watcher] Shutting down dispatch task")
112
+ instance._task.cancel()
113
+ with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
114
+ await asyncio.wait_for(instance._task, timeout=2.0)
115
+ cls.reset_instance()
116
+
117
+ def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
118
+ """Parse file path to extract project, run name, and file type.
119
+
120
+ Args:
121
+ file_path: Absolute path to a file
122
+
123
+ Returns:
124
+ (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
125
+ None if path doesn't match expected pattern or names are invalid
126
+ """
127
+ try:
128
+ relative = file_path.relative_to(self.data_dir)
129
+ except ValueError:
130
+ return None
131
+
132
+ parts = relative.parts
133
+ if len(parts) != 2:
134
+ return None
135
+
136
+ project = parts[0]
137
+ filename = parts[1]
138
+
139
+ if filename.endswith(".wal.jsonl"):
140
+ run_name = filename[:-10]
141
+ file_type = "wal"
142
+ elif filename.endswith(".meta.json"):
143
+ run_name = filename[:-10]
144
+ file_type = "meta"
145
+ elif filename.endswith(".jsonl"):
146
+ run_name = filename[:-6]
147
+ file_type = "metrics"
148
+ else:
149
+ return None
150
+
151
+ # Validate project and run names so that reserved/hidden directories
152
+ # (e.g. .queue) or names with path-traversal characters are ignored
153
+ # at the watcher level rather than dispatched to subscribers and
154
+ # later rejected by the API layer.
155
+ try:
156
+ validate_name(project, "project name")
157
+ validate_name(run_name, "run name")
158
+ except ValueError:
159
+ return None
160
+
161
+ return (project, run_name, file_type)
162
+
163
+ def _parse_metric_line(self, line: str, project: str, run: str, since: datetime) -> MetricRecord | None:
164
+ """Parse a JSONL line and return MetricRecord if it passes the since filter.
165
+
166
+ Args:
167
+ line: A single line from a JSONL file
168
+ project: Project name
169
+ run: Run name
170
+ since: Filter timestamp - only records with timestamp >= since are returned
171
+
172
+ Returns:
173
+ MetricRecord if parsing succeeds and passes filter, None otherwise
174
+ """
175
+ if not line.strip():
176
+ return None
177
+ try:
178
+ entry = json.loads(line)
179
+ ts_value = entry.get("timestamp")
180
+ record_ts = None
181
+ if ts_value is not None:
182
+ with contextlib.suppress(ValueError):
183
+ record_ts = parse_to_datetime(ts_value)
184
+ if record_ts is None or record_ts >= since:
185
+ entry["run"] = run
186
+ entry["project"] = project
187
+ return MetricRecord(**entry)
188
+ except Exception as e:
189
+ logger.debug(f"[Watcher] Error parsing line: {e}")
190
+ return None
191
+
192
+ def _read_file_with_strategy(self, file_path: Path) -> tuple[str, int]:
193
+ """Read file content with size-based strategy.
194
+
195
+ For large files, only the tail portion is read to improve initial load time.
196
+
197
+ Args:
198
+ file_path: Path to the file to read
199
+
200
+ Returns:
201
+ Tuple of (content, end_position) where end_position is the file position after reading
202
+ """
203
+ file_size = file_path.stat().st_size
204
+
205
+ if file_size < self.LARGE_FILE_THRESHOLD:
206
+ with open(file_path) as f:
207
+ content = f.read()
208
+ return content, f.tell()
209
+
210
+ # Large file: read tail only
211
+ logger.debug(f"[Watcher] Large file ({file_size} bytes), reading tail: {file_path}")
212
+ with open(file_path) as f:
213
+ read_start = max(0, file_size - self.TAIL_READ_SIZE)
214
+ f.seek(read_start)
215
+ content = f.read()
216
+ end_pos = f.tell()
217
+
218
+ # Skip partial first line if we didn't start at beginning
219
+ if read_start > 0:
220
+ first_newline = content.find("\n")
221
+ if first_newline != -1:
222
+ content = content[first_newline + 1 :]
223
+
224
+ return content, end_pos
225
+
226
+ def _init_run_status(self, project: str, run: str, meta_file: Path) -> None:
227
+ """Initialize run status tracking from meta file.
228
+
229
+ Args:
230
+ project: Project name
231
+ run: Run name
232
+ meta_file: Path to the metadata file
233
+ """
234
+ key = (project, run)
235
+ if meta_file.exists():
236
+ try:
237
+ with open(meta_file) as f:
238
+ meta = json.load(f)
239
+ self._run_statuses[key] = meta.get("status")
240
+ except Exception:
241
+ self._run_statuses[key] = None
242
+ else:
243
+ self._run_statuses[key] = None
244
+
245
+ def _matches_targets(self, targets: Mapping[str, list[str] | None], project: str, run: str) -> bool:
246
+ """Check if a project/run matches the subscription targets.
247
+
248
+ Args:
249
+ targets: Subscription targets
250
+ project: Project name
251
+ run: Run name
252
+
253
+ Returns:
254
+ True if the project/run matches the targets
255
+ """
256
+ if project not in targets:
257
+ return False
258
+
259
+ run_list = targets[project]
260
+ if run_list is None:
261
+ # None means watch all runs in the project
262
+ return True
263
+
264
+ return run in run_list
265
+
266
+ async def _read_initial_data(
267
+ self,
268
+ targets: Mapping[str, list[str] | None],
269
+ since: datetime,
270
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
271
+ """Read initial data from existing files.
272
+
273
+ Args:
274
+ targets: Dictionary mapping project names to run lists
275
+ since: Filter to only yield records with timestamp >= since
276
+
277
+ Yields:
278
+ MetricRecord objects from existing files
279
+ """
280
+ for project, run_names in targets.items():
281
+ try:
282
+ validate_name(project, "project name")
283
+ except ValueError as e:
284
+ logger.warning(f"[Watcher] Invalid project name {project}: {e}")
285
+ continue
286
+
287
+ project_dir = self.data_dir / project
288
+ if not project_dir.exists():
289
+ logger.warning(f"[Watcher] Project directory does not exist: {project_dir}")
290
+ continue
291
+
292
+ # If run_names is None, discover all runs
293
+ if run_names is None:
294
+ actual_runs = []
295
+ for f in project_dir.glob("*.jsonl"):
296
+ if f.name.endswith(".wal.jsonl"):
297
+ continue
298
+ # Skip symlinks to prevent symlink-based attacks
299
+ if f.is_symlink():
300
+ logger.warning(f"[Watcher] Skipping symlink: {f}")
301
+ continue
302
+ actual_runs.append(f.stem)
303
+ run_names = actual_runs
304
+
305
+ for run in run_names:
306
+ # Check which files exist for this run
307
+ wal_file = project_dir / f"{run}.wal.jsonl"
308
+ jsonl_file = project_dir / f"{run}.jsonl"
309
+ meta_file = project_dir / f"{run}.meta.json"
310
+
311
+ # Initialize status tracking
312
+ self._init_run_status(project, run, meta_file)
313
+
314
+ # Read metrics files
315
+ for file_path in [wal_file, jsonl_file]:
316
+ if not file_path.exists():
317
+ continue
318
+
319
+ resolved = file_path.resolve()
320
+
321
+ try:
322
+ content, end_pos = self._read_file_with_strategy(resolved)
323
+ self._file_sizes[resolved] = end_pos
324
+
325
+ for line in content.splitlines():
326
+ record = self._parse_metric_line(line, project, run, since)
327
+ if record is not None:
328
+ yield record
329
+ except Exception as e:
330
+ logger.warning(f"[Watcher] Error reading {resolved}: {e}")
331
+ if resolved.exists():
332
+ self._file_sizes[resolved] = resolved.stat().st_size
333
+
334
+ # Record meta file size
335
+ if meta_file.exists():
336
+ self._file_sizes[meta_file.resolve()] = meta_file.stat().st_size
337
+
338
+ async def _dispatch_loop(self) -> None:
339
+ """Main loop: watch data_dir and dispatch to subscribers."""
340
+ logger.info(f"[Watcher] Starting dispatch loop for {self.data_dir}")
341
+ watcher = None
342
+
343
+ try:
344
+ watcher = awatch(str(self.data_dir))
345
+ loop_count = 0
346
+ async for changes in watcher:
347
+ loop_count += 1
348
+ if loop_count % 10000 == 0:
349
+ logger.warning(f"[Watcher] Loop count: {loop_count}, changes: {len(changes)}")
350
+ logger.debug(f"[Watcher] Received {len(changes)} change(s)")
351
+
352
+ for _change_type, changed_path_str in changes:
353
+ raw_path = Path(changed_path_str)
354
+ # Skip symlinks to prevent reading files outside data_dir.
355
+ # The initial read in _read_initial_data already skips
356
+ # symlinks; the dispatch loop must do the same so a
357
+ # symlink created after subscription cannot bypass it.
358
+ if raw_path.is_symlink():
359
+ logger.warning(f"[Watcher] Skipping symlink in dispatch: {raw_path}")
360
+ continue
361
+
362
+ changed_path = raw_path.resolve()
363
+
364
+ # Parse file path to get project/run/type
365
+ parsed = self._parse_file_path(changed_path)
366
+ if parsed is None:
367
+ continue
368
+
369
+ project, run, file_type = parsed
370
+ logger.debug(f"[Watcher] File change: {changed_path} (project={project}, run={run}, type={file_type})")
371
+
372
+ # Dispatch to matching subscribers
373
+ async with self._instance_lock:
374
+ for sub in self._subscriptions.values():
375
+ if not self._matches_targets(sub.targets, project, run):
376
+ continue
377
+
378
+ try:
379
+ if file_type == "meta":
380
+ # Handle metadata/status update
381
+ status_record = await self._process_meta_change(changed_path, project, run)
382
+ if status_record:
383
+ await sub.queue.put(status_record)
384
+ else:
385
+ # Handle metrics update
386
+ metric_records = await self._process_metrics_change(changed_path, project, run, sub.since)
387
+ for metric_record in metric_records:
388
+ await sub.queue.put(metric_record)
389
+ except Exception as e:
390
+ logger.error(f"[Watcher] Error dispatching to subscription: {e}")
391
+
392
+ except asyncio.CancelledError:
393
+ logger.info("[Watcher] Dispatch loop cancelled")
394
+ raise
395
+ except Exception as e:
396
+ logger.error(f"[Watcher] Error in dispatch loop: {e}")
397
+ finally:
398
+ if watcher is not None:
399
+ logger.info("[Watcher] Closing awatch instance")
400
+ try:
401
+ await asyncio.wait_for(watcher.aclose(), timeout=2.0)
402
+ except asyncio.TimeoutError:
403
+ logger.warning("[Watcher] Timeout closing awatch instance")
404
+ except Exception as e:
405
+ logger.error(f"[Watcher] Error closing watcher: {e}")
406
+
407
+ async def _process_meta_change(self, file_path: Path, project: str, run: str) -> StatusRecord | None:
408
+ """Process a metadata file change.
409
+
410
+ Args:
411
+ file_path: Path to the metadata file
412
+ project: Project name
413
+ run: Run name
414
+
415
+ Returns:
416
+ StatusRecord if status changed, None otherwise
417
+ """
418
+ try:
419
+ with open(file_path) as f:
420
+ meta = json.load(f)
421
+ new_status = meta.get("status")
422
+
423
+ key = (project, run)
424
+ if new_status != self._run_statuses.get(key):
425
+ logger.info(f"[Watcher] Status change for {project}/{run}: {self._run_statuses.get(key)} -> {new_status}")
426
+ self._run_statuses[key] = new_status
427
+
428
+ return StatusRecord(
429
+ run=run,
430
+ project=project,
431
+ status=new_status or RunStatus.WIP.value,
432
+ is_finished=meta.get("is_finished", False),
433
+ exit_code=meta.get("exit_code"),
434
+ )
435
+ except Exception as e:
436
+ logger.error(f"[Watcher] Error reading metadata file {file_path}: {e}")
437
+
438
+ return None
439
+
440
+ async def _process_metrics_change(self, file_path: Path, project: str, run: str, since: datetime) -> list[MetricRecord]:
441
+ """Process a metrics file change.
442
+
443
+ Args:
444
+ file_path: Path to the metrics file
445
+ project: Project name
446
+ run: Run name
447
+ since: Filter timestamp
448
+
449
+ Returns:
450
+ List of MetricRecord objects
451
+ """
452
+ records: list[MetricRecord] = []
453
+
454
+ try:
455
+ # Determine where to resume reading. The tracked size may be stale
456
+ # if the file was truncated (e.g. PolarsMetricsStorage._clear_wal
457
+ # truncates the WAL to 0 bytes after archiving) or replaced. When
458
+ # the actual size is smaller than what we last read, rewind to the
459
+ # beginning so the newly appended content is not silently skipped.
460
+ actual_size = file_path.stat().st_size
461
+ tracked_size = self._file_sizes.get(file_path, 0)
462
+ read_from = 0 if actual_size < tracked_size else tracked_size
463
+
464
+ with open(file_path) as f:
465
+ f.seek(read_from)
466
+ new_content = f.read()
467
+ self._file_sizes[file_path] = f.tell()
468
+
469
+ for line in new_content.splitlines():
470
+ record = self._parse_metric_line(line, project, run, since)
471
+ if record is not None:
472
+ records.append(record)
473
+
474
+ except Exception as e:
475
+ logger.error(f"[Watcher] Error processing metrics file {file_path}: {e}")
476
+
477
+ return records
478
+
479
+ async def subscribe(
480
+ self,
481
+ targets: Mapping[str, list[str] | None],
482
+ since: datetime,
483
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
484
+ """Subscribe to file changes for specified targets.
485
+
486
+ Args:
487
+ targets: Dictionary mapping project names to list of run names.
488
+ If run list is None, all runs in the project are watched.
489
+ since: Filter to only yield records with timestamp >= since
490
+
491
+ Yields:
492
+ MetricRecord or StatusRecord as files are updated
493
+ """
494
+ # Ensure since is timezone-aware
495
+ if since.tzinfo is None:
496
+ since = since.replace(tzinfo=timezone.utc)
497
+
498
+ subscription_id = str(uuid.uuid4())
499
+ queue: asyncio.Queue[MetricRecord | StatusRecord | None] = asyncio.Queue()
500
+
501
+ subscription = Subscription(
502
+ id=subscription_id,
503
+ targets=targets,
504
+ since=since,
505
+ queue=queue,
506
+ )
507
+
508
+ logger.info(f"[Watcher] New subscription {subscription_id} for targets={targets}")
509
+
510
+ async with self._instance_lock:
511
+ self._subscriptions[subscription_id] = subscription
512
+ # Start watcher task if not running
513
+ if self._task is None or self._task.done():
514
+ logger.info("[Watcher] Starting dispatch task")
515
+ self._task = asyncio.create_task(self._dispatch_loop())
516
+
517
+ try:
518
+ # Yield initial data (existing records >= since)
519
+ async for record in self._read_initial_data(targets, since):
520
+ yield record
521
+
522
+ # Yield updates from queue
523
+ while True:
524
+ queued_record: MetricRecord | StatusRecord | None = await queue.get()
525
+ if queued_record is None: # Sentinel for unsubscribe
526
+ break
527
+ yield queued_record
528
+ finally:
529
+ await self._unsubscribe(subscription_id)
530
+
531
+ async def _unsubscribe(self, subscription_id: str) -> None:
532
+ """Unsubscribe from file changes.
533
+
534
+ Args:
535
+ subscription_id: Subscription ID to remove
536
+ """
537
+ logger.info(f"[Watcher] Unsubscribing {subscription_id}")
538
+
539
+ async with self._instance_lock:
540
+ if subscription_id in self._subscriptions:
541
+ del self._subscriptions[subscription_id]
542
+
543
+ # Stop watcher task if no more subscribers
544
+ if not self._subscriptions and self._task is not None:
545
+ logger.info("[Watcher] No more subscribers, stopping dispatch task")
546
+ self._task.cancel()
547
+ try:
548
+ await asyncio.wait_for(self._task, timeout=2.0)
549
+ except asyncio.TimeoutError:
550
+ logger.warning("[Watcher] Timeout waiting for dispatch task to finish")
551
+ except asyncio.CancelledError:
552
+ pass
553
+ self._task = None
554
+
555
+ @property
556
+ def subscription_count(self) -> int:
557
+ """Get the number of active subscriptions."""
558
+ return len(self._subscriptions)
src/aspara/cli.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import tempfile
15
+ from importlib.metadata import version as _pkg_version
16
+ from pathlib import Path
17
+
18
+ import uvicorn
19
+
20
+ from aspara.config import _validate_data_dir, get_data_dir, get_storage_backend
21
+
22
+
23
+ def _get_version() -> str:
24
+ """Get the installed aspara version.
25
+
26
+ Reads package metadata (sourced from pyproject.toml) so the CLI does not
27
+ need to import the aspara package (and its heavy dependencies) just to
28
+ print ``--version``.
29
+ """
30
+ try:
31
+ return _pkg_version("aspara")
32
+ except Exception:
33
+ # Fallback for environments without installed metadata (e.g. running
34
+ # directly from a source checkout without install).
35
+ from aspara import __version__
36
+
37
+ return __version__
38
+
39
+
40
+ def _resolve_and_validate_data_dir(data_dir: str | None, *, require_writable: bool = True) -> str:
41
+ """Resolve *data_dir* to an absolute path and validate it.
42
+
43
+ Performs the following checks (in order):
44
+
45
+ 1. Forbidden system-path check (delegates to ``config._validate_data_dir``).
46
+ 2. Parent directory exists (so the path is creatable).
47
+ 3. If *require_writable* is ``True``, the directory (or its parent when
48
+ it does not yet exist) is writable.
49
+
50
+ On any failure an error message is printed to stdout and ``sys.exit(1)``
51
+ is called.
52
+
53
+ Args:
54
+ data_dir: Raw ``--data-dir`` value. ``None`` falls back to
55
+ ``get_data_dir()``.
56
+ require_writable: When ``True`` (server commands), verify write
57
+ access. Read-only commands (``projects``, ``runs``) pass
58
+ ``False``.
59
+
60
+ Returns:
61
+ The resolved absolute path as a ``str``.
62
+ """
63
+ if data_dir is None:
64
+ return str(get_data_dir())
65
+
66
+ raw_path = Path(data_dir).expanduser()
67
+ resolved = raw_path.resolve()
68
+
69
+ # 1. Forbidden system-path check
70
+ try:
71
+ _validate_data_dir(resolved)
72
+ except ValueError as exc:
73
+ print(f"Error: {exc}")
74
+ sys.exit(1)
75
+
76
+ # 2. Parent directory must exist (so the path is creatable)
77
+ parent = resolved if resolved.exists() else resolved.parent
78
+ if not parent.exists():
79
+ print(f"Error: data directory parent does not exist: {parent}")
80
+ print("Hint: create the parent directory first, e.g.:")
81
+ print(f" mkdir -p {parent}")
82
+ sys.exit(1)
83
+
84
+ # 3. Writable check (for server commands that write data)
85
+ if require_writable:
86
+ test_dir = resolved if resolved.exists() else parent
87
+ try:
88
+ with tempfile.TemporaryFile(dir=str(test_dir), prefix=".aspara_write_test_"):
89
+ pass
90
+ except (PermissionError, OSError) as exc:
91
+ print(f"Error: data directory is not writable: {test_dir}")
92
+ print(f" {exc}")
93
+ sys.exit(1)
94
+
95
+ return str(resolved)
96
+
97
+
98
+ def parse_serve_components(components: list[str]) -> tuple[bool, bool]:
99
+ """
100
+ Parse and validate component list for serve command
101
+
102
+ Args:
103
+ components: List of component names
104
+
105
+ Returns:
106
+ Tuple of (enable_dashboard, enable_tracker)
107
+
108
+ Raises:
109
+ ValueError: If invalid component name is provided
110
+ """
111
+ valid_components = {"dashboard", "tracker", "together"}
112
+
113
+ # Default: dashboard only
114
+ if not components:
115
+ return (True, False)
116
+
117
+ # Normalize and validate
118
+ normalized = [c.lower() for c in components]
119
+ for comp in normalized:
120
+ if comp not in valid_components:
121
+ raise ValueError(f"Invalid component: {comp}. Valid options are: dashboard, tracker, together")
122
+
123
+ # Handle 'together' keyword
124
+ if "together" in normalized:
125
+ return (True, True)
126
+
127
+ # Handle explicit component list
128
+ enable_dashboard = "dashboard" in normalized
129
+ enable_tracker = "tracker" in normalized
130
+
131
+ # If both specified, enable both
132
+ if enable_dashboard and enable_tracker:
133
+ return (True, True)
134
+
135
+ return (enable_dashboard, enable_tracker)
136
+
137
+
138
+ def get_default_port(enable_dashboard: bool, enable_tracker: bool) -> int:
139
+ """
140
+ Get default port based on enabled components
141
+
142
+ Args:
143
+ enable_dashboard: Whether dashboard is enabled
144
+ enable_tracker: Whether tracker is enabled
145
+
146
+ Returns:
147
+ Default port number (3142 for tracker-only, 3141 otherwise)
148
+ """
149
+ if enable_tracker and not enable_dashboard:
150
+ return 3142
151
+ return 3141
152
+
153
+
154
+ def find_available_port(start_port: int = 3141, max_attempts: int = 100) -> int | None:
155
+ """
156
+ Find an available port number
157
+
158
+ Args:
159
+ start_port: Starting port number
160
+ max_attempts: Maximum number of attempts
161
+
162
+ Returns:
163
+ Available port number, None if not found
164
+ """
165
+ for port in range(start_port, start_port + max_attempts):
166
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
167
+ # If connection fails, that port is available
168
+ result = sock.connect_ex(("127.0.0.1", port))
169
+ if result != 0:
170
+ return port
171
+ return None
172
+
173
+
174
+ def _warn_wildcard_host(host: str) -> None:
175
+ """Warn when binding to a wildcard address without read-only mode.
176
+
177
+ Binding to ``0.0.0.0`` or ``::`` exposes the server to the entire
178
+ network. Since Aspara has no authentication, anyone on the network
179
+ can read, modify, or delete all data. This helper prints a warning
180
+ recommending read-only mode in that case.
181
+
182
+ Args:
183
+ host: Host string passed via ``--host``
184
+ """
185
+ if host not in ("0.0.0.0", "::", ""):
186
+ return
187
+
188
+ print("WARNING: binding to a wildcard address exposes the server")
189
+ print("to the entire network. Aspara has no authentication, so")
190
+ print("anyone on the network can read, modify, or delete all data.")
191
+ if os.environ.get("ASPARA_READ_ONLY") != "1":
192
+ print("Consider enabling read-only mode:")
193
+ print(" ASPARA_READ_ONLY=1 aspara dashboard --host 0.0.0.0")
194
+ print()
195
+
196
+
197
+ def run_dashboard(
198
+ host: str = "127.0.0.1",
199
+ port: int = 3141,
200
+ with_tracker: bool = False,
201
+ data_dir: str | None = None,
202
+ dev: bool = False,
203
+ project_search_mode: str = "realtime",
204
+ ) -> None:
205
+ """
206
+ Start dashboard server
207
+
208
+ Args:
209
+ host: Host name
210
+ port: Port number
211
+ with_tracker: Whether to run integrated tracker in same process
212
+ data_dir: Data directory for local data
213
+ dev: Enable development mode with auto-reload
214
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
215
+ """
216
+ # Set env vars for component mounting
217
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1"
218
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if with_tracker else "0"
219
+
220
+ if with_tracker:
221
+ os.environ["ASPARA_WITH_TRACKER"] = "1"
222
+
223
+ if dev:
224
+ os.environ["ASPARA_DEV_MODE"] = "1"
225
+
226
+ data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True)
227
+
228
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
229
+
230
+ if project_search_mode:
231
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
232
+
233
+ from aspara.dashboard.router import configure_data_dir
234
+
235
+ configure_data_dir(data_dir)
236
+
237
+ _warn_wildcard_host(host)
238
+ print("Starting Aspara Dashboard server...")
239
+ print(f"Access http://{host}:{port} in your browser!")
240
+ print(f"Data directory: {os.path.abspath(data_dir)}")
241
+ backend = get_storage_backend() or "jsonl (default)"
242
+ print(f"Storage backend: {backend}")
243
+ if dev:
244
+ print("Development mode: auto-reload enabled")
245
+
246
+ try:
247
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
248
+ except ImportError:
249
+ print("Error: Dashboard functionality is not installed!")
250
+ print('To install: uv pip install "aspara[dashboard]"')
251
+ sys.exit(1)
252
+
253
+
254
+ def run_tui(data_dir: str | None = None) -> None:
255
+ """
256
+ Start TUI dashboard
257
+
258
+ Args:
259
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
260
+ """
261
+ data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True)
262
+
263
+ print("Starting Aspara TUI...")
264
+ print(f"Data directory: {os.path.abspath(data_dir)}")
265
+
266
+ try:
267
+ from aspara.tui import run_tui as _run_tui
268
+
269
+ _run_tui(data_dir=data_dir)
270
+ except ImportError:
271
+ print("TUI functionality is not installed!")
272
+ print('To install: uv pip install "aspara[tui]"')
273
+ sys.exit(1)
274
+
275
+
276
+ def run_tracker(
277
+ host: str = "127.0.0.1",
278
+ port: int = 3142,
279
+ data_dir: str | None = None,
280
+ dev: bool = False,
281
+ storage_backend: str | None = None,
282
+ ) -> None:
283
+ """
284
+ Start tracker API server
285
+
286
+ Args:
287
+ host: Host name
288
+ port: Port number
289
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
290
+ dev: Enable development mode with auto-reload
291
+ storage_backend: Metrics storage backend (jsonl or polars)
292
+ """
293
+ # Set env vars for backward compatibility
294
+ os.environ["ASPARA_SERVE_TRACKER"] = "1"
295
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "0"
296
+
297
+ if dev:
298
+ os.environ["ASPARA_DEV_MODE"] = "1"
299
+
300
+ if storage_backend is not None:
301
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
302
+
303
+ data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True)
304
+
305
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
306
+
307
+ _warn_wildcard_host(host)
308
+ print("Starting Aspara Tracker API server...")
309
+ print(f"Endpoint: http://{host}:{port}/tracker/api/v1")
310
+ print(f"Data directory: {os.path.abspath(data_dir)}")
311
+ backend = get_storage_backend() or "jsonl (default)"
312
+ print(f"Storage backend: {backend}")
313
+ if dev:
314
+ print("Development mode: auto-reload enabled")
315
+
316
+ try:
317
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
318
+ except ImportError:
319
+ print("Error: Tracker functionality is not installed!")
320
+ print('To install: uv pip install "aspara[tracker]"')
321
+ sys.exit(1)
322
+
323
+
324
+ def run_serve(
325
+ components: list[str],
326
+ host: str = "127.0.0.1",
327
+ port: int | None = None,
328
+ data_dir: str | None = None,
329
+ dev: bool = False,
330
+ project_search_mode: str = "realtime",
331
+ storage_backend: str | None = None,
332
+ ) -> None:
333
+ """
334
+ Start Aspara server with specified components
335
+
336
+ Args:
337
+ components: List of components to enable (dashboard, tracker, together)
338
+ host: Host name
339
+ port: Port number (auto-detected if None)
340
+ data_dir: Data directory
341
+ dev: Enable development mode with auto-reload
342
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
343
+ storage_backend: Metrics storage backend (jsonl or polars)
344
+ """
345
+ try:
346
+ enable_dashboard, enable_tracker = parse_serve_components(components)
347
+ except ValueError as e:
348
+ print(f"Error: {e}")
349
+ sys.exit(1)
350
+
351
+ # Set environment variables for component mounting
352
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1" if enable_dashboard else "0"
353
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if enable_tracker else "0"
354
+
355
+ if dev:
356
+ os.environ["ASPARA_DEV_MODE"] = "1"
357
+
358
+ if storage_backend is not None:
359
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
360
+
361
+ # Determine port
362
+ if port is None:
363
+ port = get_default_port(enable_dashboard, enable_tracker)
364
+
365
+ # Configure data directory
366
+ data_dir = _resolve_and_validate_data_dir(data_dir, require_writable=True)
367
+
368
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
369
+
370
+ # Configure dashboard if enabled
371
+ if enable_dashboard:
372
+ if project_search_mode:
373
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
374
+
375
+ from aspara.dashboard.router import configure_data_dir
376
+
377
+ configure_data_dir(data_dir)
378
+
379
+ # Build component description
380
+ if enable_dashboard and enable_tracker:
381
+ component_desc = "Dashboard + Tracker"
382
+ elif enable_dashboard:
383
+ component_desc = "Dashboard"
384
+ else:
385
+ component_desc = "Tracker"
386
+
387
+ _warn_wildcard_host(host)
388
+ print(f"Starting Aspara {component_desc} server...")
389
+ print(f"Access http://{host}:{port} in your browser!")
390
+ print(f"Data directory: {os.path.abspath(data_dir)}")
391
+ backend = get_storage_backend() or "jsonl (default)"
392
+ print(f"Storage backend: {backend}")
393
+ if dev:
394
+ print("Development mode: auto-reload enabled")
395
+
396
+ try:
397
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
398
+ except ImportError as e:
399
+ print(f"Error: Required functionality is not installed: {e}")
400
+ sys.exit(1)
401
+
402
+
403
+ def _list_projects(data_dir: str | None) -> None:
404
+ """Print all projects and their run counts."""
405
+ from aspara.catalog import ProjectCatalog
406
+
407
+ resolved_dir = _resolve_and_validate_data_dir(data_dir, require_writable=False)
408
+ catalog = ProjectCatalog(resolved_dir)
409
+ projects = catalog.get_projects()
410
+
411
+ if not projects:
412
+ print("No projects found. Use `aspara.init(project=...)` to create one.")
413
+ return
414
+
415
+ # Column widths for alignment
416
+ name_w = max(len(p.name) for p in projects)
417
+ print(f"{'PROJECT':<{name_w}} RUNS LAST UPDATED")
418
+ print(f"{'-' * name_w} ---- ------------")
419
+ for p in projects:
420
+ last = p.last_update.strftime("%Y-%m-%d %H:%M") if p.last_update else "N/A"
421
+ print(f"{p.name:<{name_w}} {p.run_count:>4} {last}")
422
+
423
+
424
+ def _list_runs(project: str, data_dir: str | None) -> int:
425
+ """Print all runs in a project.
426
+
427
+ Returns 0 on success, 1 if the project does not exist.
428
+ """
429
+ from aspara.catalog import ProjectCatalog, RunCatalog
430
+
431
+ resolved_dir = _resolve_and_validate_data_dir(data_dir, require_writable=False)
432
+ project_catalog = ProjectCatalog(resolved_dir)
433
+ if not project_catalog.exists(project):
434
+ print(f"Project '{project}' not found in {resolved_dir}")
435
+ return 1
436
+
437
+ run_catalog = RunCatalog(resolved_dir)
438
+ runs = run_catalog.get_runs(project)
439
+
440
+ if not runs:
441
+ print(f"No runs found in project '{project}'.")
442
+ return 0
443
+
444
+ status_display = {
445
+ "wip": "Running",
446
+ "completed": "Completed",
447
+ "failed": "Failed",
448
+ "maybe_failed": "Maybe Failed",
449
+ }
450
+
451
+ name_w = max(len(r.name) for r in runs)
452
+ print(f"{'RUN':<{name_w}} STATUS STARTED")
453
+ print(f"{'-' * name_w} -------- -------")
454
+ for r in runs:
455
+ status = status_display.get(r.status.value, r.status.value)
456
+ started = r.start_time.strftime("%Y-%m-%d %H:%M") if r.start_time else "N/A"
457
+ print(f"{r.name:<{name_w}} {status:<8} {started}")
458
+ return 0
459
+
460
+
461
+ def main() -> None:
462
+ """
463
+ CLI main entry point
464
+ """
465
+ parser = argparse.ArgumentParser(description="Aspara management tool. Run a subcommand to start a server or the TUI.")
466
+ parser.add_argument("--version", action="version", version=f"aspara {_get_version()}")
467
+ subparsers = parser.add_subparsers(dest="command", required=True, help="Subcommands")
468
+
469
+ dashboard_parser = subparsers.add_parser("dashboard", help="Start dashboard server")
470
+ dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
471
+ dashboard_parser.add_argument("--port", type=int, default=3141, help="Port number (default: 3141)")
472
+ dashboard_parser.add_argument("--with-tracker", action="store_true", help="Run dashboard with integrated tracker in same process")
473
+ dashboard_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
474
+ dashboard_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
475
+ dashboard_parser.add_argument(
476
+ "--project-search-mode",
477
+ choices=["realtime", "manual"],
478
+ default="realtime",
479
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
480
+ )
481
+
482
+ tracker_parser = subparsers.add_parser("tracker", help="Start tracker API server")
483
+ tracker_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
484
+ tracker_parser.add_argument("--port", type=int, default=3142, help="Port number (default: 3142)")
485
+ tracker_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
486
+ tracker_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
487
+ tracker_parser.add_argument(
488
+ "--storage-backend",
489
+ choices=["jsonl", "polars"],
490
+ default=None,
491
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
492
+ )
493
+
494
+ tui_parser = subparsers.add_parser("tui", help="Start terminal UI dashboard")
495
+ tui_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
496
+
497
+ serve_parser = subparsers.add_parser("serve", help="Start Aspara server")
498
+ serve_parser.add_argument(
499
+ "components",
500
+ nargs="*",
501
+ default=[],
502
+ help="Components to run: dashboard, tracker, together (default: dashboard only)",
503
+ )
504
+ serve_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
505
+ serve_parser.add_argument("--port", type=int, default=None, help="Port number (default: 3141 for dashboard, 3142 for tracker-only)")
506
+ serve_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
507
+ serve_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
508
+ serve_parser.add_argument(
509
+ "--project-search-mode",
510
+ choices=["realtime", "manual"],
511
+ default="realtime",
512
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
513
+ )
514
+ serve_parser.add_argument(
515
+ "--storage-backend",
516
+ choices=["jsonl", "polars"],
517
+ default=None,
518
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
519
+ )
520
+
521
+ projects_parser = subparsers.add_parser("projects", help="List all projects")
522
+ projects_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
523
+
524
+ runs_parser = subparsers.add_parser("runs", help="List runs in a project")
525
+ runs_parser.add_argument("project", help="Project name")
526
+ runs_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
527
+
528
+ args = parser.parse_args()
529
+
530
+ if args.command == "dashboard":
531
+ run_dashboard(
532
+ host=args.host,
533
+ port=args.port,
534
+ with_tracker=args.with_tracker,
535
+ data_dir=args.data_dir,
536
+ dev=args.dev,
537
+ project_search_mode=args.project_search_mode,
538
+ )
539
+ elif args.command == "tracker":
540
+ run_tracker(
541
+ host=args.host,
542
+ port=args.port,
543
+ data_dir=args.data_dir,
544
+ dev=args.dev,
545
+ storage_backend=args.storage_backend,
546
+ )
547
+ elif args.command == "tui":
548
+ run_tui(data_dir=args.data_dir)
549
+ elif args.command == "serve":
550
+ run_serve(
551
+ components=args.components,
552
+ host=args.host,
553
+ port=args.port,
554
+ data_dir=args.data_dir,
555
+ dev=args.dev,
556
+ project_search_mode=args.project_search_mode,
557
+ storage_backend=args.storage_backend,
558
+ )
559
+ elif args.command == "projects":
560
+ _list_projects(data_dir=args.data_dir)
561
+ elif args.command == "runs":
562
+ exit_code = _list_runs(project=args.project, data_dir=args.data_dir)
563
+ if exit_code != 0:
564
+ sys.exit(exit_code)
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()
src/aspara/config.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "SSE_METRICS_ITERATOR_CLOSE_TIMEOUT",
11
+ "get_data_dir",
12
+ "get_resource_limits",
13
+ "get_sse_dev_shutdown_timeout",
14
+ "get_sse_heartbeat_interval",
15
+ "get_sse_send_timeout",
16
+ "get_storage_backend",
17
+ "get_project_search_mode",
18
+ "is_dev_mode",
19
+ "is_read_only",
20
+ ]
21
+
22
+
23
+ class ResourceLimits(BaseModel):
24
+ """Resource limits configuration.
25
+
26
+ Includes security-related limits (file size, JSONL lines) and
27
+ performance/resource constraints (metric names, note length, tags count).
28
+
29
+ All limits can be customized via environment variables.
30
+ Defaults are set for internal use with generous limits.
31
+ """
32
+
33
+ max_file_size: int = Field(
34
+ default=1024 * 1024 * 1024, # 1024MB (1GB)
35
+ description="Maximum file size in bytes",
36
+ )
37
+
38
+ max_jsonl_lines: int = Field(
39
+ default=1_000_000, # 1M lines
40
+ description="Maximum number of lines when reading JSONL files",
41
+ )
42
+
43
+ max_zip_size: int = Field(
44
+ default=1024 * 1024 * 1024, # 1GB
45
+ description="Maximum ZIP file size in bytes",
46
+ )
47
+
48
+ max_metric_names: int = Field(
49
+ default=100,
50
+ description="Maximum number of metric names in comma-separated list",
51
+ )
52
+
53
+ max_notes_length: int = Field(
54
+ default=10 * 1024, # 10KB
55
+ description="Maximum notes text length in characters",
56
+ )
57
+
58
+ max_tags_count: int = Field(
59
+ default=100,
60
+ description="Maximum number of tags",
61
+ )
62
+
63
+ lttb_threshold: int = Field(
64
+ default=1_000,
65
+ description="Downsample metrics using LTTB algorithm when metric series length exceeds this threshold",
66
+ )
67
+
68
+ @classmethod
69
+ def from_env(cls) -> "ResourceLimits":
70
+ """Create ResourceLimits from environment variables.
71
+
72
+ Environment variables:
73
+ - ASPARA_MAX_FILE_SIZE: Maximum file size in bytes (default: 1GB)
74
+ - ASPARA_MAX_JSONL_LINES: Maximum JSONL lines (default: 1M)
75
+ - ASPARA_MAX_ZIP_SIZE: Maximum ZIP size in bytes (default: 1GB)
76
+ - ASPARA_MAX_METRIC_NAMES: Maximum metric names (default: 100)
77
+ - ASPARA_MAX_NOTES_LENGTH: Maximum notes length (default: 10KB)
78
+ - ASPARA_MAX_TAGS_COUNT: Maximum tags count (default: 100)
79
+ - ASPARA_LTTB_THRESHOLD: Threshold for LTTB downsampling (default: 1000)
80
+ """
81
+ return cls(
82
+ max_file_size=int(os.environ.get("ASPARA_MAX_FILE_SIZE", cls.model_fields["max_file_size"].default)),
83
+ max_jsonl_lines=int(os.environ.get("ASPARA_MAX_JSONL_LINES", cls.model_fields["max_jsonl_lines"].default)),
84
+ max_zip_size=int(os.environ.get("ASPARA_MAX_ZIP_SIZE", cls.model_fields["max_zip_size"].default)),
85
+ max_metric_names=int(os.environ.get("ASPARA_MAX_METRIC_NAMES", cls.model_fields["max_metric_names"].default)),
86
+ max_notes_length=int(os.environ.get("ASPARA_MAX_NOTES_LENGTH", cls.model_fields["max_notes_length"].default)),
87
+ max_tags_count=int(os.environ.get("ASPARA_MAX_TAGS_COUNT", cls.model_fields["max_tags_count"].default)),
88
+ lttb_threshold=int(os.environ.get("ASPARA_LTTB_THRESHOLD", cls.model_fields["lttb_threshold"].default)),
89
+ )
90
+
91
+
92
+ # Global resource limits instance
93
+ _resource_limits: ResourceLimits | None = None
94
+
95
+
96
+ def get_resource_limits() -> ResourceLimits:
97
+ """Get resource limits configuration.
98
+
99
+ Returns cached instance if already initialized.
100
+ """
101
+ global _resource_limits
102
+ if _resource_limits is None:
103
+ _resource_limits = ResourceLimits.from_env()
104
+ return _resource_limits
105
+
106
+
107
+ # Forbidden system directories that cannot be used as data directories
108
+ _FORBIDDEN_PATHS = frozenset(["/", "/etc", "/sys", "/dev", "/bin", "/sbin", "/usr", "/var", "/boot", "/proc"])
109
+
110
+
111
+ def _validate_data_dir(data_path: Path) -> None:
112
+ """Validate that data directory is not a dangerous system path.
113
+
114
+ Args:
115
+ data_path: Path to validate
116
+
117
+ Raises:
118
+ ValueError: If path is a forbidden system directory
119
+ """
120
+ resolved = data_path.resolve()
121
+ resolved_str = str(resolved)
122
+
123
+ for forbidden in _FORBIDDEN_PATHS:
124
+ if resolved_str == forbidden or resolved_str.rstrip("/") == forbidden:
125
+ raise ValueError(f"ASPARA_DATA_DIR cannot be set to system directory: {forbidden}")
126
+
127
+
128
+ def get_data_dir() -> Path:
129
+ """Get the default data directory for Aspara.
130
+
131
+ Resolution priority:
132
+ 1. ASPARA_DATA_DIR environment variable (if set)
133
+ 2. XDG_DATA_HOME/aspara (if XDG_DATA_HOME is set)
134
+ 3. ~/.local/share/aspara (fallback)
135
+
136
+ Returns:
137
+ Path object pointing to the data directory.
138
+
139
+ Raises:
140
+ ValueError: If ASPARA_DATA_DIR points to a system directory
141
+
142
+ Examples:
143
+ >>> # Using ASPARA_DATA_DIR
144
+ >>> os.environ["ASPARA_DATA_DIR"] = "/custom/path"
145
+ >>> get_data_dir()
146
+ Path('/custom/path')
147
+
148
+ >>> # Using XDG_DATA_HOME
149
+ >>> os.environ["XDG_DATA_HOME"] = "/home/user/.local/share"
150
+ >>> get_data_dir()
151
+ Path('/home/user/.local/share/aspara')
152
+
153
+ >>> # Using fallback
154
+ >>> get_data_dir()
155
+ Path('/home/user/.local/share/aspara')
156
+ """
157
+ # Priority 1: ASPARA_DATA_DIR environment variable
158
+ aspara_data_dir = os.environ.get("ASPARA_DATA_DIR")
159
+ if aspara_data_dir:
160
+ data_path = Path(aspara_data_dir).expanduser().resolve()
161
+ _validate_data_dir(data_path)
162
+ return data_path
163
+
164
+ # Priority 2: XDG_DATA_HOME/aspara
165
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
166
+ if xdg_data_home:
167
+ return Path(xdg_data_home).expanduser() / "aspara"
168
+
169
+ # Priority 3: ~/.local/share/aspara (fallback)
170
+ return Path.home() / ".local" / "share" / "aspara"
171
+
172
+
173
+ def get_project_search_mode() -> str:
174
+ """Get project search mode from environment variable.
175
+
176
+ Returns:
177
+ Project search mode ("realtime" or "manual"). Defaults to "realtime".
178
+ """
179
+ mode = os.environ.get("ASPARA_PROJECT_SEARCH_MODE", "realtime")
180
+ if mode not in ("realtime", "manual"):
181
+ return "realtime"
182
+ return mode
183
+
184
+
185
+ def get_storage_backend() -> str | None:
186
+ """Get storage backend from environment variable.
187
+
188
+ Returns:
189
+ Storage backend name if ASPARA_STORAGE_BACKEND is set, None otherwise.
190
+ """
191
+ return os.environ.get("ASPARA_STORAGE_BACKEND")
192
+
193
+
194
+ def use_lttb_fast() -> bool:
195
+ """Check if fast LTTB implementation should be used.
196
+
197
+ Returns:
198
+ True if ASPARA_LTTB_FAST is set to "1", False otherwise.
199
+ """
200
+ return os.environ.get("ASPARA_LTTB_FAST") == "1"
201
+
202
+
203
+ def is_dev_mode() -> bool:
204
+ """Check if running in development mode.
205
+
206
+ Returns:
207
+ True if ASPARA_DEV_MODE is set to "1", False otherwise.
208
+ """
209
+ return os.environ.get("ASPARA_DEV_MODE") == "1"
210
+
211
+
212
+ def is_read_only() -> bool:
213
+ """Check if running in read-only mode.
214
+
215
+ Returns:
216
+ True if ASPARA_READ_ONLY is set to "1", False otherwise.
217
+ """
218
+ return os.environ.get("ASPARA_READ_ONLY") == "1"
219
+
220
+
221
+ # Default SSE heartbeat interval in seconds.
222
+ # Sent to keep connections alive and detect dead clients.
223
+ _SSE_DEFAULT_HEARTBEAT_INTERVAL = 15
224
+
225
+ # Default SSE send timeout in seconds.
226
+ # If a client stops reading, the server gives up after this many seconds.
227
+ _SSE_DEFAULT_SEND_TIMEOUT = 30.0
228
+
229
+ # Timeout for closing the metrics async iterator during SSE cleanup.
230
+ # This is the upper bound for watcher unsubscribe / generator finalization
231
+ # when an SSE connection ends (either client disconnect or server shutdown).
232
+ SSE_METRICS_ITERATOR_CLOSE_TIMEOUT = 1.0
233
+
234
+ # Timeout for forcefully cancelling active SSE tasks during dev-mode shutdown.
235
+ # Must be >= SSE_METRICS_ITERATOR_CLOSE_TIMEOUT so that each cancelled task
236
+ # has enough time to run its `finally` block (which closes the metrics
237
+ # iterator) before the shutdown gives up. Production shutdown uses a fixed
238
+ # graceful drain instead (see dashboard.main.lifespan).
239
+ _SSE_DEV_SHUTDOWN_TIMEOUT = 2.0
240
+
241
+
242
+ def get_sse_heartbeat_interval() -> int:
243
+ """Get the SSE heartbeat (ping) interval in seconds.
244
+
245
+ Environment variable:
246
+ ASPARA_SSE_HEARTBEAT_INTERVAL: override the ping interval (default: 15)
247
+ """
248
+ return int(os.environ.get("ASPARA_SSE_HEARTBEAT_INTERVAL", _SSE_DEFAULT_HEARTBEAT_INTERVAL))
249
+
250
+
251
+ def get_sse_send_timeout() -> float:
252
+ """Get the SSE send timeout in seconds.
253
+
254
+ Environment variable:
255
+ ASPARA_SSE_SEND_TIMEOUT: override the send timeout (default: 30.0)
256
+ """
257
+ return float(os.environ.get("ASPARA_SSE_SEND_TIMEOUT", _SSE_DEFAULT_SEND_TIMEOUT))
258
+
259
+
260
+ def get_sse_dev_shutdown_timeout() -> float:
261
+ """Get the SSE task cancellation timeout (seconds) used during dev-mode shutdown.
262
+
263
+ Environment variable:
264
+ ASPARA_SSE_DEV_SHUTDOWN_TIMEOUT: override the timeout (default: 2.0)
265
+ """
266
+ return float(os.environ.get("ASPARA_SSE_DEV_SHUTDOWN_TIMEOUT", _SSE_DEV_SHUTDOWN_TIMEOUT))
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,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.staticfiles import StaticFiles
14
+ from starlette.middleware.base import BaseHTTPMiddleware
15
+ from starlette.responses import Response
16
+
17
+ from aspara.catalog import DataDirWatcher
18
+ from aspara.config import get_sse_dev_shutdown_timeout, 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
+ # HSTS - set unconditionally because:
54
+ # - Browsers ignore it on HTTP responses, so HTTP deployments are unaffected
55
+ # - Browsers ignore it from localhost (Chrome 132+, Firefox, Brave),
56
+ # so local development is never locked out
57
+ # - It only takes effect on HTTPS responses from non-localhost hosts,
58
+ # which is exactly the production case (HF Spaces, internal LAN TLS)
59
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
60
+
61
+ # Content Security Policy - basic policy
62
+ # Allows self-origin scripts/styles, inline styles for chart libraries,
63
+ # and data: URIs for images (used by chart exports)
64
+ response.headers["Content-Security-Policy"] = (
65
+ "default-src 'self'; "
66
+ "script-src 'self'; "
67
+ "style-src 'self' 'unsafe-inline'; "
68
+ "img-src 'self' data:; "
69
+ "font-src 'self'; "
70
+ "connect-src 'self'; "
71
+ "frame-ancestors 'none'"
72
+ )
73
+
74
+ # Allow iframe embedding when ASPARA_ALLOW_IFRAME=1 (e.g., HF Spaces)
75
+ if os.environ.get("ASPARA_ALLOW_IFRAME") == "1":
76
+ del response.headers["X-Frame-Options"]
77
+ response.headers["Content-Security-Policy"] = (
78
+ "default-src 'self'; "
79
+ "script-src 'self'; "
80
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
81
+ "img-src 'self' data:; "
82
+ "font-src 'self' https://fonts.gstatic.com; "
83
+ "connect-src 'self'; "
84
+ "frame-ancestors https://huggingface.co https://*.hf.space"
85
+ )
86
+
87
+ return response
88
+
89
+
90
+ @asynccontextmanager
91
+ async def lifespan(app: FastAPI):
92
+ """Manage application lifecycle.
93
+
94
+ On shutdown, signal all active SSE connections to close gracefully.
95
+ In development mode, forcefully cancel SSE tasks for fast restart.
96
+ """
97
+ # Startup
98
+ yield
99
+
100
+ # Shutdown
101
+ app_state.shutting_down = True
102
+
103
+ # Signal all active SSE connections to stop
104
+ for queue in list(app_state.active_sse_connections):
105
+ # Queue might already be closed or event loop shutting down
106
+ with contextlib.suppress(RuntimeError, OSError):
107
+ await queue.put(None) # Sentinel value to signal shutdown
108
+
109
+ if is_dev_mode():
110
+ # Development mode: forcefully cancel SSE tasks for fast restart.
111
+ # The timeout must be >= SSE_METRICS_ITERATOR_CLOSE_TIMEOUT so each
112
+ # cancelled task can finish its `finally` cleanup (closing the
113
+ # metrics iterator / watcher unsubscribe) before we give up.
114
+ shutdown_timeout = get_sse_dev_shutdown_timeout()
115
+ logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks (timeout={shutdown_timeout}s)")
116
+ for task in list(app_state.active_sse_tasks):
117
+ task.cancel()
118
+
119
+ # Wait for tasks to be cancelled
120
+ if app_state.active_sse_tasks:
121
+ with contextlib.suppress(asyncio.TimeoutError):
122
+ await asyncio.wait_for(
123
+ asyncio.gather(*app_state.active_sse_tasks, return_exceptions=True),
124
+ timeout=shutdown_timeout,
125
+ )
126
+ logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete")
127
+ else:
128
+ # Production mode: graceful shutdown with 30 second timeout
129
+ await asyncio.sleep(0.5)
130
+
131
+ # Tear down the DataDirWatcher singleton so that the underlying
132
+ # awatch/inotify FD is closed and a subsequent reload (e.g. --dev
133
+ # auto-reload) does not reuse a stale watcher — which would leak
134
+ # inotify FDs and deliver duplicate events.
135
+ await DataDirWatcher.shutdown()
136
+
137
+
138
+ app = FastAPI(
139
+ title="Aspara Dashboard",
140
+ description="Real-time metrics visualization for machine learning experiments",
141
+ docs_url="/docs/dashboard" if is_dev_mode() else None,
142
+ redoc_url=None,
143
+ lifespan=lifespan,
144
+ )
145
+
146
+ # Security headers middleware
147
+ app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type]
148
+
149
+ # No CORS middleware is configured intentionally. The dashboard static JS and API
150
+ # are served from the same origin, so CORS is unnecessary. Keeping a wildcard
151
+ # CORS policy would allow cross-origin sites to pass the X-Requested-With CSRF
152
+ # header check via preflight, defeating that protection.
153
+
154
+ BASE_DIR = Path(__file__).parent
155
+ app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
156
+
157
+ 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,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 logging
15
+ import os
16
+ import tempfile
17
+ import urllib.parse
18
+ import zipfile
19
+ from collections import defaultdict
20
+ from collections.abc import Iterator
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ import msgpack
25
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query
26
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
27
+
28
+ from aspara.config import get_resource_limits, is_read_only
29
+ from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
30
+ from aspara.utils import validators
31
+
32
+ from ..dependencies import (
33
+ DataDirDep,
34
+ ProjectCatalogDep,
35
+ RunCatalogDep,
36
+ ValidatedProject,
37
+ ValidatedRun,
38
+ )
39
+ from ..models.metrics import Metadata, MetadataUpdateRequest
40
+ from ..utils import parse_and_validate_run_list
41
+ from ..utils.compression import compress_metrics
42
+
43
+
44
+ async def verify_csrf_header(x_requested_with: str | None = Header(None, alias="X-Requested-With")) -> None:
45
+ """CSRF protection via custom header check.
46
+
47
+ Verifies that requests include the X-Requested-With header, which cannot be set
48
+ by cross-origin requests without CORS preflight. This prevents CSRF attacks.
49
+
50
+ Args:
51
+ x_requested_with: The X-Requested-With header value
52
+
53
+ Raises:
54
+ HTTPException: 403 if header is missing
55
+ """
56
+ if x_requested_with is None:
57
+ raise HTTPException(status_code=403, detail="Missing X-Requested-With header")
58
+
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ router = APIRouter()
63
+
64
+ # Spool threshold: zips smaller than this stay in memory; larger ones
65
+ # roll over to a temp file on disk. 1 MiB keeps per-request memory
66
+ # bounded while avoiding disk I/O for the common small-artifact case.
67
+ _ZIP_SPOOL_MAX_BYTES = 1 << 20 # 1 MiB
68
+ _ZIP_STREAM_CHUNK_SIZE = 64 * 1024 # 64 KiB
69
+
70
+
71
+ def _stream_zip(
72
+ artifact_entries: list[tuple[str, str, int]],
73
+ ) -> Iterator[bytes]:
74
+ """Build a ZIP on a SpooledTemporaryFile and yield it in chunks.
75
+
76
+ The ZIP is written to a ``SpooledTemporaryFile`` (in-memory up to
77
+ ``_ZIP_SPOOL_MAX_BYTES``, then transparently rolled to a temp file on
78
+ disk). After the ZIP is finalised the file pointer is rewound and the
79
+ content is yielded in fixed-size chunks. The temp file is closed in
80
+ the ``finally`` block so it is cleaned up even on client disconnect.
81
+
82
+ Args:
83
+ artifact_entries: List of (name, path, size) tuples for the
84
+ files to include in the ZIP.
85
+
86
+ Yields:
87
+ Chunks of the completed ZIP file.
88
+ """
89
+ with tempfile.SpooledTemporaryFile(max_size=_ZIP_SPOOL_MAX_BYTES, suffix=".zip") as buf:
90
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zip_file:
91
+ for filename, file_path, _ in artifact_entries:
92
+ zip_file.write(file_path, filename)
93
+ buf.seek(0)
94
+ while True:
95
+ chunk = buf.read(_ZIP_STREAM_CHUNK_SIZE)
96
+ if not chunk:
97
+ break
98
+ yield chunk
99
+
100
+
101
+ @router.get("/api/projects/{project}/runs/{run}/artifacts/download")
102
+ async def download_artifacts_zip(
103
+ project: ValidatedProject,
104
+ run: ValidatedRun,
105
+ data_dir: DataDirDep,
106
+ ) -> StreamingResponse:
107
+ """Download all artifacts for a run as a ZIP file.
108
+
109
+ Args:
110
+ project: Project name.
111
+ run: Run name.
112
+
113
+ Returns:
114
+ StreamingResponse with ZIP file containing all artifacts.
115
+ Filename format: `{project}_{run}_artifacts_{timestamp}.zip`
116
+
117
+ Raises:
118
+ HTTPException: 400 if project/run name is invalid or total size exceeds limit,
119
+ 404 if no artifacts found.
120
+ """
121
+ # Get the artifacts directory path
122
+ artifacts_dir = data_dir / project / run / "artifacts"
123
+
124
+ # Validate path to prevent path traversal
125
+ try:
126
+ validators.validate_safe_path(artifacts_dir, data_dir)
127
+ except ValueError as e:
128
+ raise HTTPException(status_code=400, detail=f"Invalid artifacts directory path: {e}") from None
129
+
130
+ artifacts_dir_str = str(artifacts_dir)
131
+
132
+ if not os.path.exists(artifacts_dir_str):
133
+ raise HTTPException(status_code=404, detail="No artifacts found for this run")
134
+
135
+ # Single-pass: collect file info using scandir (caches stat results).
136
+ # Use follow_symlinks=False so that symlinks in the artifacts directory
137
+ # are not followed — this prevents a local attacker from tricking the
138
+ # ZIP builder into bundling files outside data_dir.
139
+ artifact_entries: list[tuple[str, str, int]] = [] # (name, path, size)
140
+ total_size = 0
141
+
142
+ with os.scandir(artifacts_dir_str) as entries:
143
+ for entry in entries:
144
+ if entry.is_file(follow_symlinks=False):
145
+ size = entry.stat(follow_symlinks=False).st_size
146
+ artifact_entries.append((entry.name, entry.path, size))
147
+ total_size += size
148
+ elif entry.is_symlink():
149
+ logger.warning(f"Skipping symlink in artifacts directory: {entry.path}")
150
+
151
+ if not artifact_entries:
152
+ raise HTTPException(status_code=404, detail="No artifact files found")
153
+
154
+ # Check total size
155
+ limits = get_resource_limits()
156
+ if total_size > limits.max_zip_size:
157
+ raise HTTPException(
158
+ status_code=400,
159
+ detail=(f"Total artifacts size ({total_size} bytes) exceeds maximum ZIP size limit ({limits.max_zip_size} bytes)"),
160
+ )
161
+
162
+ # Generate filename with timestamp
163
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
164
+ zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip"
165
+
166
+ # Encode filename for Content-Disposition header to prevent header
167
+ # injection. Use RFC 5987 encoding for non-ASCII characters.
168
+ encoded_filename = urllib.parse.quote(zip_filename, safe="")
169
+
170
+ # Build the ZIP using a SpooledTemporaryFile so that memory usage is
171
+ # bounded (small zips stay in memory up to the spool threshold; larger
172
+ # ones roll over to a temp file on disk). The generator then streams
173
+ # the file in fixed-size chunks, keeping per-request memory constant
174
+ # regardless of total ZIP size. The temp file is cleaned up in the
175
+ # generator's finally block.
176
+ return StreamingResponse(
177
+ _stream_zip(artifact_entries),
178
+ media_type="application/zip",
179
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
180
+ )
181
+
182
+
183
+ @router.get("/api/projects/{project}/runs/metrics")
184
+ async def runs_metrics_api(
185
+ project: ValidatedProject,
186
+ run_catalog: RunCatalogDep,
187
+ runs: str,
188
+ format: str = "json",
189
+ since: int | None = Query(
190
+ default=None,
191
+ description="Filter metrics since this UNIX timestamp in milliseconds",
192
+ ),
193
+ ) -> Response:
194
+ """Get metrics for multiple runs in a single request.
195
+
196
+ Useful for comparing metrics across runs. Returns data in metric-first structure
197
+ where each metric contains data from all requested runs.
198
+
199
+ Args:
200
+ project: Project name.
201
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
202
+ format: Response format - "json" (default) or "msgpack".
203
+ since: Optional filter to only return metrics with timestamp >= since (UNIX ms).
204
+
205
+ Returns:
206
+ Response with structure: `{"project": str, "metrics": {metric: {run: {...}}}}`
207
+ - For "json" format: JSONResponse
208
+ - For "msgpack" format: Response with application/x-msgpack content type
209
+
210
+ Raises:
211
+ HTTPException: 400 if project name is invalid, format is invalid,
212
+ or too many runs specified.
213
+ """
214
+ # Validate format parameter
215
+ if format not in ("json", "msgpack"):
216
+ raise HTTPException(
217
+ status_code=400,
218
+ detail=f"Invalid format: {format}. Must be 'json' or 'msgpack'",
219
+ )
220
+
221
+ try:
222
+ run_list = parse_and_validate_run_list(runs)
223
+ except ValueError as e:
224
+ if format == "msgpack":
225
+ raise HTTPException(status_code=400, detail=str(e)) from None
226
+ return JSONResponse(content={"error": str(e)}, status_code=400)
227
+
228
+ # Convert since (UNIX ms) to datetime if provided
229
+ # Create timezone-naive datetime (matches DataFrame storage)
230
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc).replace(tzinfo=None) if since is not None else None
231
+
232
+ # Load and downsample metrics for all runs in parallel
233
+ async def load_and_downsample(
234
+ run_name: str,
235
+ ) -> tuple[str, dict[str, dict[str, list]] | None]:
236
+ """Load and downsample metrics for a single run."""
237
+ try:
238
+ df = await asyncio.to_thread(run_catalog.load_metrics, project, run_name, since_dt)
239
+ return (run_name, compress_metrics(df))
240
+ except Exception as e:
241
+ logger.warning(f"Failed to load metrics for {project}/{run_name}: {type(e).__name__}: {e}")
242
+ return (run_name, None)
243
+
244
+ # Execute all loads in parallel
245
+ results = await asyncio.gather(*[load_and_downsample(run_name) for run_name in run_list])
246
+
247
+ # Build metrics_by_run from results
248
+ metrics_by_run: dict[str, dict[str, dict[str, list]]] = {}
249
+ for run_name, metrics in results:
250
+ if metrics is not None:
251
+ metrics_by_run[run_name] = metrics
252
+
253
+ # Reorganize to metric-first structure using defaultdict for O(1) key insertion
254
+ metrics_data: dict[str, dict[str, dict[str, list]]] = defaultdict(dict)
255
+ for run_name, run_metrics in metrics_by_run.items():
256
+ for metric_name, metric_arrays in run_metrics.items():
257
+ metrics_data[metric_name][run_name] = metric_arrays
258
+
259
+ response_data = {"project": project, "metrics": metrics_data}
260
+
261
+ # Return response based on format
262
+ if format == "msgpack":
263
+ # Serialize to MessagePack
264
+ packed_data = msgpack.packb(response_data, use_single_float=True)
265
+ return Response(content=packed_data, media_type="application/x-msgpack")
266
+
267
+ return JSONResponse(content=response_data)
268
+
269
+
270
+ @router.get("/api/projects/{project}/metadata")
271
+ async def get_project_metadata_api(
272
+ project: ValidatedProject,
273
+ project_catalog: ProjectCatalogDep,
274
+ ) -> Metadata:
275
+ """Get project metadata.
276
+
277
+ Args:
278
+ project: Project name.
279
+
280
+ Returns:
281
+ Metadata object containing project metadata (tags, notes, etc.).
282
+
283
+ Raises:
284
+ HTTPException: 400 if project name is invalid.
285
+ """
286
+ # Use ProjectCatalog metadata API.
287
+ # Read-only catalog call: offload to a worker thread so the event loop
288
+ # is not blocked while waiting on file I/O. Write paths
289
+ # (update_metadata / delete) stay synchronous because they use a
290
+ # read-modify-write pattern that would race if run concurrently.
291
+ metadata = await asyncio.to_thread(project_catalog.get_metadata, project)
292
+ return Metadata.model_validate(metadata)
293
+
294
+
295
+ @router.put("/api/projects/{project}/metadata")
296
+ async def update_project_metadata_api(
297
+ project: ValidatedProject,
298
+ metadata: MetadataUpdateRequest,
299
+ project_catalog: ProjectCatalogDep,
300
+ _csrf: None = Depends(verify_csrf_header),
301
+ ) -> Metadata:
302
+ """Update project metadata.
303
+
304
+ Args:
305
+ project: Project name.
306
+ metadata: MetadataUpdateRequest containing fields to update.
307
+
308
+ Returns:
309
+ Metadata object containing the updated project metadata.
310
+
311
+ Raises:
312
+ HTTPException: 400 if project name is invalid.
313
+ """
314
+ if is_read_only():
315
+ existing = await asyncio.to_thread(project_catalog.get_metadata, project)
316
+ return Metadata.model_validate(existing)
317
+
318
+ update_data = metadata.model_dump(exclude_none=True)
319
+
320
+ # Use ProjectCatalog metadata API
321
+ updated_metadata = project_catalog.update_metadata(project, update_data)
322
+ return Metadata.model_validate(updated_metadata)
323
+
324
+
325
+ @router.delete("/api/projects/{project}")
326
+ async def delete_project(
327
+ project: ValidatedProject,
328
+ project_catalog: ProjectCatalogDep,
329
+ _csrf: None = Depends(verify_csrf_header),
330
+ ) -> Response:
331
+ """Delete a project and all its runs.
332
+
333
+ **Warning**: This operation is irreversible.
334
+
335
+ Args:
336
+ project: Project name.
337
+
338
+ Returns:
339
+ 204 No Content on success.
340
+
341
+ Raises:
342
+ HTTPException: 400 if project name is invalid, 403 if permission denied,
343
+ 404 if project not found, 500 for unexpected errors.
344
+ """
345
+ if is_read_only():
346
+ return Response(status_code=204)
347
+
348
+ try:
349
+ project_catalog.delete(project)
350
+ logger.info(f"Deleted project: {project}")
351
+ return Response(status_code=204)
352
+ except ProjectNotFoundError as e:
353
+ raise HTTPException(status_code=404, detail=str(e)) from e
354
+ except PermissionError as e:
355
+ logger.warning(f"Permission denied deleting project {project}: {e}")
356
+ raise HTTPException(status_code=403, detail="Permission denied") from e
357
+ except Exception as e:
358
+ logger.error(f"Error deleting project {project}: {type(e).__name__}: {e}")
359
+ raise HTTPException(status_code=500, detail="Failed to delete project") from e
360
+
361
+
362
+ @router.get("/api/projects/{project}/runs/{run}/metadata")
363
+ async def get_run_metadata_api(
364
+ project: ValidatedProject,
365
+ run: ValidatedRun,
366
+ run_catalog: RunCatalogDep,
367
+ ) -> dict[str, Any]:
368
+ """Get run metadata.
369
+
370
+ Args:
371
+ project: Project name.
372
+ run: Run name.
373
+
374
+ Returns:
375
+ Dictionary containing run metadata (tags, notes, params, etc.).
376
+
377
+ Raises:
378
+ HTTPException: 400 if project/run name is invalid.
379
+ """
380
+ # Read-only catalog call: offload to a worker thread so the event
381
+ # loop is not blocked on file I/O. Write paths stay synchronous to
382
+ # avoid read-modify-write races (see get_project_metadata_api for
383
+ # the rationale).
384
+ metadata = await asyncio.to_thread(run_catalog.get_metadata, project, run)
385
+ return metadata
386
+
387
+
388
+ @router.put("/api/projects/{project}/runs/{run}/metadata")
389
+ async def update_run_metadata_api(
390
+ project: ValidatedProject,
391
+ run: ValidatedRun,
392
+ metadata: MetadataUpdateRequest,
393
+ run_catalog: RunCatalogDep,
394
+ _csrf: None = Depends(verify_csrf_header),
395
+ ) -> dict[str, Any]:
396
+ """Update run metadata.
397
+
398
+ Args:
399
+ project: Project name.
400
+ run: Run name.
401
+ metadata: MetadataUpdateRequest containing fields to update.
402
+
403
+ Returns:
404
+ Dictionary containing the updated run metadata.
405
+
406
+ Raises:
407
+ HTTPException: 400 if project/run name is invalid.
408
+ """
409
+ if is_read_only():
410
+ existing = await asyncio.to_thread(run_catalog.get_metadata, project, run)
411
+ return existing
412
+
413
+ update_data = metadata.model_dump(exclude_none=True)
414
+
415
+ # Use RunCatalog metadata API
416
+ updated_metadata = run_catalog.update_metadata(project, run, update_data)
417
+ return updated_metadata
418
+
419
+
420
+ @router.delete("/api/projects/{project}/runs/{run}")
421
+ async def delete_run(
422
+ project: ValidatedProject,
423
+ run: ValidatedRun,
424
+ run_catalog: RunCatalogDep,
425
+ _csrf: None = Depends(verify_csrf_header),
426
+ ) -> Response:
427
+ """Delete a run and its artifacts.
428
+
429
+ **Warning**: This operation is irreversible.
430
+
431
+ Args:
432
+ project: Project name.
433
+ run: Run name.
434
+
435
+ Returns:
436
+ 204 No Content on success.
437
+
438
+ Raises:
439
+ HTTPException: 400 if project/run name is invalid, 403 if permission denied,
440
+ 404 if project or run not found, 500 for unexpected errors.
441
+ """
442
+ if is_read_only():
443
+ return Response(status_code=204)
444
+
445
+ try:
446
+ run_catalog.delete(project, run)
447
+ logger.info(f"Deleted run: {project}/{run}")
448
+ return Response(status_code=204)
449
+ except ProjectNotFoundError as e:
450
+ raise HTTPException(status_code=404, detail=str(e)) from e
451
+ except RunNotFoundError as e:
452
+ raise HTTPException(status_code=404, detail=str(e)) from e
453
+ except PermissionError as e:
454
+ logger.warning(f"Permission denied deleting run {project}/{run}: {e}")
455
+ raise HTTPException(status_code=403, detail="Permission denied") from e
456
+ except Exception as e:
457
+ logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}: {e}")
458
+ raise HTTPException(status_code=500, detail="Failed to delete run") from e
src/aspara/dashboard/routes/html_routes.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from aspara.models import RunStatus
24
+ from aspara.utils.timestamp import parse_to_ms
25
+
26
+ from ..dependencies import (
27
+ ProjectCatalogDep,
28
+ RunCatalogDep,
29
+ ValidatedProject,
30
+ ValidatedRun,
31
+ )
32
+ from ..services.template_service import (
33
+ TemplateService,
34
+ create_breadcrumbs,
35
+ render_mustache_response,
36
+ )
37
+
38
+ router = APIRouter()
39
+
40
+
41
+ def _format_duration_ms(duration_ms: float | int | None) -> str:
42
+ """Format a duration given in milliseconds into a human-readable string.
43
+
44
+ Returns ``"N/A"`` when no duration is available.
45
+ """
46
+ if duration_ms is None or duration_ms < 0:
47
+ return "N/A"
48
+ seconds = duration_ms / 1000.0
49
+ if seconds < 1:
50
+ return f"{int(duration_ms)}ms"
51
+ if seconds < 60:
52
+ return f"{seconds:.1f}s"
53
+ total_seconds = int(seconds)
54
+ minutes = total_seconds // 60
55
+ secs = total_seconds % 60
56
+ if minutes < 60:
57
+ return f"{minutes}m {secs}s"
58
+ hours = minutes // 60
59
+ mins = minutes % 60
60
+ if hours < 24:
61
+ return f"{hours}h {mins}m"
62
+ days = hours // 24
63
+ hrs = hours % 24
64
+ return f"{days}d {hrs}h"
65
+
66
+
67
+ @router.get("/")
68
+ async def home(
69
+ request: Request,
70
+ project_catalog: ProjectCatalogDep,
71
+ ) -> HTMLResponse:
72
+ """Render the projects list page."""
73
+ # Read-only catalog call: offload to worker threads so the event
74
+ # loop is not blocked on file I/O. Write paths stay synchronous to
75
+ # avoid read-modify-write races (see api_routes.get_project_metadata_api).
76
+ # Project metadata is loaded together with the directory scan in one pass
77
+ # to avoid the N+1 pattern of per-project file opens.
78
+ projects_with_metadata = await asyncio.to_thread(project_catalog.get_projects_with_metadata)
79
+
80
+ formatted_projects = []
81
+ for project, metadata in projects_with_metadata:
82
+ tags = metadata.get("tags") or []
83
+ formatted_projects.append(TemplateService.format_project_for_template(project, tags))
84
+
85
+ from aspara.config import get_project_search_mode
86
+
87
+ project_search_mode = get_project_search_mode()
88
+
89
+ context = {
90
+ "page_title": "Aspara",
91
+ "breadcrumbs": create_breadcrumbs([{"label": "Home", "is_home": True}]),
92
+ "projects": formatted_projects,
93
+ "has_projects": len(formatted_projects) > 0,
94
+ "project_search_mode": project_search_mode,
95
+ "read_only": is_read_only(),
96
+ }
97
+
98
+ html = render_mustache_response("projects_list", context)
99
+ return HTMLResponse(content=html)
100
+
101
+
102
+ @router.get("/projects/{project}")
103
+ async def project_detail(
104
+ request: Request,
105
+ project: ValidatedProject,
106
+ project_catalog: ProjectCatalogDep,
107
+ run_catalog: RunCatalogDep,
108
+ ) -> HTMLResponse:
109
+ """Project detail page - shows metrics charts."""
110
+ # Check if project exists
111
+ if not await asyncio.to_thread(project_catalog.exists, project):
112
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
113
+
114
+ runs = await asyncio.to_thread(run_catalog.get_runs, project)
115
+
116
+ # Format runs for template (excluding corrupted runs)
117
+ formatted_runs = []
118
+ for run in runs:
119
+ formatted = TemplateService.format_run_for_project_detail(run)
120
+ if formatted is not None:
121
+ formatted_runs.append(formatted)
122
+
123
+ # Find the most recent last_update from all runs
124
+ project_last_update = None
125
+ if runs:
126
+ last_updates = [r.last_update for r in runs if r.last_update is not None]
127
+ if last_updates:
128
+ project_last_update = max(last_updates)
129
+
130
+ context = {
131
+ "page_title": f"{project} - Metrics",
132
+ "breadcrumbs": create_breadcrumbs([
133
+ {"label": "Home", "url": "/", "is_home": True},
134
+ {"label": project},
135
+ ]),
136
+ "project": project,
137
+ "runs": formatted_runs,
138
+ "has_runs": len(formatted_runs) > 0,
139
+ "run_count": len(formatted_runs),
140
+ "formatted_project_last_update": (project_last_update.strftime("%b %d, %Y at %I:%M %p") if project_last_update else "N/A"),
141
+ "read_only": is_read_only(),
142
+ }
143
+
144
+ html = render_mustache_response("project_detail", context)
145
+ return HTMLResponse(content=html)
146
+
147
+
148
+ @router.get("/projects/{project}/runs")
149
+ async def list_project_runs(
150
+ request: Request,
151
+ project: ValidatedProject,
152
+ project_catalog: ProjectCatalogDep,
153
+ run_catalog: RunCatalogDep,
154
+ ) -> HTMLResponse:
155
+ """List runs in a project."""
156
+ # Check if project exists
157
+ if not await asyncio.to_thread(project_catalog.exists, project):
158
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
159
+
160
+ runs = await asyncio.to_thread(run_catalog.get_runs, project)
161
+
162
+ # Format runs for template
163
+ formatted_runs = [TemplateService.format_run_for_list(run) for run in runs]
164
+
165
+ context = {
166
+ "page_title": f"{project} - Runs",
167
+ "breadcrumbs": create_breadcrumbs([
168
+ {"label": "Home", "url": "/", "is_home": True},
169
+ {"label": project, "url": f"/projects/{project}"},
170
+ {"label": "Runs"},
171
+ ]),
172
+ "project": project,
173
+ "runs": formatted_runs,
174
+ "has_runs": len(formatted_runs) > 0,
175
+ "read_only": is_read_only(),
176
+ }
177
+
178
+ html = render_mustache_response("runs_list", context)
179
+ return HTMLResponse(content=html)
180
+
181
+
182
+ @router.get("/projects/{project}/runs/{run}")
183
+ async def get_run(
184
+ request: Request,
185
+ project: ValidatedProject,
186
+ run: ValidatedRun,
187
+ project_catalog: ProjectCatalogDep,
188
+ run_catalog: RunCatalogDep,
189
+ ) -> HTMLResponse:
190
+ """Get run details including parameters and metrics."""
191
+ # Check if project exists
192
+ if not await asyncio.to_thread(project_catalog.exists, project):
193
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
194
+
195
+ # Get Run information and check if it's corrupted
196
+ try:
197
+ current_run = await asyncio.to_thread(run_catalog.get, project, run)
198
+ except RunNotFoundError as e:
199
+ raise HTTPException(status_code=404, detail=f"Run '{run}' not found in project '{project}'") from e
200
+
201
+ is_corrupted = current_run.is_corrupted
202
+ error_message = current_run.error_message
203
+ run_tags = current_run.tags
204
+
205
+ # Load metrics, artifacts, and metadata in parallel
206
+ df_metrics, artifacts, metadata = await asyncio.gather(
207
+ asyncio.to_thread(run_catalog.load_metrics, project, run),
208
+ run_catalog.get_artifacts_async(project, run),
209
+ run_catalog.get_run_config_async(project, run),
210
+ )
211
+
212
+ # Extract params from metadata
213
+ params: dict[str, Any] = {}
214
+ params.update(metadata.get("params", {}))
215
+ params.update(metadata.get("config", {}))
216
+
217
+ # Format data for template
218
+ formatted_params = [{"key": k, "value": v} for k, v in params.items()]
219
+
220
+ # Get latest metrics for scalar display from wide-format DataFrame
221
+ latest_metrics: dict[str, Any] = {}
222
+ if len(df_metrics) > 0:
223
+ # Get last row (latest metrics)
224
+ last_row = df_metrics.tail(1).to_dicts()[0]
225
+ # Extract metric columns (those starting with underscore)
226
+ for col, value in last_row.items():
227
+ if col.startswith("_") and value is not None:
228
+ # Remove underscore prefix
229
+ metric_name = col[1:]
230
+ latest_metrics[metric_name] = value
231
+
232
+ formatted_latest_metrics = [{"key": k, "value": f"{v:.4f}" if isinstance(v, int | float) else str(v)} for k, v in latest_metrics.items()]
233
+
234
+ # Resolve start/finish timestamps (in ms) from metadata. The metadata may
235
+ # store them as either UNIX milliseconds (real API) or ISO 8601 strings
236
+ # (legacy/test fixtures), so normalize via parse_to_ms.
237
+ start_time_raw = metadata.get("start_time")
238
+ finish_time_raw = metadata.get("finish_time")
239
+ start_time_ms: int | None = None
240
+ finish_time_ms: int | None = None
241
+ if start_time_raw is not None:
242
+ try:
243
+ start_time_ms = parse_to_ms(start_time_raw)
244
+ except ValueError:
245
+ start_time_ms = None
246
+ if finish_time_raw is not None:
247
+ try:
248
+ finish_time_ms = parse_to_ms(finish_time_raw)
249
+ except ValueError:
250
+ finish_time_ms = None
251
+
252
+ # Compute duration. For WIP runs (no finish_time), use the most recent
253
+ # metrics timestamp as the current end so the user sees elapsed time.
254
+ duration_ms: int | None = None
255
+ if start_time_ms is not None:
256
+ end_ms: int | None = finish_time_ms
257
+ if end_ms is None and len(df_metrics) > 0 and "timestamp" in df_metrics.columns:
258
+ last_ts = df_metrics.select("timestamp").to_series().max()
259
+ if isinstance(last_ts, datetime):
260
+ end_ms = int(last_ts.timestamp() * 1000)
261
+ elif isinstance(last_ts, (int, float)):
262
+ end_ms = int(last_ts)
263
+ if end_ms is not None:
264
+ duration_ms = end_ms - start_time_ms
265
+
266
+ if duration_ms is not None:
267
+ formatted_duration = _format_duration_ms(duration_ms)
268
+ elif not is_corrupted and current_run.status == RunStatus.WIP:
269
+ # WIP run with no metrics yet.
270
+ formatted_duration = "Running..."
271
+ else:
272
+ formatted_duration = "N/A"
273
+
274
+ # Step count = number of logged metric rows
275
+ step_count = len(df_metrics)
276
+
277
+ # Start time display: prefer metadata start_time, fall back to DataFrame
278
+ start_time_display = "N/A"
279
+ if start_time_ms is not None:
280
+ try:
281
+ from aspara.utils.timestamp import parse_to_datetime
282
+
283
+ start_time_display = parse_to_datetime(start_time_ms).strftime("%B %d, %Y at %I:%M %p")
284
+ except ValueError:
285
+ start_time_display = "N/A"
286
+ elif len(df_metrics) > 0 and "timestamp" in df_metrics.columns:
287
+ ts = df_metrics.select("timestamp").to_series().min()
288
+ if isinstance(ts, datetime):
289
+ start_time_display = ts.strftime("%B %d, %Y at %I:%M %p")
290
+
291
+ # Run status flags for template rendering
292
+ status = current_run.status
293
+ status_value = status.value
294
+
295
+ context = {
296
+ "page_title": f"{run} - Details",
297
+ "breadcrumbs": create_breadcrumbs([
298
+ {"label": "Home", "url": "/", "is_home": True},
299
+ {"label": project, "url": f"/projects/{project}"},
300
+ {"label": "Runs", "url": f"/projects/{project}/runs"},
301
+ {"label": run},
302
+ ]),
303
+ "project": project,
304
+ "run_name": run,
305
+ "params": formatted_params,
306
+ "has_params": len(formatted_params) > 0,
307
+ "latest_metrics": formatted_latest_metrics,
308
+ "has_latest_metrics": len(formatted_latest_metrics) > 0,
309
+ "formatted_start_time": start_time_display,
310
+ "duration": formatted_duration,
311
+ "step_count": step_count,
312
+ "status": status_value,
313
+ "is_wip": status == RunStatus.WIP,
314
+ "is_completed": status == RunStatus.COMPLETED,
315
+ "is_failed": status == RunStatus.FAILED,
316
+ "is_maybe_failed": status == RunStatus.MAYBE_FAILED,
317
+ "has_tags": len(run_tags) > 0,
318
+ "tags": run_tags,
319
+ "artifacts": [TemplateService.format_artifact_for_template(artifact) for artifact in artifacts],
320
+ "has_artifacts": len(artifacts) > 0,
321
+ "is_corrupted": is_corrupted,
322
+ "error_message": error_message,
323
+ "read_only": is_read_only(),
324
+ }
325
+
326
+ html = render_mustache_response("run_detail", context)
327
+ return HTMLResponse(content=html)
src/aspara/dashboard/routes/sse_routes.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (
21
+ SSE_METRICS_ITERATOR_CLOSE_TIMEOUT,
22
+ get_sse_heartbeat_interval,
23
+ get_sse_send_timeout,
24
+ is_dev_mode,
25
+ )
26
+ from aspara.models import MetricRecord, StatusRecord
27
+
28
+ from ..dependencies import RunCatalogDep, ValidatedProject
29
+ from ..utils import parse_and_validate_run_list
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter()
34
+
35
+
36
+ @router.get("/api/projects/{project}/runs/stream")
37
+ async def stream_multiple_runs(
38
+ project: ValidatedProject,
39
+ run_catalog: RunCatalogDep,
40
+ runs: str,
41
+ since: int = Query(
42
+ ...,
43
+ description="Filter metrics since this UNIX timestamp in milliseconds",
44
+ ),
45
+ ) -> EventSourceResponse:
46
+ """Stream metrics for multiple runs using Server-Sent Events (SSE).
47
+
48
+ Args:
49
+ project: Project name.
50
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
51
+ since: Filter to only stream metrics with timestamp >= since (required, UNIX ms).
52
+
53
+ Returns:
54
+ EventSourceResponse streaming metric and status events from all specified runs.
55
+ Event types:
56
+ - `metric`: `{"event": "metric", "data": <MetricRecord JSON>}`
57
+ - `status`: `{"event": "status", "data": <StatusRecord JSON>}`
58
+
59
+ Raises:
60
+ HTTPException: 400 if project/run name is invalid, 422 if since is missing.
61
+ """
62
+ logger.info(f"[SSE ENDPOINT] Called with project={project}, runs={runs}")
63
+
64
+ from ..main import app_state
65
+
66
+ try:
67
+ run_list = parse_and_validate_run_list(runs)
68
+ except ValueError as e:
69
+
70
+ async def validation_error_generator(msg: str = str(e)):
71
+ yield {"event": "error", "data": msg}
72
+
73
+ return EventSourceResponse(validation_error_generator())
74
+
75
+ # Convert UNIX ms to datetime
76
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc)
77
+
78
+ async def event_generator():
79
+ logger.info(f"[SSE] event_generator started for project={project}, runs={run_list}")
80
+
81
+ # Register current task for dev mode forced cancellation
82
+ current_task = asyncio.current_task()
83
+ if current_task is not None:
84
+ app_state.active_sse_tasks.add(current_task)
85
+
86
+ # Create shutdown queue for this connection
87
+ shutdown_queue: asyncio.Queue[None] = asyncio.Queue()
88
+ app_state.active_sse_connections.add(shutdown_queue)
89
+
90
+ # Use new subscribe() method with singleton watcher
91
+ targets = {project: run_list}
92
+ metrics_iterator = run_catalog.subscribe(targets, since=since_dt).__aiter__()
93
+ logger.info("[SSE] Created metrics_iterator using subscribe()")
94
+
95
+ # In dev mode, use shorter timeout for faster shutdown detection
96
+ dev_mode = is_dev_mode()
97
+ wait_timeout = 1.0 if dev_mode else None
98
+
99
+ # Track pending metric task to avoid re-creating it after timeout
100
+ # IMPORTANT: Cancelling a task that's awaiting inside an async generator
101
+ # will close the generator. We must NOT cancel metric_task on timeout.
102
+ pending_metric_task: asyncio.Task[MetricRecord | StatusRecord] | None = None
103
+
104
+ try:
105
+ while True:
106
+ # Check shutdown flag in dev mode
107
+ if dev_mode and app_state.shutting_down:
108
+ logger.info("[SSE] Dev mode: shutdown flag detected")
109
+ # Cancel pending metric_task before exiting
110
+ if pending_metric_task is not None:
111
+ pending_metric_task.cancel()
112
+ with suppress(asyncio.CancelledError):
113
+ await pending_metric_task
114
+ break
115
+
116
+ # Create metric_task only if we don't have a pending one
117
+ if pending_metric_task is None:
118
+ metric_coro = cast(
119
+ "Coroutine[Any, Any, MetricRecord | StatusRecord]",
120
+ metrics_iterator.__anext__(),
121
+ )
122
+ pending_metric_task = asyncio.create_task(metric_coro, name="metric_task")
123
+
124
+ # Always create a new shutdown_task
125
+ shutdown_coro = cast("Coroutine[Any, Any, Any]", shutdown_queue.get())
126
+ shutdown_task = asyncio.create_task(shutdown_coro, name="shutdown_task")
127
+
128
+ try:
129
+ done, pending = await asyncio.wait(
130
+ [pending_metric_task, shutdown_task],
131
+ return_when=asyncio.FIRST_COMPLETED,
132
+ timeout=wait_timeout,
133
+ )
134
+ except asyncio.CancelledError:
135
+ # Cancelled by lifespan handler in dev mode
136
+ logger.info("[SSE] Task cancelled (dev mode shutdown)")
137
+ pending_metric_task.cancel()
138
+ shutdown_task.cancel()
139
+ with suppress(asyncio.CancelledError):
140
+ await pending_metric_task
141
+ with suppress(asyncio.CancelledError):
142
+ await shutdown_task
143
+ raise
144
+
145
+ # Handle timeout (dev mode only)
146
+ if not done:
147
+ # Timeout occurred - only cancel shutdown_task, NOT metric_task
148
+ # Cancelling metric_task would close the async generator!
149
+ shutdown_task.cancel()
150
+ with suppress(asyncio.CancelledError):
151
+ await shutdown_task
152
+ # pending_metric_task is kept and will be reused in next iteration
153
+ continue
154
+
155
+ logger.debug(f"[SSE] asyncio.wait returned: done={[t.get_name() for t in done]}, pending={[t.get_name() for t in pending]}")
156
+
157
+ # Cancel pending tasks (but NOT metric_task if it's pending)
158
+ if shutdown_task in pending:
159
+ shutdown_task.cancel()
160
+ with suppress(asyncio.CancelledError):
161
+ await shutdown_task
162
+
163
+ # Check which task completed
164
+ if pending_metric_task in done:
165
+ # Reset so we create a new task in next iteration
166
+ completed_task = pending_metric_task
167
+ pending_metric_task = None
168
+ try:
169
+ record = completed_task.result()
170
+ if isinstance(record, MetricRecord):
171
+ logger.debug(f"[SSE] Sending metric to client: run={record.run}, step={record.step}")
172
+ yield {"event": "metric", "data": record.model_dump_json()}
173
+ elif isinstance(record, StatusRecord):
174
+ logger.info(f"[SSE] Sending status update to client: run={record.run}, status={record.status}")
175
+ yield {"event": "status", "data": record.model_dump_json()}
176
+ except StopAsyncIteration:
177
+ logger.info("[SSE] No more records (StopAsyncIteration)")
178
+ break
179
+ elif shutdown_task in done:
180
+ logger.info("[SSE] Shutdown requested")
181
+ # Cancel metric_task since we're shutting down
182
+ if pending_metric_task is not None:
183
+ pending_metric_task.cancel()
184
+ with suppress(asyncio.CancelledError):
185
+ await pending_metric_task
186
+ break
187
+
188
+ except asyncio.CancelledError:
189
+ logger.info("[SSE] Generator cancelled")
190
+ raise
191
+ except Exception as e:
192
+ # Log the full exception internally. In production, send a generic
193
+ # message to the client so internal paths, library internals, or
194
+ # other sensitive details are not leaked over the wire. In dev
195
+ # mode, include the exception text for easier debugging.
196
+ logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True)
197
+ error_data = str(e) if dev_mode else "Internal server error"
198
+ yield {"event": "error", "data": error_data}
199
+ finally:
200
+ # Clean up: remove this connection from active set
201
+ logger.info("[SSE] event_generator finished, cleaning up")
202
+ app_state.active_sse_connections.discard(shutdown_queue)
203
+ if current_task is not None:
204
+ app_state.active_sse_tasks.discard(current_task)
205
+ # Cancel pending metric task if still running
206
+ if pending_metric_task is not None and not pending_metric_task.done():
207
+ pending_metric_task.cancel()
208
+ with suppress(asyncio.CancelledError):
209
+ await pending_metric_task
210
+ # Close the async generator to trigger watcher unsubscribe
211
+ try:
212
+ await asyncio.wait_for(
213
+ metrics_iterator.aclose(),
214
+ timeout=SSE_METRICS_ITERATOR_CLOSE_TIMEOUT,
215
+ )
216
+ except asyncio.TimeoutError:
217
+ logger.warning("[SSE] Timeout closing metrics_iterator")
218
+ except Exception as e:
219
+ logger.warning(f"[SSE] Error closing metrics_iterator: {e}")
220
+
221
+ logger.info(f"[SSE ENDPOINT] Returning EventSourceResponse for runs={run_list}")
222
+ return EventSourceResponse(
223
+ event_generator(),
224
+ ping=get_sse_heartbeat_interval(),
225
+ send_timeout=get_sse_send_timeout(),
226
+ )
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,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pystache
14
+
15
+ from aspara.catalog import ProjectInfo, RunInfo
16
+ from aspara.config import get_resource_limits, is_dev_mode
17
+
18
+ BASE_DIR = Path(__file__).parent.parent
19
+ _mustache_renderer = pystache.Renderer(search_dirs=[str(BASE_DIR / "templates")])
20
+
21
+
22
+ def create_breadcrumbs(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
23
+ """Create standardized breadcrumbs with consistent formatting.
24
+
25
+ Args:
26
+ items: List of breadcrumb items with 'label' and optional 'url' keys.
27
+ First item is assumed to be Home.
28
+
29
+ Returns:
30
+ List of breadcrumb items with consistent is_not_first flags.
31
+ """
32
+ result = []
33
+
34
+ for i, item in enumerate(items):
35
+ crumb = item.copy()
36
+ crumb["is_not_first"] = i != 0
37
+
38
+ # Add home icon to first item if not already specified
39
+ if i == 0 and "is_home" not in crumb:
40
+ crumb["is_home"] = True
41
+
42
+ result.append(crumb)
43
+
44
+ return result
45
+
46
+
47
+ def render_mustache_response(template_name: str, context: dict[str, Any]) -> str:
48
+ """Render mustache template with context.
49
+
50
+ Args:
51
+ template_name: Name of the template file (without extension).
52
+ context: Template context dictionary.
53
+
54
+ Returns:
55
+ Rendered HTML string.
56
+ """
57
+ # Add common context variables
58
+ context.update({
59
+ "current_year": datetime.now(timezone.utc).year,
60
+ "page_title": context.get("page_title", "Aspara"),
61
+ "dev_mode": is_dev_mode(),
62
+ # Expose server-side resource limits to the client so the server
63
+ # remains the single source of truth for validation bounds.
64
+ "max_notes_length": get_resource_limits().max_notes_length,
65
+ })
66
+
67
+ # Render content template
68
+ content = _mustache_renderer.render_name(template_name, context)
69
+
70
+ # Render layout with content
71
+ layout_context = context.copy()
72
+ layout_context["content"] = content
73
+
74
+ return _mustache_renderer.render_name("layout", layout_context)
75
+
76
+
77
+ class TemplateService:
78
+ """Service for template rendering and data formatting.
79
+
80
+ This class provides methods for formatting data objects for template rendering.
81
+ """
82
+
83
+ @staticmethod
84
+ def format_project_for_template(project: ProjectInfo, tags: list[str] | None = None) -> dict[str, Any]:
85
+ """Format a ProjectInfo for template rendering.
86
+
87
+ Args:
88
+ project: ProjectInfo object.
89
+ tags: Optional list of tags from metadata.
90
+
91
+ Returns:
92
+ Dictionary suitable for template rendering.
93
+ """
94
+ return {
95
+ "name": project.name,
96
+ "run_count": project.run_count or 0,
97
+ "last_update": int(project.last_update.timestamp() * 1000) if project.last_update else 0,
98
+ "formatted_last_update": (project.last_update.strftime("%B %d, %Y at %I:%M %p") if project.last_update else "N/A"),
99
+ "tags": tags or [],
100
+ }
101
+
102
+ @staticmethod
103
+ def format_run_for_list(run: RunInfo) -> dict[str, Any]:
104
+ """Format a RunInfo for runs list template.
105
+
106
+ Args:
107
+ run: RunInfo object.
108
+
109
+ Returns:
110
+ Dictionary suitable for runs list template rendering.
111
+ """
112
+ return {
113
+ "name": run.name,
114
+ "param_count": run.param_count or 0,
115
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
116
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
117
+ "is_corrupted": run.is_corrupted,
118
+ "error_message": run.error_message,
119
+ "tags": run.tags,
120
+ "has_tags": len(run.tags) > 0,
121
+ "is_finished": run.is_finished,
122
+ "is_wip": run.status.value == "wip",
123
+ "status": run.status.value,
124
+ }
125
+
126
+ @staticmethod
127
+ def format_run_for_project_detail(run: RunInfo) -> dict[str, Any] | None:
128
+ """Format a RunInfo for project detail template (excludes corrupted runs).
129
+
130
+ Args:
131
+ run: RunInfo object.
132
+
133
+ Returns:
134
+ Dictionary suitable for project detail template rendering,
135
+ or None if the run is corrupted.
136
+ """
137
+ if run.is_corrupted:
138
+ return None
139
+
140
+ return {
141
+ "name": run.name,
142
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
143
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
144
+ "is_finished": run.is_finished,
145
+ "is_wip": run.status.value == "wip",
146
+ "status": run.status.value,
147
+ }
148
+
149
+ @staticmethod
150
+ def format_artifact_for_template(artifact: dict[str, Any]) -> dict[str, Any]:
151
+ """Format an artifact for template rendering with category flags.
152
+
153
+ Args:
154
+ artifact: Artifact dictionary.
155
+
156
+ Returns:
157
+ Dictionary with category boolean flags added.
158
+ """
159
+ category = artifact.get("category")
160
+ return {
161
+ **artifact,
162
+ "is_code": category == "code",
163
+ "is_config": category == "config",
164
+ "is_model": category == "model",
165
+ "is_data": category == "data",
166
+ "is_other": category == "other" or category is None,
167
+ }
src/aspara/dashboard/static/css/input.css ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @source "../../templates/**/*.mustache";
3
+
4
+ /* === Global reduced-motion guard ===
5
+ * Disable all CSS animations and transitions when the user has
6
+ * requested reduced motion at the OS / browser level. This is the
7
+ * single source of truth for reduced-motion handling — component-
8
+ * specific @media blocks are no longer needed.
9
+ * Order matters: this must come after @import "tailwindcss" so it
10
+ * can override Tailwind's animate-* utilities (e.g. animate-pulse).
11
+ */
12
+ @media (prefers-reduced-motion: reduce) {
13
+ *,
14
+ ::before,
15
+ ::after {
16
+ animation-duration: 0.01ms !important;
17
+ animation-iteration-count: 1 !important;
18
+ transition-duration: 0.01ms !important;
19
+ scroll-behavior: auto !important;
20
+ }
21
+ }
22
+
23
+ @theme {
24
+ --color-action: #2C2520;
25
+ --color-action-hover: #1a1512;
26
+ --color-action-disabled: #d4cfc9;
27
+
28
+ --color-secondary: #8B7F75;
29
+ --color-secondary-hover: #6B5F55;
30
+
31
+ --color-accent: #CC785C;
32
+ --color-accent-hover: #B5654A;
33
+ --color-accent-light: #E8A892;
34
+
35
+ --color-base-bg: #F5F3F0;
36
+ --color-base-border: #E6E3E0;
37
+ --color-base-surface: #FDFCFB;
38
+
39
+ --color-text-primary: #2C2520;
40
+ --color-text-secondary: #6B5F55;
41
+ --color-text-muted: #9B8F85;
42
+
43
+ --color-status-error: #C84C3C;
44
+ --color-status-success: #5A8B6F;
45
+ --color-status-warning: #D4864E;
46
+
47
+ --font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
48
+ --font-family-mono: "JetBrains Mono", Consolas, Monaco, monospace;
49
+
50
+ --radius-button: 0.5rem;
51
+ }
52
+
53
+ /* Custom styles beyond Tailwind */
54
+ .plot-container {
55
+ height: 24rem;
56
+ background: #FDFCFB;
57
+ padding: 1rem;
58
+ border-radius: 0;
59
+ border: 1px solid #E6E3E0;
60
+ box-shadow: none;
61
+ }
62
+
63
+ .sidebar {
64
+ width: 16rem;
65
+ background: #FDFCFB;
66
+ border: 1px solid #E6E3E0;
67
+ box-shadow: none;
68
+ }
69
+
70
+ /* Sidebar animation - optimized for responsiveness */
71
+ #runs-sidebar {
72
+ transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
73
+ }
74
+
75
+ .content-area {
76
+ padding: 2rem;
77
+ flex: 1;
78
+ }
79
+
80
+ /* === @jcubic/tagger Aspara Theme Override === */
81
+
82
+ /* Container - border and background only */
83
+ .tagger {
84
+ border: 1px solid var(--color-base-border);
85
+ border-radius: 0.375rem;
86
+ background: var(--color-base-surface);
87
+ }
88
+
89
+ /* Tags - color and border-radius only, keep original padding/display */
90
+ .tagger > ul > li:not(.tagger-new) > :first-child {
91
+ background: var(--color-base-bg);
92
+ border: 1px solid var(--color-base-border);
93
+ border-radius: 9999px;
94
+ /* padding is kept as original 4px 4px 4px 8px - do not override */
95
+ }
96
+
97
+ /* Tag text color */
98
+ .tagger > ul > li:not(.tagger-new) span.label {
99
+ color: var(--color-text-muted);
100
+ }
101
+
102
+ /* Close button */
103
+ .tagger li a.close {
104
+ color: var(--color-text-muted);
105
+ }
106
+ .tagger li a.close:hover {
107
+ color: var(--color-text-primary);
108
+ }
109
+
110
+ /* Input field */
111
+ .tagger .tagger-new input {
112
+ font-size: 0.75rem;
113
+ color: var(--color-text-primary);
114
+ }
115
+ .tagger .tagger-new input::placeholder {
116
+ color: var(--color-text-muted);
117
+ }
118
+
119
+ /* === Status Icon Styles (based on data-status attribute) === */
120
+
121
+ [data-status="wip"] {
122
+ @apply animate-pulse text-status-warning;
123
+ }
124
+
125
+ [data-status="completed"] {
126
+ @apply text-status-success;
127
+ }
128
+
129
+ [data-status="failed"] {
130
+ @apply text-status-error;
131
+ }
132
+
133
+ [data-status="maybe_failed"] {
134
+ @apply text-status-warning;
135
+ }
136
+
137
+ /* === Note Editor Cursor Styles === */
138
+
139
+ /* Placeholder (Add note...) cursor */
140
+ .note-content .text-text-muted.italic {
141
+ cursor: pointer;
142
+ }
143
+
144
+ /* Edit link cursor */
145
+ .note-edit-btn {
146
+ cursor: pointer;
147
+ }
148
+
149
+ /* === Dialog Styles === */
150
+
151
+ dialog.delete-dialog {
152
+ position: fixed;
153
+ top: 50%;
154
+ left: 50%;
155
+ transform: translate(-50%, -50%);
156
+ margin: 0;
157
+ border: 1px solid var(--color-base-border);
158
+ border-radius: 0.5rem;
159
+ background: var(--color-base-surface);
160
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
161
+ max-width: 28rem;
162
+ width: calc(100% - 2rem);
163
+ }
164
+
165
+ dialog.delete-dialog::backdrop {
166
+ background: rgb(0 0 0 / 0.5);
167
+ }
168
+
169
+ /* === Card Interactive Styles === */
170
+
171
+ /* Common card styles */
172
+ .card-interactive {
173
+ @apply transition-colors duration-150 outline-none;
174
+ @apply hover:border-accent;
175
+ @apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2;
176
+ }
177
+
178
+ /* === SSE Status Indicator === */
179
+
180
+ .sse-status {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 0.375rem;
184
+ font-size: 0.75rem;
185
+ color: var(--color-text-muted);
186
+ }
187
+
188
+ .sse-dot {
189
+ width: 0.5rem;
190
+ height: 0.5rem;
191
+ border-radius: 9999px;
192
+ flex-shrink: 0;
193
+ }
194
+
195
+ .sse-dot--connected {
196
+ background: var(--color-status-success);
197
+ animation: sse-pulse 2s ease-in-out infinite;
198
+ }
199
+
200
+ .sse-dot--reconnecting {
201
+ background: var(--color-status-warning);
202
+ animation: sse-blink 1s ease-in-out infinite;
203
+ }
204
+
205
+ .sse-dot--disconnected {
206
+ background: var(--color-status-error);
207
+ }
208
+
209
+ @keyframes sse-pulse {
210
+ 0%, 100% { opacity: 1; }
211
+ 50% { opacity: 0.4; }
212
+ }
213
+
214
+ @keyframes sse-blink {
215
+ 0%, 100% { opacity: 1; }
216
+ 50% { opacity: 0.2; }
217
+ }
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,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Delete API utility functions
3
+ * Pure API calls without UI logic
4
+ */
5
+
6
+ import { isDev } from '../dev-mode.js';
7
+
8
+ /**
9
+ * Parse an error response, returning a meaningful message.
10
+ * In dev mode, includes the raw response body for debugging.
11
+ * @param {Response} response - The failed fetch response
12
+ * @returns {Promise<string>} Error message
13
+ */
14
+ async function parseErrorResponse(response) {
15
+ let detail = 'Unknown error';
16
+ let rawBody = null;
17
+ try {
18
+ const errorData = await response.json();
19
+ detail = errorData.detail || detail;
20
+ } catch {
21
+ detail = `Server error: ${response.status}`;
22
+ if (isDev()) {
23
+ try {
24
+ rawBody = await response.text();
25
+ } catch {
26
+ // ignore
27
+ }
28
+ }
29
+ }
30
+ if (isDev() && rawBody) {
31
+ return `${detail} (raw: ${rawBody.slice(0, 200)})`;
32
+ }
33
+ return detail;
34
+ }
35
+
36
+ /**
37
+ * Delete a project via API
38
+ * @param {string} projectName - The project name to delete
39
+ * @returns {Promise<object>} - Response data
40
+ * @throws {Error} - API error
41
+ */
42
+ export async function deleteProjectApi(projectName) {
43
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}`, {
44
+ method: 'DELETE',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ 'X-Requested-With': 'XMLHttpRequest',
48
+ },
49
+ });
50
+
51
+ if (!response.ok) {
52
+ throw new Error(await parseErrorResponse(response));
53
+ }
54
+
55
+ // Handle 204 No Content responses
56
+ if (response.status === 204) {
57
+ return { message: 'Project deleted successfully' };
58
+ }
59
+
60
+ return response.json();
61
+ }
62
+
63
+ /**
64
+ * Delete a run via API
65
+ * @param {string} projectName - The project name
66
+ * @param {string} runName - The run name to delete
67
+ * @returns {Promise<object>} - Response data
68
+ * @throws {Error} - API error
69
+ */
70
+ export async function deleteRunApi(projectName, runName) {
71
+ const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/runs/${encodeURIComponent(runName)}`, {
72
+ method: 'DELETE',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'X-Requested-With': 'XMLHttpRequest',
76
+ },
77
+ });
78
+
79
+ if (!response.ok) {
80
+ throw new Error(await parseErrorResponse(response));
81
+ }
82
+
83
+ // Handle 204 No Content responses
84
+ if (response.status === 204) {
85
+ return { message: 'Run deleted successfully' };
86
+ }
87
+
88
+ return response.json();
89
+ }
src/aspara/dashboard/static/js/chart.js ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import { calculateDataRanges } from './chart/interaction-utils.js';
11
+ import { YScale, computePaddedYRange, isLogScale, isValidLogValue, valueToChartY } from './chart/scale.js';
12
+
13
+ export class Chart {
14
+ // Chart layout constants
15
+ static MARGIN = 60;
16
+ static CANVAS_SCALE_FACTOR = 1.5; // Reduced from 2.5 for better performance
17
+ static SIZE_UPDATE_RETRY_DELAY_MS = 100;
18
+ static FULLSCREEN_UPDATE_DELAY_MS = 100;
19
+ static MIN_DRAG_DISTANCE = 10;
20
+
21
+ // Grid constants
22
+ static X_GRID_COUNT = 10;
23
+ static Y_GRID_COUNT = 8;
24
+ static Y_PADDING_RATIO = 0.1;
25
+
26
+ // Style constants
27
+ static LINE_WIDTH = 1.5; // Normal view
28
+ static LINE_WIDTH_FULLSCREEN = 2.5; // Fullscreen view
29
+ static GRID_LINE_WIDTH = 0.5;
30
+ static LEGEND_ITEM_SPACING = 16;
31
+ static LEGEND_LINE_LENGTH = 16;
32
+ static LEGEND_TEXT_OFFSET = 4;
33
+ static LEGEND_Y_OFFSET = 30;
34
+
35
+ // Animation constants
36
+ static ANIMATION_PULSE_DURATION_MS = 1000;
37
+
38
+ constructor(containerId, options = {}) {
39
+ this.container = document.querySelector(containerId);
40
+ if (!this.container) {
41
+ throw new Error(`Container ${containerId} not found`);
42
+ }
43
+
44
+ this.data = null;
45
+ this.width = 0;
46
+ this.height = 0;
47
+ this.onZoomChange = options.onZoomChange || null;
48
+ this.onYScaleChange = options.onYScaleChange || null;
49
+ this.yScale = options.yScale || YScale.LINEAR;
50
+
51
+ // Color palette for managing series styles
52
+ this.colorPalette = new ChartColorPalette();
53
+
54
+ // Initialize modules
55
+ this.renderer = new ChartRenderer(this);
56
+ this.chartExport = new ChartExport(this);
57
+ this.interaction = new ChartInteraction(this, this.renderer);
58
+ this.controls = new ChartControls(this, this.chartExport);
59
+
60
+ this.hoverPoint = null;
61
+
62
+ this.zoomState = {
63
+ active: false,
64
+ startX: null,
65
+ startY: null,
66
+ currentX: null,
67
+ currentY: null,
68
+ };
69
+ this.zoom = { x: null, y: null };
70
+
71
+ // Fullscreen event handler (stored for cleanup)
72
+ this.fullscreenChangeHandler = null;
73
+
74
+ // Data range cache for performance optimization
75
+ this._cachedDataRanges = null;
76
+ this._lastDataRef = null;
77
+ this._lastScale = null;
78
+
79
+ this.init();
80
+ }
81
+
82
+ init() {
83
+ this.container.innerHTML = '';
84
+
85
+ this.canvas = document.createElement('canvas');
86
+ this.canvas.style.border = '1px solid #e5e7eb';
87
+ this.canvas.style.display = 'block';
88
+ this.canvas.style.maxWidth = '100%';
89
+
90
+ // Accessibility: expose the canvas as a decorative image whose
91
+ // accessible name is derived from the chart's <h3> title via
92
+ // aria-labelledby. The title id is stashed on the container div by
93
+ // createMetricChartContainer(); when absent (e.g. standalone Chart
94
+ // usage) fall back to a generic label.
95
+ this.canvas.setAttribute('role', 'img');
96
+ const titleId = this.container.dataset.ariaLabelledby;
97
+ if (titleId) {
98
+ this.canvas.setAttribute('aria-labelledby', titleId);
99
+ } else {
100
+ this.canvas.setAttribute('aria-label', 'Metrics line chart');
101
+ }
102
+
103
+ this.container.appendChild(this.canvas);
104
+ this.ctx = this.canvas.getContext('2d');
105
+
106
+ this.ctx.imageSmoothingEnabled = true;
107
+ this.ctx.imageSmoothingQuality = 'high';
108
+
109
+ // For throttling draw calls
110
+ this.pendingDraw = false;
111
+
112
+ this.updateSize();
113
+ this.interaction.setupEventListeners();
114
+ this.setupFullscreenListener();
115
+ this.controls.create();
116
+ }
117
+
118
+ updateSize() {
119
+ // Use clientWidth/clientHeight to get size excluding border
120
+ const rect = this.container.getBoundingClientRect?.();
121
+ const width = this.container.clientWidth || rect?.width || 0;
122
+ const height = this.container.clientHeight || rect?.height || 0;
123
+
124
+ // Retry later if container is not yet visible
125
+ if (width === 0 || height === 0) {
126
+ // Avoid infinite retry loops - only retry if we haven't set a size yet
127
+ if (this.width === 0 && this.height === 0) {
128
+ setTimeout(() => this.updateSize(), Chart.SIZE_UPDATE_RETRY_DELAY_MS);
129
+ }
130
+ return;
131
+ }
132
+
133
+ this.width = width;
134
+ this.height = height;
135
+
136
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
137
+
138
+ const dpr = window.devicePixelRatio || 1;
139
+ const totalScale = dpr * Chart.CANVAS_SCALE_FACTOR;
140
+
141
+ // Set internal canvas resolution (high-DPI)
142
+ this.canvas.width = this.width * totalScale;
143
+ this.canvas.height = this.height * totalScale;
144
+
145
+ // Set CSS display size to exact pixel values (matching internal aspect ratio)
146
+ this.canvas.style.width = `${this.width}px`;
147
+ this.canvas.style.height = `${this.height}px`;
148
+ this.canvas.style.display = 'block';
149
+
150
+ this.ctx.scale(totalScale, totalScale);
151
+
152
+ // Redraw if data is already set
153
+ if (this.data) {
154
+ this.draw();
155
+ }
156
+ }
157
+
158
+ setData(data) {
159
+ this.data = data;
160
+ // Invalidate data range cache when data changes
161
+ this._cachedDataRanges = null;
162
+ this._lastDataRef = null;
163
+ if (data?.series) {
164
+ this.colorPalette.ensureRunStyles(data.series.map((s) => s.name));
165
+ }
166
+ this.draw();
167
+ }
168
+
169
+ /**
170
+ * Get cached data ranges, recalculating only when data or scale changes.
171
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
172
+ */
173
+ _getDataRanges() {
174
+ if (this.data?.series !== this._lastDataRef || this.yScale !== this._lastScale) {
175
+ this._lastDataRef = this.data?.series;
176
+ this._lastScale = this.yScale;
177
+ this._cachedDataRanges = this._calculateDataRanges();
178
+ }
179
+ return this._cachedDataRanges;
180
+ }
181
+
182
+ /**
183
+ * Calculate data ranges from all series.
184
+ * For log scale, only positive values are considered.
185
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
186
+ */
187
+ _calculateDataRanges() {
188
+ return calculateDataRanges(this.data?.series || [], this.yScale);
189
+ }
190
+
191
+ draw() {
192
+ // Skip drawing if canvas size is not yet initialized
193
+ if (this.width === 0 || this.height === 0) {
194
+ return;
195
+ }
196
+
197
+ this.ctx.fillStyle = 'white';
198
+ this.ctx.fillRect(0, 0, this.width, this.height);
199
+
200
+ if (!this.data) {
201
+ console.warn('Chart.draw(): No data set');
202
+ return;
203
+ }
204
+
205
+ if (!this.data.series || !Array.isArray(this.data.series)) {
206
+ console.error('Chart.draw(): Invalid data format - series must be an array');
207
+ return;
208
+ }
209
+
210
+ if (this.data.series.length === 0) {
211
+ console.warn('Chart.draw(): Empty series array');
212
+ return;
213
+ }
214
+
215
+ const margin = Chart.MARGIN;
216
+ const plotWidth = this.width - margin * 2;
217
+ const plotHeight = this.height - margin * 2;
218
+
219
+ // Use cached data ranges for performance
220
+ const ranges = this._getDataRanges();
221
+ if (!ranges) {
222
+ console.warn('Chart.draw(): No valid data points in series');
223
+ return;
224
+ }
225
+
226
+ let { xMin, xMax, yMin, yMax } = ranges;
227
+
228
+ if (this.zoom.x) {
229
+ xMin = this.zoom.x.min;
230
+ xMax = this.zoom.x.max;
231
+ }
232
+ if (this.zoom.y) {
233
+ yMin = this.zoom.y.min;
234
+ yMax = this.zoom.y.max;
235
+ }
236
+
237
+ if (isLogScale(this.yScale) && (yMin <= 0 || yMax <= 0)) {
238
+ this.renderer.drawMessage('Log scale requires positive y values');
239
+ return;
240
+ }
241
+
242
+ const { yMinPadded, yMaxPadded } = computePaddedYRange(yMin, yMax, this.yScale, Chart.Y_PADDING_RATIO);
243
+
244
+ this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded, this.yScale);
245
+ this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded, this.yScale);
246
+
247
+ // Clip to plot area
248
+ this.ctx.save();
249
+ this.ctx.beginPath();
250
+ this.ctx.rect(margin, margin, plotWidth, plotHeight);
251
+ this.ctx.clip();
252
+
253
+ for (const series of this.data.series) {
254
+ if (!series.data?.steps?.length) continue;
255
+ const { steps, values } = series.data;
256
+
257
+ const style = this.colorPalette.getRunStyle(series.name);
258
+
259
+ this.ctx.strokeStyle = style.borderColor;
260
+ this.ctx.lineWidth = this.getLineWidth();
261
+ this.ctx.lineCap = 'round';
262
+ this.ctx.lineJoin = 'round';
263
+
264
+ // Apply border dash pattern
265
+ if (style.borderDash && style.borderDash.length > 0) {
266
+ this.ctx.setLineDash(style.borderDash);
267
+ } else {
268
+ this.ctx.setLineDash([]);
269
+ }
270
+
271
+ this.ctx.beginPath();
272
+ let hasValidPoint = false;
273
+
274
+ for (let i = 0; i < steps.length; i++) {
275
+ const value = values[i];
276
+ if (isLogScale(this.yScale) && !isValidLogValue(value)) {
277
+ // Break the line at non-positive values; resume at the next valid point.
278
+ if (hasValidPoint) {
279
+ this.ctx.stroke();
280
+ this.ctx.beginPath();
281
+ hasValidPoint = false;
282
+ }
283
+ continue;
284
+ }
285
+
286
+ const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth;
287
+ const y = valueToChartY(value, this.yScale, plotHeight, margin, yMinPadded, yMaxPadded);
288
+
289
+ if (!hasValidPoint) {
290
+ this.ctx.moveTo(x, y);
291
+ hasValidPoint = true;
292
+ } else {
293
+ this.ctx.lineTo(x, y);
294
+ }
295
+ }
296
+
297
+ this.ctx.stroke();
298
+ this.ctx.setLineDash([]); // Reset dash pattern
299
+ }
300
+
301
+ this.ctx.restore();
302
+ this.renderer.drawLegend();
303
+ this.interaction.drawHoverEffects();
304
+ this.interaction.drawZoomSelection();
305
+ }
306
+
307
+ getLineWidth() {
308
+ if (document.fullscreenElement === this.container) {
309
+ return Chart.LINE_WIDTH_FULLSCREEN;
310
+ }
311
+ return Chart.LINE_WIDTH;
312
+ }
313
+
314
+ getRunStyle(seriesName) {
315
+ return this.colorPalette.getRunStyle(seriesName);
316
+ }
317
+
318
+ setupFullscreenListener() {
319
+ // Store handler for cleanup
320
+ this.fullscreenChangeHandler = () => {
321
+ setTimeout(() => {
322
+ this.updateSize();
323
+ }, Chart.FULLSCREEN_UPDATE_DELAY_MS);
324
+ };
325
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
326
+ }
327
+
328
+ resetZoom() {
329
+ this.zoom.x = null;
330
+ this.zoom.y = null;
331
+ this.draw();
332
+ }
333
+
334
+ /**
335
+ * Toggle the y-axis scale between linear and logarithmic.
336
+ * Zoom is cleared because a linear zoom range does not translate to log space.
337
+ */
338
+ toggleYScale() {
339
+ this.setYScale(this.yScale === YScale.LOG ? YScale.LINEAR : YScale.LOG);
340
+ }
341
+
342
+ /**
343
+ * Set the y-axis scale explicitly.
344
+ * @param {string} scale - YScale.LINEAR or YScale.LOG
345
+ */
346
+ setYScale(scale) {
347
+ if (scale !== YScale.LINEAR && scale !== YScale.LOG) return;
348
+ this.yScale = scale;
349
+ this.zoom.y = null;
350
+ this.hoverPoint = null;
351
+ if (this.onYScaleChange) {
352
+ this.onYScaleChange(this.yScale);
353
+ }
354
+ this.draw();
355
+ }
356
+
357
+ setExternalZoom(zoomState) {
358
+ if (zoomState?.x) {
359
+ this.zoom.x = { ...zoomState.x };
360
+ this.draw();
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Add a new data point to an existing series (SoA format)
366
+ * @param {string} runName - Name of the run
367
+ * @param {number} step - Step number
368
+ * @param {number} value - Metric value
369
+ */
370
+ addDataPoint(runName, step, value) {
371
+ console.log(`[Chart] addDataPoint called: run=${runName}, step=${step}, value=${value}`);
372
+
373
+ if (!this.data || !this.data.series) {
374
+ console.warn('[Chart] No data or series available');
375
+ return;
376
+ }
377
+
378
+ // Find the series for this run
379
+ let series = this.data.series.find((s) => s.name === runName);
380
+
381
+ if (!series) {
382
+ // Create new series if it doesn't exist (SoA format)
383
+ series = {
384
+ name: runName,
385
+ data: { steps: [], values: [] },
386
+ };
387
+ this.data.series.push(series);
388
+ }
389
+
390
+ const { steps, values } = series.data;
391
+
392
+ // Binary search to find insertion position
393
+ let left = 0;
394
+ let right = steps.length;
395
+ while (left < right) {
396
+ const mid = (left + right) >> 1;
397
+ if (steps[mid] < step) {
398
+ left = mid + 1;
399
+ } else if (steps[mid] > step) {
400
+ right = mid;
401
+ } else {
402
+ // Exact match - update existing value
403
+ values[mid] = value;
404
+ this.scheduleDraw();
405
+ return;
406
+ }
407
+ }
408
+
409
+ // Insert at the found position (usually at the end, so O(1) in practice)
410
+ steps.splice(left, 0, step);
411
+ values.splice(left, 0, value);
412
+
413
+ // Invalidate data range cache when data changes
414
+ this._cachedDataRanges = null;
415
+
416
+ // Schedule redraw using requestAnimationFrame to throttle updates
417
+ this.scheduleDraw();
418
+ }
419
+
420
+ /**
421
+ * Schedule a draw operation using requestAnimationFrame
422
+ * This prevents excessive redraws when multiple data points arrive rapidly
423
+ */
424
+ scheduleDraw() {
425
+ console.log('[Chart] scheduleDraw called, pendingDraw:', this.pendingDraw);
426
+
427
+ if (this.pendingDraw) {
428
+ return; // Draw already scheduled
429
+ }
430
+
431
+ this.pendingDraw = true;
432
+ requestAnimationFrame(() => {
433
+ console.log('[Chart] requestAnimationFrame callback executing');
434
+ this.pendingDraw = false;
435
+ this.draw();
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Clean up event listeners and resources.
441
+ */
442
+ destroy() {
443
+ if (this.fullscreenChangeHandler) {
444
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
445
+ this.fullscreenChangeHandler = null;
446
+ }
447
+ if (this.interaction) {
448
+ this.interaction.destroy();
449
+ }
450
+ if (this.controls) {
451
+ this.controls.destroy();
452
+ }
453
+ }
454
+ }
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,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CHART_CONTROL_LABELS, ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_HELP, ICON_RESET_ZOOM } from '../html-utils.js';
2
+
3
+ /**
4
+ * Apply shared styling and ARIA attributes to a chart control button.
5
+ * @param {HTMLButtonElement} button
6
+ * @param {string} label - Accessible name (also used as tooltip)
7
+ */
8
+ function styleControlButton(button, label) {
9
+ button.type = 'button';
10
+ button.setAttribute('aria-label', label);
11
+ button.title = label;
12
+ button.style.cssText = `
13
+ width: 32px;
14
+ height: 32px;
15
+ border: 1px solid #ddd;
16
+ background: white;
17
+ cursor: pointer;
18
+ border-radius: 6px;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ color: #555;
23
+ `;
24
+ }
25
+
26
+ export class ChartControls {
27
+ constructor(chart, chartExport) {
28
+ this.chart = chart;
29
+ this.chartExport = chartExport;
30
+ this.buttonContainer = null;
31
+ this.resetButton = null;
32
+ this.fullSizeButton = null;
33
+ this.downloadButton = null;
34
+ this.downloadMenu = null;
35
+ this.helpButton = null;
36
+ this.helpPopover = null;
37
+ this.logScaleControl = null;
38
+ this.logScaleCheckbox = null;
39
+
40
+ // Document click handler (stored for cleanup)
41
+ this.documentClickHandler = null;
42
+ // fullscreenchange handler (stored for cleanup)
43
+ this.fullscreenChangeHandler = null;
44
+ // Keydown handler for Esc / arrow keys inside open menus (stored for cleanup)
45
+ this._menuKeydownHandler = null;
46
+ }
47
+
48
+ create() {
49
+ this.chart.container.style.position = 'relative';
50
+
51
+ this.buttonContainer = document.createElement('div');
52
+ this.buttonContainer.style.cssText = `
53
+ position: absolute;
54
+ top: 10px;
55
+ right: 10px;
56
+ display: flex;
57
+ gap: 8px;
58
+ z-index: 10;
59
+ `;
60
+
61
+ this.createLogScaleControl();
62
+ this.createResetButton();
63
+ this.createFullSizeButton();
64
+ this.createDownloadButton();
65
+ this.createHelpButton();
66
+
67
+ this.buttonContainer.appendChild(this.logScaleControl);
68
+ this.buttonContainer.appendChild(this.resetButton);
69
+ this.buttonContainer.appendChild(this.fullSizeButton);
70
+ this.buttonContainer.appendChild(this.downloadButton);
71
+ this.buttonContainer.appendChild(this.helpButton);
72
+ this.chart.container.appendChild(this.buttonContainer);
73
+
74
+ this.updateLogScaleControl();
75
+ // Wire up callback so the checkbox reflects scale changes from other sources
76
+ // (e.g. keyboard shortcuts) without coupling Chart to ChartControls.
77
+ const existingYScaleCallback = this.chart.onYScaleChange;
78
+ this.chart.onYScaleChange = (scale) => {
79
+ if (existingYScaleCallback) existingYScaleCallback(scale);
80
+ this.updateLogScaleControl();
81
+ };
82
+ }
83
+
84
+ createResetButton() {
85
+ this.resetButton = document.createElement('button');
86
+ this.resetButton.innerHTML = ICON_RESET_ZOOM;
87
+ styleControlButton(this.resetButton, CHART_CONTROL_LABELS.resetZoom);
88
+
89
+ this.attachButtonHover(this.resetButton);
90
+ this.resetButton.addEventListener('click', () => this.chart.resetZoom());
91
+ }
92
+
93
+ createFullSizeButton() {
94
+ this.fullSizeButton = document.createElement('button');
95
+ this.fullSizeButton.innerHTML = ICON_FULLSCREEN;
96
+ styleControlButton(this.fullSizeButton, CHART_CONTROL_LABELS.enterFullscreen);
97
+
98
+ this.attachButtonHover(this.fullSizeButton);
99
+ this.fullSizeButton.addEventListener('click', () => this.fitToFullSize());
100
+
101
+ // Keep the title/aria-label in sync with the actual fullscreen state so
102
+ // the tooltip reflects what clicking the button will do (e.g. after the
103
+ // user exits fullscreen via Esc).
104
+ this.fullscreenChangeHandler = () => {
105
+ if (!this.fullSizeButton) return;
106
+ const label = document.fullscreenElement
107
+ ? CHART_CONTROL_LABELS.exitFullscreen
108
+ : CHART_CONTROL_LABELS.enterFullscreen;
109
+ this.fullSizeButton.title = label;
110
+ this.fullSizeButton.setAttribute('aria-label', label);
111
+ };
112
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
113
+ }
114
+
115
+ createDownloadButton() {
116
+ this.downloadButton = document.createElement('button');
117
+ this.downloadButton.innerHTML = ICON_DOWNLOAD;
118
+ styleControlButton(this.downloadButton, CHART_CONTROL_LABELS.download);
119
+ this.downloadButton.style.position = 'relative';
120
+ // Declare the popup menu relationship for AT users.
121
+ this.downloadButton.setAttribute('aria-haspopup', 'menu');
122
+ this.downloadButton.setAttribute('aria-expanded', 'false');
123
+
124
+ this.downloadMenu = document.createElement('div');
125
+ this.downloadMenu.setAttribute('role', 'menu');
126
+ this.downloadMenu.style.cssText = `
127
+ position: absolute;
128
+ top: 100%;
129
+ right: 0;
130
+ background: white;
131
+ border: 1px solid #ddd;
132
+ border-radius: 6px;
133
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
134
+ display: none;
135
+ flex-direction: column;
136
+ width: 120px;
137
+ z-index: 20;
138
+ `;
139
+
140
+ const downloadOptions = [
141
+ { format: 'CSV', label: 'CSV format' },
142
+ { format: 'SVG', label: 'SVG image' },
143
+ { format: 'PNG', label: 'PNG image' },
144
+ ];
145
+
146
+ for (const option of downloadOptions) {
147
+ const menuItem = document.createElement('button');
148
+ menuItem.textContent = option.label;
149
+ menuItem.setAttribute('role', 'menuitem');
150
+ menuItem.style.cssText = `
151
+ padding: 8px 12px;
152
+ text-align: left;
153
+ background: none;
154
+ border: none;
155
+ cursor: pointer;
156
+ font-size: 13px;
157
+ color: #333;
158
+ `;
159
+ menuItem.addEventListener('mouseenter', () => {
160
+ menuItem.style.background = '#f5f5f5';
161
+ });
162
+ menuItem.addEventListener('mouseleave', () => {
163
+ menuItem.style.background = 'none';
164
+ });
165
+ menuItem.addEventListener('click', (e) => {
166
+ e.stopPropagation();
167
+ this.chartExport.downloadData(option.format);
168
+ this.toggleDownloadMenu(false);
169
+ });
170
+ this.downloadMenu.appendChild(menuItem);
171
+ }
172
+
173
+ this.downloadButton.appendChild(this.downloadMenu);
174
+
175
+ this.attachButtonHover(this.downloadButton);
176
+ this.downloadButton.addEventListener('click', () => this.toggleDownloadMenu());
177
+ }
178
+
179
+ createLogScaleControl() {
180
+ const controlId = `logscale-${Math.random().toString(36).slice(2, 9)}`;
181
+
182
+ this.logScaleControl = document.createElement('label');
183
+ this.logScaleControl.htmlFor = controlId;
184
+ this.logScaleControl.style.cssText = `
185
+ height: 32px;
186
+ border: 1px solid #ddd;
187
+ background: white;
188
+ border-radius: 6px;
189
+ display: flex;
190
+ align-items: center;
191
+ padding: 0 10px;
192
+ gap: 6px;
193
+ font-size: 12px;
194
+ color: #555;
195
+ cursor: pointer;
196
+ user-select: none;
197
+ `;
198
+
199
+ this.logScaleCheckbox = document.createElement('input');
200
+ this.logScaleCheckbox.type = 'checkbox';
201
+ this.logScaleCheckbox.id = controlId;
202
+ this.logScaleCheckbox.setAttribute('aria-label', CHART_CONTROL_LABELS.toggleLogScale);
203
+ this.logScaleCheckbox.style.cursor = 'pointer';
204
+
205
+ const labelText = document.createElement('span');
206
+ labelText.textContent = 'logscale';
207
+
208
+ this.logScaleControl.appendChild(this.logScaleCheckbox);
209
+ this.logScaleControl.appendChild(labelText);
210
+
211
+ this.logScaleControl.addEventListener('mouseenter', () => {
212
+ this.logScaleControl.style.background = '#f5f5f5';
213
+ this.logScaleControl.style.borderColor = '#bbb';
214
+ });
215
+ this.logScaleControl.addEventListener('mouseleave', () => {
216
+ this.logScaleControl.style.background = 'white';
217
+ this.logScaleControl.style.borderColor = '#ddd';
218
+ });
219
+
220
+ this.logScaleCheckbox.addEventListener('change', () => {
221
+ this.chart.toggleYScale();
222
+ });
223
+ }
224
+
225
+ updateLogScaleControl() {
226
+ if (!this.logScaleCheckbox) return;
227
+ const isLog = this.chart.yScale === 'log';
228
+ this.logScaleCheckbox.checked = isLog;
229
+ }
230
+
231
+ createHelpButton() {
232
+ this.helpButton = document.createElement('button');
233
+ this.helpButton.innerHTML = ICON_HELP;
234
+ styleControlButton(this.helpButton, CHART_CONTROL_LABELS.help);
235
+ this.helpButton.style.position = 'relative';
236
+ // The help popover is informational, not a menu, but it is a popup.
237
+ this.helpButton.setAttribute('aria-haspopup', 'dialog');
238
+ this.helpButton.setAttribute('aria-expanded', 'false');
239
+
240
+ this.helpPopover = document.createElement('div');
241
+ this.helpPopover.setAttribute('role', 'dialog');
242
+ this.helpPopover.style.cssText = `
243
+ position: absolute;
244
+ top: 100%;
245
+ right: 0;
246
+ background: white;
247
+ border: 1px solid #ddd;
248
+ border-radius: 6px;
249
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
250
+ display: none;
251
+ flex-direction: column;
252
+ width: 220px;
253
+ z-index: 20;
254
+ padding: 10px 12px;
255
+ `;
256
+
257
+ // SSOT: chart interaction descriptions live here. Each button's
258
+ // `title` attribute stays short; this popover is the canonical place
259
+ // for the full explanation.
260
+ const items = [
261
+ { icon: '✋', text: 'Drag on chart to zoom' },
262
+ { icon: '↺', text: 'Click reset to restore view' },
263
+ { icon: '⛶', text: 'Click fullscreen to expand' },
264
+ { icon: '⤓', text: 'Click download to export data' },
265
+ { icon: 'log', text: 'Check logscale to toggle y-axis scale' },
266
+ ];
267
+
268
+ for (const item of items) {
269
+ const row = document.createElement('div');
270
+ row.style.cssText = `
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 8px;
274
+ padding: 4px 0;
275
+ font-size: 13px;
276
+ color: #333;
277
+ `;
278
+ const iconSpan = document.createElement('span');
279
+ iconSpan.textContent = item.icon;
280
+ iconSpan.setAttribute('aria-hidden', 'true');
281
+ iconSpan.style.cssText = 'width: 18px; text-align: center; flex-shrink: 0;';
282
+ const textSpan = document.createElement('span');
283
+ textSpan.textContent = item.text;
284
+ row.appendChild(iconSpan);
285
+ row.appendChild(textSpan);
286
+ this.helpPopover.appendChild(row);
287
+ }
288
+
289
+ this.helpButton.appendChild(this.helpPopover);
290
+
291
+ this.attachButtonHover(this.helpButton);
292
+ this.helpButton.addEventListener('click', (e) => {
293
+ e.stopPropagation();
294
+ this.toggleHelpPopover();
295
+ });
296
+
297
+ // Reuse the document click handler pattern from the download menu to
298
+ // close the help popover when clicking outside.
299
+ const existingHandler = this.documentClickHandler;
300
+ this.documentClickHandler = (e) => {
301
+ if (this.downloadButton && !this.downloadButton.contains(e.target)) {
302
+ this.toggleDownloadMenu(false);
303
+ }
304
+ if (this.helpButton && !this.helpButton.contains(e.target)) {
305
+ this.toggleHelpPopover(false);
306
+ }
307
+ };
308
+ // Replace the previously registered handler (if any) with the unified one.
309
+ if (existingHandler) {
310
+ document.removeEventListener('click', existingHandler);
311
+ }
312
+ document.addEventListener('click', this.documentClickHandler);
313
+
314
+ // Global keydown handler: Esc closes the open popup and returns focus
315
+ // to the triggering button; arrow keys move between menu items of the
316
+ // download menu (ARIA Authoring Practices menu pattern).
317
+ this._menuKeydownHandler = (e) => this._handleMenuKeydown(e);
318
+ document.addEventListener('keydown', this._menuKeydownHandler);
319
+ }
320
+
321
+ /**
322
+ * Handle keyboard navigation for open popups (Esc / Arrow / Home / End).
323
+ * Follows the ARIA Authoring Practices Guide menu pattern.
324
+ */
325
+ _handleMenuKeydown(e) {
326
+ const downloadOpen = this.downloadMenu?.style.display === 'flex';
327
+ const helpOpen = this.helpPopover?.style.display === 'flex';
328
+ if (!downloadOpen && !helpOpen) return;
329
+
330
+ // Esc closes whichever popup is open and returns focus to its trigger.
331
+ if (e.key === 'Escape') {
332
+ e.preventDefault();
333
+ if (downloadOpen) {
334
+ this.toggleDownloadMenu(false);
335
+ this.downloadButton?.focus();
336
+ } else if (helpOpen) {
337
+ this.toggleHelpPopover(false);
338
+ this.helpButton?.focus();
339
+ }
340
+ return;
341
+ }
342
+
343
+ // Arrow / Home / End only apply to the download menu (role=menu).
344
+ if (!downloadOpen) return;
345
+
346
+ const items = Array.from(this.downloadMenu.querySelectorAll('[role="menuitem"]'));
347
+ if (items.length === 0) return;
348
+ const currentIndex = items.indexOf(document.activeElement);
349
+
350
+ if (e.key === 'ArrowDown') {
351
+ e.preventDefault();
352
+ items[(currentIndex + 1) % items.length].focus();
353
+ } else if (e.key === 'ArrowUp') {
354
+ e.preventDefault();
355
+ items[(currentIndex - 1 + items.length) % items.length].focus();
356
+ } else if (e.key === 'Home') {
357
+ e.preventDefault();
358
+ items[0].focus();
359
+ } else if (e.key === 'End') {
360
+ e.preventDefault();
361
+ items[items.length - 1].focus();
362
+ }
363
+ }
364
+
365
+ toggleHelpPopover(forceState) {
366
+ if (!this.helpPopover) return;
367
+ const isVisible = this.helpPopover.style.display === 'flex';
368
+ const newState = forceState !== undefined ? forceState : !isVisible;
369
+ this.helpPopover.style.display = newState ? 'flex' : 'none';
370
+ if (this.helpButton) {
371
+ this.helpButton.setAttribute('aria-expanded', newState ? 'true' : 'false');
372
+ }
373
+ if (newState) {
374
+ // Move focus into the dialog so AT users perceive the context switch.
375
+ this.helpPopover.focus?.();
376
+ }
377
+ }
378
+
379
+ fitToFullSize() {
380
+ if (!document.fullscreenElement) {
381
+ if (this.chart.container.requestFullscreen) {
382
+ this.chart.container.requestFullscreen();
383
+ } else if (this.chart.container.webkitRequestFullscreen) {
384
+ this.chart.container.webkitRequestFullscreen();
385
+ } else if (this.chart.container.mozRequestFullScreen) {
386
+ this.chart.container.mozRequestFullScreen();
387
+ } else if (this.chart.container.msRequestFullscreen) {
388
+ this.chart.container.msRequestFullscreen();
389
+ }
390
+ this.fullSizeButton.title = CHART_CONTROL_LABELS.exitFullscreen;
391
+ this.fullSizeButton.setAttribute('aria-label', CHART_CONTROL_LABELS.exitFullscreen);
392
+ } else {
393
+ if (document.exitFullscreen) {
394
+ document.exitFullscreen();
395
+ } else if (document.webkitExitFullscreen) {
396
+ document.webkitExitFullscreen();
397
+ } else if (document.mozCancelFullScreen) {
398
+ document.mozCancelFullScreen();
399
+ } else if (document.msExitFullscreen) {
400
+ document.msExitFullscreen();
401
+ }
402
+ this.fullSizeButton.title = CHART_CONTROL_LABELS.enterFullscreen;
403
+ this.fullSizeButton.setAttribute('aria-label', CHART_CONTROL_LABELS.enterFullscreen);
404
+ }
405
+ }
406
+
407
+ toggleDownloadMenu(forceState) {
408
+ const isVisible = this.downloadMenu.style.display === 'flex';
409
+ const newState = forceState !== undefined ? forceState : !isVisible;
410
+
411
+ this.downloadMenu.style.display = newState ? 'flex' : 'none';
412
+ if (this.downloadButton) {
413
+ this.downloadButton.setAttribute('aria-expanded', newState ? 'true' : 'false');
414
+ }
415
+ if (newState) {
416
+ // Move focus to the first menu item so keyboard users can
417
+ // immediately navigate with arrows (ARIA APG menu pattern).
418
+ const firstItem = this.downloadMenu.querySelector('[role="menuitem"]');
419
+ firstItem?.focus();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Attach hover effect to a button element
425
+ * @param {HTMLButtonElement} button - Button element to attach hover effect
426
+ */
427
+ attachButtonHover(button) {
428
+ button.addEventListener('mouseenter', () => {
429
+ button.style.background = '#f5f5f5';
430
+ button.style.borderColor = '#bbb';
431
+ });
432
+ button.addEventListener('mouseleave', () => {
433
+ button.style.background = 'white';
434
+ button.style.borderColor = '#ddd';
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Clean up event listeners and remove DOM elements.
440
+ */
441
+ destroy() {
442
+ if (this.documentClickHandler) {
443
+ document.removeEventListener('click', this.documentClickHandler);
444
+ this.documentClickHandler = null;
445
+ }
446
+ if (this.fullscreenChangeHandler) {
447
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
448
+ this.fullscreenChangeHandler = null;
449
+ }
450
+ if (this._menuKeydownHandler) {
451
+ document.removeEventListener('keydown', this._menuKeydownHandler);
452
+ this._menuKeydownHandler = null;
453
+ }
454
+ if (this.buttonContainer?.parentNode) {
455
+ this.buttonContainer.parentNode.removeChild(this.buttonContainer);
456
+ }
457
+ this.buttonContainer = null;
458
+ this.resetButton = null;
459
+ this.fullSizeButton = null;
460
+ this.downloadButton = null;
461
+ this.downloadMenu = null;
462
+ this.helpButton = null;
463
+ this.helpPopover = null;
464
+ this.logScaleControl = null;
465
+ this.logScaleCheckbox = null;
466
+ }
467
+ }
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
+ }