github-actions[bot] commited on
Commit
6b18711
·
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 +35 -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 +202 -0
  25. src/aspara/catalog/run_catalog.py +749 -0
  26. src/aspara/catalog/watcher.py +524 -0
  27. src/aspara/cli.py +403 -0
  28. src/aspara/config.py +214 -0
  29. src/aspara/dashboard/__init__.py +5 -0
  30. src/aspara/dashboard/dependencies.py +120 -0
  31. src/aspara/dashboard/main.py +145 -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 +414 -0
  37. src/aspara/dashboard/routes/html_routes.py +234 -0
  38. src/aspara/dashboard/routes/sse_routes.py +214 -0
  39. src/aspara/dashboard/services/__init__.py +9 -0
  40. src/aspara/dashboard/services/template_service.py +164 -0
  41. src/aspara/dashboard/static/css/input.css +171 -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 +420 -0
  47. src/aspara/dashboard/static/js/chart/color-palette.js +198 -0
  48. src/aspara/dashboard/static/js/chart/controls.js +224 -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,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
+ "organizeImports": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true,
10
+ "suspicious": {
11
+ "noExplicitAny": "off"
12
+ },
13
+ "style": {
14
+ "useImportType": "off"
15
+ }
16
+ }
17
+ },
18
+ "formatter": {
19
+ "enabled": true,
20
+ "indentStyle": "space",
21
+ "indentWidth": 2,
22
+ "lineWidth": 160
23
+ },
24
+ "javascript": {
25
+ "formatter": {
26
+ "quoteStyle": "single",
27
+ "semicolons": "always",
28
+ "trailingCommas": "es5"
29
+ }
30
+ },
31
+ "files": {
32
+ "include": ["src/**/*.js", "tests/**/*.js", "*.js"],
33
+ "ignore": ["node_modules/**", "dist/**", "build/**", "docs/**", "site/**", "site.old/**", "playwright-report/**", ".venv", "coverage/**"]
34
+ }
35
+ }
examples/generate_random_runs.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sample script to generate multiple random experiment runs.
3
+ Creates 4 different runs, each recording 100 steps of metrics.
4
+ """
5
+
6
+ import math
7
+ import random
8
+
9
+ import aspara
10
+
11
+
12
+ def generate_metrics_with_trend(
13
+ step: int,
14
+ total_steps: int,
15
+ base_values: dict[str, float],
16
+ noise_levels: dict[str, float],
17
+ trends: dict[str, float],
18
+ ) -> dict[str, float]:
19
+ """
20
+ Generate metrics with trend and noise.
21
+
22
+ Args:
23
+ step: Current step
24
+ total_steps: Total number of steps
25
+ base_values: Initial values for each metric
26
+ noise_levels: Noise level for each metric
27
+ trends: Final change amount for each metric
28
+
29
+ Returns:
30
+ Generated metrics
31
+ """
32
+ progress = step / total_steps
33
+ metrics = {}
34
+
35
+ for metric_name, base_value in base_values.items():
36
+ # Change due to trend (linear + slight exponential component)
37
+ trend_factor = progress * (1.0 + 0.2 * math.log(1 + 5 * progress))
38
+ trend_change = trends[metric_name] * trend_factor
39
+
40
+ # Random noise (sine wave + Gaussian noise)
41
+ noise = (
42
+ noise_levels[metric_name] * math.sin(step * 0.2) * 0.3 # Periodic noise
43
+ + noise_levels[metric_name] * random.gauss(0, 0.5) # Random noise
44
+ )
45
+
46
+ # Calculate final value
47
+ value = base_value + trend_change + noise
48
+
49
+ # Limit value range (accuracy between 0-1, loss >= 0)
50
+ if "accuracy" in metric_name:
51
+ value = max(0.0, min(1.0, value))
52
+ elif "loss" in metric_name:
53
+ value = max(0.01, value)
54
+
55
+ metrics[metric_name] = value
56
+
57
+ return metrics
58
+
59
+
60
+ def create_run_config(run_id: int) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
61
+ """
62
+ Create configuration for each run.
63
+
64
+ Args:
65
+ run_id: Run number
66
+
67
+ Returns:
68
+ Tuple of (initial values, noise levels, trends)
69
+ """
70
+ # Set slightly different initial values for each run
71
+ base_values = {
72
+ "accuracy": 0.3 + random.uniform(-0.1, 0.1),
73
+ "loss": 1.0 + random.uniform(-0.2, 0.2),
74
+ "val_accuracy": 0.25 + random.uniform(-0.1, 0.1),
75
+ "val_loss": 1.1 + random.uniform(-0.2, 0.2),
76
+ }
77
+
78
+ # Set noise levels
79
+ noise_levels = {
80
+ "accuracy": 0.02 + 0.01 * run_id,
81
+ "loss": 0.05 + 0.02 * run_id,
82
+ "val_accuracy": 0.03 + 0.01 * run_id,
83
+ "val_loss": 0.07 + 0.02 * run_id,
84
+ }
85
+
86
+ # Set trends (accuracy increases, loss decreases)
87
+ trends = {
88
+ "accuracy": 0.5 + random.uniform(-0.1, 0.1), # Upward trend
89
+ "loss": -0.8 + random.uniform(-0.1, 0.1), # Downward trend
90
+ "val_accuracy": 0.45 + random.uniform(-0.1, 0.1), # Upward trend (slightly lower than train)
91
+ "val_loss": -0.75 + random.uniform(-0.1, 0.1), # Downward trend (slightly higher than train)
92
+ }
93
+
94
+ return base_values, noise_levels, trends
95
+
96
+
97
+ def generate_run(
98
+ project: str,
99
+ run_id: int,
100
+ total_steps: int = 100,
101
+ project_tags: list[str] | None = None,
102
+ run_name: str | None = None,
103
+ ) -> None:
104
+ """
105
+ Generate an experiment run with the specified ID.
106
+
107
+ Args:
108
+ project: Project name
109
+ run_id: Run number
110
+ total_steps: Number of steps to generate
111
+ project_tags: Common tags for the project
112
+ run_name: Run name (generated from run_id if not specified)
113
+ """
114
+ # Initialize run
115
+ if run_name is None:
116
+ run_name = f"random_training_run_{run_id}"
117
+
118
+ print(f"Starting generation of run {run_id} for project '{project}'! ({run_name})")
119
+
120
+ # Create run configuration
121
+ base_values, noise_levels, trends = create_run_config(run_id)
122
+
123
+ # Add run-specific tags (fruits) to project-common tags (animals)
124
+ fruits = ["apple", "pear", "orange", "grape", "banana", "mango"]
125
+ num_fruit_tags = random.randint(1, len(fruits))
126
+ run_tags = random.sample(fruits, k=num_fruit_tags)
127
+
128
+ aspara.init(
129
+ project=project,
130
+ name=run_name,
131
+ config={
132
+ "learning_rate": 0.01 * (1 + 0.2 * run_id),
133
+ "batch_size": 32 * (1 + run_id % 2),
134
+ "optimizer": ["adam", "sgd", "rmsprop", "adagrad"][run_id % 4],
135
+ "model_type": "mlp",
136
+ "hidden_layers": [128, 64, 32],
137
+ "dropout": 0.2 + 0.05 * run_id,
138
+ "epochs": 10,
139
+ "run_id": run_id,
140
+ },
141
+ tags=run_tags,
142
+ project_tags=project_tags,
143
+ )
144
+
145
+ # Simulate training loop
146
+ print(f"Generating metrics for {total_steps} steps...")
147
+ for step in range(total_steps):
148
+ # Generate metrics
149
+ metrics = generate_metrics_with_trend(step, total_steps, base_values, noise_levels, trends)
150
+
151
+ # Log metrics
152
+ aspara.log(metrics, step=step)
153
+
154
+ # Show progress (every 10 steps)
155
+ if step % 10 == 0 or step == total_steps - 1:
156
+ print(f" Step {step}/{total_steps - 1}: accuracy={metrics['accuracy']:.3f}, loss={metrics['loss']:.3f}")
157
+
158
+ # Finish run
159
+ aspara.finish()
160
+
161
+ print(f"Completed generation of run {run_id} for project '{project}'!")
162
+
163
+
164
+ def main() -> None:
165
+ """Main function: Generate multiple runs."""
166
+ steps_per_run = 100
167
+
168
+ # Cool secret project names
169
+ project_names = [
170
+ "Project_Phoenix",
171
+ "Operation_Midnight",
172
+ "Genesis_Initiative",
173
+ "Project_Prometheus",
174
+ ]
175
+
176
+ # Famous SF titles (mix of Western and Japanese works)
177
+ sf_titles = [
178
+ "AKIRA",
179
+ "Ghost_in_the_Shell",
180
+ "Planetes",
181
+ "Steins_Gate",
182
+ "Paprika",
183
+ "Blade_Runner",
184
+ "Dune",
185
+ "Neuromancer",
186
+ "Foundation",
187
+ "The_Martian",
188
+ "Interstellar",
189
+ "Solaris",
190
+ "Hyperion",
191
+ "Snow_Crash",
192
+ "Contact",
193
+ "Arrival",
194
+ "Gravity",
195
+ "Moon",
196
+ "Ex_Machina",
197
+ "Tenet",
198
+ ]
199
+
200
+ print(f"Generating {len(project_names)} projects!")
201
+ print(f" Each project has 4-5 runs! ({steps_per_run} steps per run)")
202
+ animals = ["dog", "cat", "rabbit", "coala", "bear", "goat"]
203
+
204
+ # Shuffle SF titles before using
205
+ shuffled_sf_titles = sf_titles.copy()
206
+ random.shuffle(shuffled_sf_titles)
207
+ sf_title_index = 0
208
+
209
+ # Generate multiple projects, create 4-5 runs for each project
210
+ for project_name in project_names:
211
+ # Project-common tags (animals)
212
+ num_project_tags = random.randint(1, len(animals))
213
+ project_tags = random.sample(animals, k=num_project_tags)
214
+
215
+ num_runs = random.randint(4, 5)
216
+ for run_id in range(num_runs):
217
+ # Use SF title as run name
218
+ run_name = shuffled_sf_titles[sf_title_index % len(shuffled_sf_titles)]
219
+ sf_title_index += 1
220
+ generate_run(project_name, run_id, steps_per_run, project_tags, run_name)
221
+ print("") # Insert blank line
222
+
223
+ print("All runs have been generated!")
224
+ print(" Check them out on the dashboard!")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
examples/slow_metrics_writer.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simple slow metrics writer for testing SSE real-time updates.
3
+
4
+ This is a simpler version that's easy to customize.
5
+
6
+ Usage:
7
+ # Terminal 1: Start dashboard
8
+ aspara dashboard
9
+
10
+ # Terminal 2: Run this script
11
+ uv run python examples/slow_metrics_writer.py
12
+
13
+ # Terminal 3 (optional): Run again with different run name
14
+ uv run python examples/slow_metrics_writer.py --run experiment_2
15
+
16
+ # Open browser and watch metrics update in real-time!
17
+ """
18
+
19
+ import argparse
20
+ import math
21
+ import random
22
+ import time
23
+ from datetime import datetime
24
+
25
+ from aspara import Run
26
+
27
+
28
+ def main():
29
+ parser = argparse.ArgumentParser(description="Slow metrics writer for SSE testing")
30
+ parser.add_argument("--project", default="sse_test", help="Project name")
31
+ parser.add_argument("--run", default="experiment_1", help="Run name")
32
+ parser.add_argument("--steps", type=int, default=30, help="Number of steps")
33
+ parser.add_argument("--delay", type=float, default=2.0, help="Delay between steps (seconds)")
34
+ args = parser.parse_args()
35
+
36
+ # Random parameters for this run
37
+ run_seed = hash(args.run) % 1000
38
+ random.seed(run_seed)
39
+
40
+ loss_base = 1.2 + random.uniform(-0.2, 0.2)
41
+ acc_base = 0.3 + random.uniform(-0.1, 0.1)
42
+ noise_level = 0.015 + random.uniform(0, 0.01)
43
+
44
+ # Create run
45
+ run = Run(
46
+ project=args.project,
47
+ name=args.run,
48
+ tags=["sse", "test", "realtime"],
49
+ notes="Testing SSE real-time updates",
50
+ )
51
+
52
+ print(f"🚀 Writing metrics to {args.project}/{args.run}")
53
+ print(f" Steps: {args.steps}, Delay: {args.delay}s")
54
+ print(f" Base Loss: {loss_base:.3f}, Base Acc: {acc_base:.3f}")
55
+ print(" Open http://localhost:3141 to watch in real-time!\n")
56
+
57
+ # Write metrics gradually
58
+ for step in range(args.steps):
59
+ # Add noise (periodic + random)
60
+ noise = noise_level * (math.sin(step * 0.4) * 0.5 + random.gauss(0, 0.5))
61
+
62
+ # Simulate training metrics with noise
63
+ loss = max(0.01, (loss_base / (step + 1)) + noise)
64
+ accuracy = min(0.99, acc_base + (0.6 * (1.0 - 1.0 / (step + 1))) + noise * 0.3)
65
+
66
+ run.log(
67
+ {
68
+ "loss": loss,
69
+ "accuracy": accuracy,
70
+ "step_time": 0.1 + (step * 0.01) + random.uniform(-0.01, 0.01),
71
+ },
72
+ step=step,
73
+ )
74
+
75
+ timestamp = datetime.now().strftime("%H:%M:%S")
76
+ print(f"[{timestamp}] Step {step:3d}/{args.steps} | loss={loss:.4f} acc={accuracy:.4f}")
77
+
78
+ time.sleep(args.delay)
79
+
80
+ run.finish(exit_code=0)
81
+ print(f"\n✅ Completed! Total time: {args.steps * args.delay:.1f}s")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
icons.config.json ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "icons": [
3
+ {
4
+ "name": "stop",
5
+ "style": "solid",
6
+ "id": "status-icon-wip",
7
+ "comment": "Work in progress status"
8
+ },
9
+ {
10
+ "name": "x-mark",
11
+ "style": "outline",
12
+ "id": "status-icon-failed",
13
+ "comment": "Failed status"
14
+ },
15
+ {
16
+ "name": "check",
17
+ "style": "outline",
18
+ "id": "status-icon-completed",
19
+ "comment": "Completed status"
20
+ },
21
+ {
22
+ "name": "pencil-square",
23
+ "style": "outline",
24
+ "id": "icon-edit",
25
+ "comment": "Edit/pencil icon for note editing"
26
+ },
27
+ {
28
+ "name": "arrow-uturn-left",
29
+ "style": "outline",
30
+ "id": "icon-reset-zoom",
31
+ "comment": "Reset zoom for chart controls"
32
+ },
33
+ {
34
+ "name": "arrows-pointing-out",
35
+ "style": "outline",
36
+ "id": "icon-fullscreen",
37
+ "comment": "Full screen/expand for chart controls"
38
+ },
39
+ {
40
+ "name": "arrow-down-tray",
41
+ "style": "outline",
42
+ "id": "icon-download",
43
+ "comment": "Download for chart controls"
44
+ },
45
+ {
46
+ "name": "trash",
47
+ "style": "outline",
48
+ "id": "icon-delete",
49
+ "comment": "Delete/trash icon for delete buttons"
50
+ },
51
+ {
52
+ "name": "chevron-left",
53
+ "style": "outline",
54
+ "id": "icon-chevron-left",
55
+ "comment": "Chevron left for sidebar collapse"
56
+ },
57
+ {
58
+ "name": "chevron-right",
59
+ "style": "outline",
60
+ "id": "icon-chevron-right",
61
+ "comment": "Chevron right for sidebar expand"
62
+ },
63
+ {
64
+ "name": "bars-3",
65
+ "style": "outline",
66
+ "id": "icon-menu",
67
+ "comment": "Hamburger menu icon for settings"
68
+ },
69
+ {
70
+ "name": "exclamation-triangle",
71
+ "style": "outline",
72
+ "id": "icon-exclamation-triangle",
73
+ "comment": "Warning/danger icon for confirm modal and maybe_failed status"
74
+ },
75
+ {
76
+ "name": "information-circle",
77
+ "style": "outline",
78
+ "id": "icon-information-circle",
79
+ "comment": "Info icon for confirm modal"
80
+ }
81
+ ]
82
+ }
mkdocs.yml ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ site_name: Aspara User Manual
2
+ site_description: Aspara, blazingly fast metrics tracker for machine learning experiments
3
+ site_author: Aspara Development Group
4
+ copyright: Copyright © 2026 Aspara Development Group
5
+ use_directory_urls: false
6
+
7
+ theme:
8
+ name: material
9
+ language: en
10
+ logo: aspara-icon.png
11
+ favicon: aspara-icon.png
12
+ palette:
13
+ primary: indigo
14
+ accent: indigo
15
+ features:
16
+ - navigation.tabs
17
+ - navigation.sections
18
+ - navigation.top
19
+ - search.highlight
20
+ - content.code.copy
21
+
22
+ extra_css:
23
+ - aspara-theme.css
24
+
25
+ plugins:
26
+ - search
27
+ - mkdocstrings:
28
+ handlers:
29
+ python:
30
+ paths:
31
+ - src
32
+ selection:
33
+ docstring_style: google
34
+ rendering:
35
+ show_source: true
36
+ show_if_no_docstring: false
37
+ show_root_heading: false
38
+ show_root_toc_entry: false
39
+ heading_level: 2
40
+ show_signature_annotations: true
41
+ separate_signature: true
42
+ merge_init_into_class: false
43
+ docstring_section_style: "spacy"
44
+ show_symbol_type_heading: true
45
+ show_symbol_type_toc: true
46
+
47
+ markdown_extensions:
48
+ - pymdownx.highlight:
49
+ anchor_linenums: true
50
+ - pymdownx.superfences
51
+ - pymdownx.inlinehilite
52
+ - admonition
53
+ - pymdownx.details
54
+ - pymdownx.tabbed:
55
+ alternate_style: true
56
+ - tables
57
+ - footnotes
58
+
59
+ nav:
60
+ - Home: index.md
61
+ - Getting Started:
62
+ - Overview: getting-started.md
63
+ - User Guide:
64
+ - Overview: user-guide/basics.md
65
+ - Core Concepts: user-guide/concepts.md
66
+ - Metadata and Notes: user-guide/metadata.md
67
+ - Visualizing Results in Dashboard: user-guide/dashboard-visualization.md
68
+ - Terminal UI: user-guide/terminal-ui.md
69
+ - Best Practices: user-guide/best-practices.md
70
+ - Troubleshooting: user-guide/troubleshooting.md
71
+ - Advanced:
72
+ - Configuration: advanced/configuration.md
73
+ - LocalRun vs RemoteRun: advanced/local-vs-remote.md
74
+ - Storage: advanced/storage.md
75
+ - Dashboard: advanced/dashboard.md
76
+ - Tracker API: advanced/tracker-api.md
77
+ - Read-only Mode: advanced/read-only-mode.md
78
+ - Examples:
79
+ - Overview: examples/index.md
80
+ - PyTorch: examples/pytorch_example.md
81
+ - TensorFlow / Keras: examples/tensorflow_example.md
82
+ - scikit-learn: examples/sklearn_example.md
83
+ - API Reference:
84
+ - Overview: api/index.md
85
+ - aspara: api/aspara.md
86
+ - Run: api/run.md
87
+ - Dashboard API: api/dashboard.md
88
+ - Tracker API: api/tracker.md
89
+ - Contributing: contributing.md
package.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "aspara",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "vitest run",
9
+ "test:watch": "vitest",
10
+ "test:coverage": "vitest run --coverage",
11
+ "test:ui": "vitest --ui",
12
+ "test:ci": "vitest run --coverage",
13
+ "lint": "biome lint",
14
+ "format": "biome format --write",
15
+ "check": "biome check --write",
16
+ "build:icons": "node scripts/build-icons.js",
17
+ "build:js": "vite build",
18
+ "build:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --minify",
19
+ "watch:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --watch",
20
+ "build": "pnpm run build:icons && pnpm run build:js && pnpm run build:css"
21
+ },
22
+ "keywords": [],
23
+ "author": "",
24
+ "license": "Apache-2.0",
25
+ "packageManager": "pnpm@10.6.3",
26
+ "dependencies": {
27
+ "@jcubic/tagger": "^0.6.2",
28
+ "@msgpack/msgpack": "^3.1.3"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "^1.9.4",
32
+ "@playwright/test": "^1.58.2",
33
+ "@swc/core": "^1.15.17",
34
+ "@tailwindcss/cli": "^4.2.1",
35
+ "@testing-library/dom": "^10.4.1",
36
+ "@vitest/coverage-v8": ">=4.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
+ // E2Eテストのみを対象にする(Vitestとの競合を避ける)
11
+ testDir: './tests/e2e',
12
+
13
+ // 並列実行の worker 数
14
+ // CI では 2 workers、ローカルでは制限なし
15
+ workers: process.env.CI ? 2 : undefined,
16
+
17
+ // テストの実行タイムアウト
18
+ timeout: 30 * 1000,
19
+
20
+ // テスト実行の期待値
21
+ expect: {
22
+ // 要素が表示されるまでの最大待機時間
23
+ timeout: 5000,
24
+ },
25
+
26
+ // 失敗したテストのスクリーンショットを撮る
27
+ use: {
28
+ // ベースURL
29
+ baseURL: `http://localhost:${BASE_PORT}`,
30
+
31
+ // スクリーンショットを撮る
32
+ screenshot: 'only-on-failure',
33
+
34
+ // トレースを記録する
35
+ trace: 'on-first-retry',
36
+
37
+ // ダウンロードを許可
38
+ acceptDownloads: true,
39
+ },
40
+
41
+ // テスト実行のレポート形式
42
+ // 'list' はコンソール出力のみ、HTMLレポートは生成しない
43
+ reporter: process.env.CI ? 'github' : 'list',
44
+
45
+ // テスト前にサーバーを自動起動
46
+ webServer: {
47
+ command: `uv run aspara dashboard --port ${BASE_PORT}`,
48
+ port: BASE_PORT,
49
+ reuseExistingServer: !process.env.CI,
50
+ timeout: 60 * 1000,
51
+ },
52
+
53
+ // プロジェクト設定
54
+ projects: [
55
+ {
56
+ name: 'chromium',
57
+ use: { ...devices['Desktop Chrome'] },
58
+ },
59
+ {
60
+ name: 'firefox',
61
+ use: { ...devices['Desktop Firefox'] },
62
+ },
63
+ {
64
+ name: 'webkit',
65
+ use: { ...devices['Desktop Safari'] },
66
+ },
67
+ ],
68
+ });
pnpm-lock.yaml ADDED
@@ -0,0 +1,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,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ProjectCatalog - Catalog for discovering and managing projects.
3
+
4
+ This module provides functionality for listing, getting, and deleting projects
5
+ in the data directory.
6
+ """
7
+
8
+ import logging
9
+ import shutil
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from aspara.exceptions import ProjectNotFoundError
17
+ from aspara.storage import ProjectMetadataStorage
18
+ from aspara.utils.validators import validate_name, validate_safe_path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ProjectInfo(BaseModel):
24
+ """Project information."""
25
+
26
+ name: str
27
+ run_count: int
28
+ last_update: datetime
29
+
30
+
31
+ class ProjectCatalog:
32
+ """Catalog for discovering and managing projects.
33
+
34
+ This class provides methods to list, get, and delete projects
35
+ in the data directory. It does not handle metrics data directly;
36
+ that responsibility belongs to MetricsStorage.
37
+ """
38
+
39
+ def __init__(self, data_dir: str | Path) -> None:
40
+ """Initialize the project catalog.
41
+
42
+ Args:
43
+ data_dir: Base directory for data storage
44
+ """
45
+ self.data_dir = Path(data_dir)
46
+
47
+ def get_projects(self) -> list[ProjectInfo]:
48
+ """List all projects in the data directory.
49
+
50
+ Uses os.scandir() for efficient directory iteration with cached stat info.
51
+
52
+ Returns:
53
+ List of ProjectInfo objects sorted by name
54
+ """
55
+ import os
56
+
57
+ projects: list[ProjectInfo] = []
58
+ if not self.data_dir.exists():
59
+ return projects
60
+
61
+ try:
62
+ # Use scandir for efficient iteration with cached stat info
63
+ with os.scandir(self.data_dir) as project_entries:
64
+ for project_entry in project_entries:
65
+ if not project_entry.is_dir():
66
+ continue
67
+
68
+ # Collect run files with stat info in single pass
69
+ run_files_mtime: list[float] = []
70
+ with os.scandir(project_entry.path) as file_entries:
71
+ for file_entry in file_entries:
72
+ if (
73
+ file_entry.name.endswith(".jsonl")
74
+ and not file_entry.name.endswith(".wal.jsonl")
75
+ and not file_entry.name.endswith(".meta.jsonl")
76
+ ):
77
+ # stat() result is cached by scandir
78
+ run_files_mtime.append(file_entry.stat().st_mtime)
79
+
80
+ run_count = len(run_files_mtime)
81
+
82
+ # Find last update time - use cached stat from scandir
83
+ if run_files_mtime:
84
+ last_update = datetime.fromtimestamp(max(run_files_mtime), tz=timezone.utc)
85
+ else:
86
+ last_update = datetime.fromtimestamp(project_entry.stat().st_mtime, tz=timezone.utc)
87
+
88
+ projects.append(
89
+ ProjectInfo(
90
+ name=project_entry.name,
91
+ run_count=run_count,
92
+ last_update=last_update,
93
+ )
94
+ )
95
+ except (OSError, PermissionError):
96
+ pass
97
+
98
+ return sorted(projects, key=lambda p: p.name)
99
+
100
+ def get(self, name: str) -> ProjectInfo:
101
+ """Get a specific project by name.
102
+
103
+ Args:
104
+ name: Project name
105
+
106
+ Returns:
107
+ ProjectInfo object
108
+
109
+ Raises:
110
+ ValueError: If project name is invalid
111
+ ProjectNotFoundError: If project does not exist
112
+ """
113
+ validate_name(name, "project name")
114
+
115
+ project_dir = self.data_dir / name
116
+ validate_safe_path(project_dir, self.data_dir)
117
+
118
+ if not project_dir.exists() or not project_dir.is_dir():
119
+ raise ProjectNotFoundError(f"Project '{name}' not found")
120
+
121
+ # Count runs
122
+ run_files = [f for f in project_dir.iterdir() if f.suffix in [".jsonl", ".db", ".wal"]]
123
+ run_count = len(run_files)
124
+
125
+ # Get last update time from run files
126
+ last_update = datetime.fromtimestamp(project_dir.stat().st_mtime, tz=timezone.utc)
127
+ if run_files:
128
+ last_update = max(datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc) for f in run_files)
129
+
130
+ return ProjectInfo(
131
+ name=name,
132
+ run_count=run_count,
133
+ last_update=last_update,
134
+ )
135
+
136
+ def exists(self, name: str) -> bool:
137
+ """Check if a project exists.
138
+
139
+ Args:
140
+ name: Project name
141
+
142
+ Returns:
143
+ True if project exists, False otherwise
144
+ """
145
+ try:
146
+ validate_name(name, "project name")
147
+ project_dir = self.data_dir / name
148
+ validate_safe_path(project_dir, self.data_dir)
149
+ return project_dir.exists() and project_dir.is_dir()
150
+ except ValueError:
151
+ return False
152
+
153
+ def delete(self, name: str) -> None:
154
+ """Delete a project and all its runs.
155
+
156
+ Args:
157
+ name: Project name to delete
158
+
159
+ Raises:
160
+ ValueError: If project name is empty or invalid
161
+ ProjectNotFoundError: If project does not exist
162
+ PermissionError: If deletion is not permitted
163
+ """
164
+ if not name:
165
+ raise ValueError("Project name cannot be empty")
166
+
167
+ validate_name(name, "project name")
168
+
169
+ project_dir = self.data_dir / name
170
+ validate_safe_path(project_dir, self.data_dir)
171
+
172
+ if not project_dir.exists():
173
+ raise ProjectNotFoundError(f"Project '{name}' does not exist")
174
+
175
+ try:
176
+ shutil.rmtree(project_dir)
177
+ logger.info(f"Successfully deleted project: {name}")
178
+ except (PermissionError, OSError) as e:
179
+ logger.error(f"Error deleting project {name}: {type(e).__name__}")
180
+ raise
181
+
182
+ def get_metadata(self, name: str) -> dict[str, Any]:
183
+ """Get project-level metadata.json for a project.
184
+
185
+ Returns a dictionary with notes, tags, created_at, updated_at fields.
186
+ """
187
+ storage = ProjectMetadataStorage(self.data_dir, name)
188
+ return storage.get_metadata()
189
+
190
+ def update_metadata(self, name: str, metadata: dict[str, Any]) -> dict[str, Any]:
191
+ """Update project-level metadata.json for a project.
192
+
193
+ The metadata dict may contain partial fields (notes, tags).
194
+ Validation and timestamp handling is delegated to ProjectMetadataStorage.
195
+ """
196
+ storage = ProjectMetadataStorage(self.data_dir, name)
197
+ return storage.update_metadata(metadata)
198
+
199
+ def delete_metadata(self, name: str) -> bool:
200
+ """Delete project-level metadata.json for a project."""
201
+ storage = ProjectMetadataStorage(self.data_dir, name)
202
+ return storage.delete_metadata()
src/aspara/catalog/run_catalog.py ADDED
@@ -0,0 +1,749 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RunCatalog - Catalog for discovering and managing runs within a project.
3
+
4
+ This module provides functionality for listing, getting, and deleting runs
5
+ in a project directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import shutil
15
+ from collections.abc import AsyncGenerator, Mapping
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import polars as pl
21
+ from pydantic import BaseModel, Field
22
+
23
+ from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
24
+ from aspara.models import MetricRecord, RunStatus, StatusRecord
25
+ from aspara.storage import RunMetadataStorage
26
+ from aspara.utils.timestamp import parse_to_datetime
27
+ from aspara.utils.validators import validate_name, validate_safe_path
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Threshold in seconds to consider a run as potentially failed (1 hour)
32
+ STALE_RUN_THRESHOLD_SECONDS = 3600
33
+
34
+
35
+ class RunInfo(BaseModel):
36
+ """Run information."""
37
+
38
+ name: str
39
+ run_id: str | None = None
40
+ start_time: datetime | None = None
41
+ last_update: datetime | None = None
42
+ param_count: int
43
+ artifact_count: int = 0
44
+ tags: list[str] = []
45
+ is_corrupted: bool = False
46
+ error_message: str | None = None
47
+ is_finished: bool = False
48
+ exit_code: int | None = None
49
+ status: RunStatus = Field(default=RunStatus.WIP)
50
+
51
+
52
+ def _detect_backend(data_dir: Path, project: str, run_name: str) -> str:
53
+ """Detect which storage backend a run is using.
54
+
55
+ Args:
56
+ data_dir: Base data directory
57
+ project: Project name
58
+ run_name: Run name
59
+
60
+ Returns:
61
+ "polars" if the run uses Polars backend (WAL + Parquet), "jsonl" otherwise
62
+ """
63
+ project_dir = data_dir / project
64
+
65
+ # Check for Polars backend indicators
66
+ wal_file = project_dir / f"{run_name}.wal.jsonl"
67
+ archive_dir = project_dir / f"{run_name}_archive"
68
+
69
+ # If WAL file or archive directory exists, it's a Polars backend
70
+ if wal_file.exists() or archive_dir.exists():
71
+ return "polars"
72
+
73
+ # Otherwise, it's JSONL backend
74
+ return "jsonl"
75
+
76
+
77
+ def _open_metrics_storage(
78
+ base_dir: Path | str,
79
+ project: str,
80
+ run_name: str,
81
+ ):
82
+ """Open metrics storage for an existing run.
83
+
84
+ Detects the backend type from existing files and returns
85
+ the appropriate storage instance.
86
+
87
+ Args:
88
+ base_dir: Base data directory
89
+ project: Project name
90
+ run_name: Run name
91
+
92
+ Returns:
93
+ JsonlMetricsStorage or PolarsMetricsStorage instance
94
+ """
95
+ from aspara.storage import JsonlMetricsStorage, PolarsMetricsStorage
96
+
97
+ backend = _detect_backend(Path(base_dir), project, run_name)
98
+
99
+ if backend == "polars":
100
+ return PolarsMetricsStorage(
101
+ base_dir=str(base_dir),
102
+ project_name=project,
103
+ run_name=run_name,
104
+ )
105
+ else:
106
+ return JsonlMetricsStorage(
107
+ base_dir=str(base_dir),
108
+ project_name=project,
109
+ run_name=run_name,
110
+ )
111
+
112
+
113
+ def _read_metadata_file(metadata_file: Path) -> dict:
114
+ """Read .meta.json file and return parsed data.
115
+
116
+ Args:
117
+ metadata_file: Path to the .meta.json file
118
+
119
+ Returns:
120
+ Dictionary with metadata, or empty dict if file doesn't exist or is invalid
121
+ """
122
+ if not metadata_file.exists():
123
+ return {}
124
+
125
+ try:
126
+ with open(metadata_file) as f:
127
+ return json.load(f)
128
+ except Exception as e:
129
+ logger.warning(f"Error reading metadata file {metadata_file}: {e}")
130
+ return {}
131
+
132
+
133
+ def _infer_stale_status(
134
+ status: RunStatus,
135
+ start_time: datetime | None,
136
+ is_finished: bool,
137
+ ) -> RunStatus:
138
+ """Infer MAYBE_FAILED status for old runs that were never finished.
139
+
140
+ Args:
141
+ status: Current run status
142
+ start_time: When the run started
143
+ is_finished: Whether the run has finished
144
+
145
+ Returns:
146
+ MAYBE_FAILED if run is stale, otherwise the original status
147
+ """
148
+ if status != RunStatus.WIP or not start_time or is_finished:
149
+ return status
150
+
151
+ current_time = datetime.now(timezone.utc)
152
+ age_seconds = (current_time - start_time).total_seconds()
153
+ if age_seconds > STALE_RUN_THRESHOLD_SECONDS:
154
+ return RunStatus.MAYBE_FAILED
155
+
156
+ return status
157
+
158
+
159
+ def _extract_timestamp_range(
160
+ df: pl.DataFrame,
161
+ ) -> tuple[datetime | None, datetime | None]:
162
+ """Extract start_time and last_update from DataFrame.
163
+
164
+ Args:
165
+ df: DataFrame with timestamp column
166
+
167
+ Returns:
168
+ Tuple of (start_time, last_update)
169
+ """
170
+ if len(df) == 0 or "timestamp" not in df.columns:
171
+ return (None, None)
172
+
173
+ timestamps = df.select("timestamp").to_series()
174
+ if len(timestamps) == 0:
175
+ return (None, None)
176
+
177
+ ts_min = timestamps.min()
178
+ ts_max = timestamps.max()
179
+
180
+ start_time = ts_min if isinstance(ts_min, datetime) else None
181
+ last_update = ts_max if isinstance(ts_max, datetime) else None
182
+
183
+ return (start_time, last_update)
184
+
185
+
186
+ def _check_corruption(
187
+ df: pl.DataFrame,
188
+ metadata_file_exists: bool,
189
+ ) -> tuple[bool, str | None]:
190
+ """Check if metrics data is corrupted.
191
+
192
+ Args:
193
+ df: DataFrame with metrics
194
+ metadata_file_exists: Whether metadata file exists
195
+
196
+ Returns:
197
+ Tuple of (is_corrupted, error_message)
198
+ """
199
+ if len(df) == 0 and not metadata_file_exists:
200
+ return (True, "Empty file! No data found!")
201
+ if len(df) > 0 and "timestamp" not in df.columns:
202
+ return (True, "No timestamps found! Corrupted Run!")
203
+ return (False, None)
204
+
205
+
206
+ def _map_error_to_corruption(
207
+ error: Exception,
208
+ metadata_file_exists: bool,
209
+ ) -> tuple[bool, str | None]:
210
+ """Map storage read errors to corruption status.
211
+
212
+ Args:
213
+ error: The exception that occurred
214
+ metadata_file_exists: Whether metadata file exists
215
+
216
+ Returns:
217
+ Tuple of (is_corrupted, error_message)
218
+ """
219
+ error_str = str(error).lower()
220
+
221
+ if "empty" in error_str or "empty string" in error_str:
222
+ return (True, "Empty file! No data found!")
223
+ if "expectedobjectkey" in error_str.replace(" ", "") or "invalid json" in error_str:
224
+ return (True, f"Invalid file format! Error: {error!s}")
225
+ if "timestamp" in error_str:
226
+ return (True, f"No timestamps found! Error: {error!s}")
227
+ if "step" in error_str and not metadata_file_exists:
228
+ return (True, f"Failed to read metrics: {error!s}")
229
+ if not metadata_file_exists:
230
+ return (True, f"Failed to read metrics: {error!s}")
231
+
232
+ return (False, None)
233
+
234
+
235
+ class RunCatalog:
236
+ """Catalog for discovering and managing runs within a project.
237
+
238
+ This class provides methods to list, get, delete, and watch runs.
239
+ It handles both JSONL and DuckDB storage formats.
240
+ """
241
+
242
+ def __init__(self, data_dir: str | Path) -> None:
243
+ """Initialize the run catalog.
244
+
245
+ Args:
246
+ data_dir: Base directory for data storage
247
+ """
248
+ self.data_dir = Path(data_dir)
249
+
250
+ def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
251
+ """Parse file path to extract project, run name, and file type.
252
+
253
+ Args:
254
+ file_path: Absolute path to a file (e.g., data/project/run.jsonl)
255
+
256
+ Returns:
257
+ (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
258
+ None if path doesn't match expected pattern
259
+ """
260
+ try:
261
+ relative = file_path.relative_to(self.data_dir)
262
+ except ValueError:
263
+ return None
264
+
265
+ parts = relative.parts
266
+ if len(parts) != 2:
267
+ return None
268
+
269
+ project = parts[0]
270
+ filename = parts[1]
271
+
272
+ if filename.endswith(".wal.jsonl"):
273
+ return (project, filename[:-10], "wal")
274
+ elif filename.endswith(".meta.json"):
275
+ return (project, filename[:-10], "meta")
276
+ elif filename.endswith(".jsonl"):
277
+ return (project, filename[:-6], "metrics")
278
+
279
+ return None
280
+
281
+ def _read_run_info(self, project: str, run_name: str, run_file: Path) -> RunInfo:
282
+ """Read run information from JSONL metrics file and metadata file.
283
+
284
+ Supports both JSONL and Polars backends.
285
+ Optimization: Avoids loading full DataFrame when metadata provides sufficient info.
286
+
287
+ Args:
288
+ project: Project name
289
+ run_name: Run name
290
+ run_file: Path to the JSONL metrics file
291
+
292
+ Returns:
293
+ RunInfo object with metadata from both files
294
+ """
295
+ metadata_file = run_file.parent / f"{run_name}.meta.json"
296
+
297
+ # Read metadata
298
+ metadata = _read_metadata_file(metadata_file)
299
+ run_id = metadata.get("run_id")
300
+ tags = metadata.get("tags", [])
301
+ is_finished = metadata.get("is_finished", False)
302
+ exit_code = metadata.get("exit_code")
303
+
304
+ # Read params count
305
+ params = metadata.get("params", {})
306
+ params_count = len(params) if isinstance(params, dict) else 0
307
+
308
+ # Parse status
309
+ status_value = metadata.get("status", RunStatus.WIP.value)
310
+ try:
311
+ status = RunStatus(status_value)
312
+ except ValueError:
313
+ status = RunStatus.from_is_finished_and_exit_code(is_finished, exit_code)
314
+
315
+ # Parse start_time from metadata
316
+ start_time = None
317
+ start_time_value = metadata.get("start_time")
318
+ if start_time_value is not None:
319
+ with contextlib.suppress(ValueError):
320
+ start_time = parse_to_datetime(start_time_value)
321
+
322
+ # Infer stale status
323
+ status = _infer_stale_status(status, start_time, is_finished)
324
+
325
+ # Lightweight corruption check: file exists and is not empty.
326
+ # Use try/except instead of exists()→stat() to avoid TOCTOU race
327
+ # when the file is deleted between the two calls (e.g. watcher and
328
+ # delete API running concurrently).
329
+ is_corrupted = False
330
+ error_message = None
331
+ last_update = None
332
+
333
+ run_exists = False
334
+ run_size = 0
335
+ try:
336
+ run_stat = run_file.stat()
337
+ run_exists = True
338
+ run_size = run_stat.st_size
339
+ last_update = datetime.fromtimestamp(run_stat.st_mtime, tz=timezone.utc)
340
+ except FileNotFoundError:
341
+ pass
342
+
343
+ metadata_exists = metadata_file.exists()
344
+
345
+ if not run_exists and not metadata_exists:
346
+ is_corrupted = True
347
+ error_message = "Run file not found"
348
+ elif run_exists and run_size == 0 and not metadata_exists:
349
+ is_corrupted = True
350
+ error_message = "Empty file! No data found!"
351
+
352
+ return RunInfo(
353
+ name=run_name,
354
+ run_id=run_id,
355
+ start_time=start_time,
356
+ last_update=last_update,
357
+ param_count=params_count,
358
+ artifact_count=0,
359
+ tags=tags,
360
+ is_corrupted=is_corrupted,
361
+ error_message=error_message,
362
+ is_finished=is_finished,
363
+ exit_code=exit_code,
364
+ status=status,
365
+ )
366
+
367
+ def get_runs(self, project: str) -> list[RunInfo]:
368
+ """List all runs in a project.
369
+
370
+ Args:
371
+ project: Project name
372
+
373
+ Returns:
374
+ List of RunInfo objects sorted by name
375
+
376
+ Raises:
377
+ ValueError: If project name is invalid
378
+ ProjectNotFoundError: If project does not exist
379
+ """
380
+ validate_name(project, "project name")
381
+
382
+ project_dir = self.data_dir / project
383
+ validate_safe_path(project_dir, self.data_dir)
384
+
385
+ if not project_dir.exists():
386
+ raise ProjectNotFoundError(f"Project '{project}' not found")
387
+
388
+ runs = []
389
+ seen_run_names: set[str] = set()
390
+
391
+ # Process .jsonl files (including .wal.jsonl for Polars backend)
392
+ for run_file in list(project_dir.glob("*.jsonl")):
393
+ # Determine run name from file
394
+ if run_file.name.endswith(".wal.jsonl"):
395
+ # Skip WAL files - they're handled by metadata
396
+ continue
397
+ else:
398
+ run_name = run_file.stem
399
+
400
+ # Skip if we've already processed this run
401
+ if run_name in seen_run_names:
402
+ continue
403
+ seen_run_names.add(run_name)
404
+
405
+ # Handle plain JSONL files
406
+ run = self._read_run_info(project, run_name, run_file)
407
+ runs.append(run)
408
+
409
+ return sorted(runs, key=lambda r: r.name)
410
+
411
+ def get(self, project: str, run: str) -> RunInfo:
412
+ """Get a specific run.
413
+
414
+ Args:
415
+ project: Project name
416
+ run: Run name
417
+
418
+ Returns:
419
+ RunInfo object
420
+
421
+ Raises:
422
+ ValueError: If project or run name is invalid
423
+ ProjectNotFoundError: If project does not exist
424
+ RunNotFoundError: If run does not exist
425
+ """
426
+ validate_name(project, "project name")
427
+ validate_name(run, "run name")
428
+
429
+ project_dir = self.data_dir / project
430
+ validate_safe_path(project_dir, self.data_dir)
431
+
432
+ if not project_dir.exists():
433
+ raise ProjectNotFoundError(f"Project '{project}' not found")
434
+
435
+ # Check for JSONL file
436
+ jsonl_file = project_dir / f"{run}.jsonl"
437
+
438
+ if jsonl_file.exists():
439
+ return self._read_run_info(project, run, jsonl_file)
440
+ else:
441
+ raise RunNotFoundError(f"Run '{run}' not found in project '{project}'")
442
+
443
+ def delete(self, project: str, run: str) -> None:
444
+ """Delete a run and its artifacts.
445
+
446
+ Args:
447
+ project: Project name
448
+ run: Run name to delete
449
+
450
+ Raises:
451
+ ValueError: If project or run name is empty or invalid
452
+ ProjectNotFoundError: If project does not exist
453
+ RunNotFoundError: If run does not exist
454
+ PermissionError: If deletion is not permitted
455
+ """
456
+ if not project:
457
+ raise ValueError("Project name cannot be empty")
458
+ if not run:
459
+ raise ValueError("Run name cannot be empty")
460
+
461
+ validate_name(project, "project name")
462
+ validate_name(run, "run name")
463
+
464
+ project_dir = self.data_dir / project
465
+ validate_safe_path(project_dir, self.data_dir)
466
+
467
+ if not project_dir.exists():
468
+ raise ProjectNotFoundError(f"Project '{project}' does not exist")
469
+
470
+ # Check for any run files
471
+ wal_file = project_dir / f"{run}.wal.jsonl"
472
+ jsonl_file = project_dir / f"{run}.jsonl"
473
+
474
+ if not wal_file.exists() and not jsonl_file.exists():
475
+ raise RunNotFoundError(f"Run '{run}' does not exist in project '{project}'")
476
+
477
+ try:
478
+ # Delete all run-related files
479
+ metadata_file = project_dir / f"{run}.meta.json"
480
+ for file_path in [wal_file, jsonl_file, metadata_file]:
481
+ if file_path.exists():
482
+ file_path.unlink()
483
+ logger.debug(f"Deleted file: {file_path}")
484
+
485
+ # Delete artifacts directory if it exists
486
+ artifacts_dir = project_dir / run / "artifacts"
487
+ run_dir = project_dir / run
488
+
489
+ if artifacts_dir.exists():
490
+ shutil.rmtree(artifacts_dir)
491
+ logger.debug(f"Deleted artifacts for {project}/{run}")
492
+
493
+ # Delete run directory if it exists and is empty
494
+ if run_dir.exists():
495
+ try:
496
+ run_dir.rmdir()
497
+ logger.debug(f"Deleted run directory for {project}/{run}")
498
+ except OSError:
499
+ pass
500
+
501
+ logger.info(f"Successfully deleted run: {project}/{run}")
502
+ except (PermissionError, OSError) as e:
503
+ logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}")
504
+ raise
505
+
506
+ def exists(self, project: str, run: str) -> bool:
507
+ """Check if a run exists.
508
+
509
+ Args:
510
+ project: Project name
511
+ run: Run name
512
+
513
+ Returns:
514
+ True if run exists, False otherwise
515
+ """
516
+ try:
517
+ validate_name(project, "project name")
518
+ validate_name(run, "run name")
519
+
520
+ project_dir = self.data_dir / project
521
+ validate_safe_path(project_dir, self.data_dir)
522
+
523
+ wal_file = project_dir / f"{run}.wal.jsonl"
524
+ jsonl_file = project_dir / f"{run}.jsonl"
525
+
526
+ return wal_file.exists() or jsonl_file.exists()
527
+ except ValueError:
528
+ return False
529
+
530
+ async def subscribe(
531
+ self,
532
+ targets: Mapping[str, list[str] | None],
533
+ since: datetime,
534
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
535
+ """Subscribe to file changes for specified targets using DataDirWatcher.
536
+
537
+ This method uses a singleton DataDirWatcher instance to minimize inotify
538
+ file descriptor usage. Multiple SSE connections share the same watcher.
539
+
540
+ Args:
541
+ targets: Dictionary mapping project names to list of run names.
542
+ If run list is None, all runs in the project are watched.
543
+ since: Filter to only yield records with timestamp >= since
544
+
545
+ Yields:
546
+ MetricRecord or StatusRecord as files are updated
547
+ """
548
+ from aspara.catalog.watcher import DataDirWatcher
549
+
550
+ watcher = await DataDirWatcher.get_instance(self.data_dir)
551
+ async for record in watcher.subscribe(targets, since):
552
+ yield record
553
+
554
+ def get_artifacts(self, project: str, run: str) -> list[dict]:
555
+ """Get artifacts for a run from metadata file.
556
+
557
+ Args:
558
+ project: Project name
559
+ run: Run name
560
+
561
+ Returns:
562
+ List of artifact dictionaries
563
+ """
564
+ validate_name(project, "project name")
565
+ validate_name(run, "run name")
566
+
567
+ # Read from metadata file
568
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
569
+ validate_safe_path(metadata_file, self.data_dir)
570
+
571
+ if metadata_file.exists():
572
+ try:
573
+ with open(metadata_file) as f:
574
+ metadata = json.load(f)
575
+ return metadata.get("artifacts", [])
576
+ except Exception as e:
577
+ logger.warning(f"Error reading artifacts from metadata file for {run}: {e}")
578
+
579
+ return []
580
+
581
+ def get_metadata(self, project: str, run: str) -> dict:
582
+ """Get run metadata from .meta.json file.
583
+
584
+ Args:
585
+ project: Project name
586
+ run: Run name
587
+
588
+ Returns:
589
+ Dictionary containing run metadata
590
+ """
591
+ storage = RunMetadataStorage(self.data_dir, project, run)
592
+ return storage.get_metadata()
593
+
594
+ def update_metadata(self, project: str, run: str, metadata: dict) -> dict:
595
+ """Update run metadata in .meta.json file.
596
+
597
+ Args:
598
+ project: Project name
599
+ run: Run name
600
+ metadata: Dictionary with fields to update (notes, tags)
601
+
602
+ Returns:
603
+ Updated complete metadata dictionary
604
+ """
605
+ storage = RunMetadataStorage(self.data_dir, project, run)
606
+ return storage.update_metadata(metadata)
607
+
608
+ def delete_metadata(self, project: str, run: str) -> bool:
609
+ """Delete run metadata file.
610
+
611
+ Args:
612
+ project: Project name
613
+ run: Run name
614
+
615
+ Returns:
616
+ True if file was deleted, False if it didn't exist
617
+ """
618
+ storage = RunMetadataStorage(self.data_dir, project, run)
619
+ return storage.delete_metadata()
620
+
621
+ def _guess_artifact_category(self, filename: str) -> str:
622
+ """Guess artifact category from file extension.
623
+
624
+ Args:
625
+ filename: Name of the artifact file
626
+
627
+ Returns:
628
+ Category string
629
+ """
630
+ ext = filename.lower().split(".")[-1] if "." in filename else ""
631
+
632
+ if ext in ["py", "js", "ts", "jsx", "tsx", "cpp", "c", "h", "java", "go", "rs", "rb", "php"]:
633
+ return "code"
634
+ if ext in ["yaml", "yml", "json", "toml", "ini", "cfg", "conf", "env"]:
635
+ return "config"
636
+ if ext in ["pt", "pth", "pkl", "pickle", "h5", "hdf5", "onnx", "pb", "tflite", "joblib"]:
637
+ return "model"
638
+ if ext in ["csv", "tsv", "parquet", "feather", "xlsx", "xls", "hdf", "npy", "npz"]:
639
+ return "data"
640
+
641
+ return "other"
642
+
643
+ def load_metrics(
644
+ self,
645
+ project: str,
646
+ run: str,
647
+ start_time: datetime | None = None,
648
+ ) -> pl.DataFrame:
649
+ """Load metrics for a run in wide format (auto-detects storage backend).
650
+
651
+ Args:
652
+ project: Project name
653
+ run: Run name
654
+ start_time: Optional start time to filter metrics from
655
+
656
+ Returns:
657
+ Polars DataFrame in wide format with columns:
658
+ - timestamp: Datetime
659
+ - step: Int64
660
+ - _<metric_name>: Float64 for each metric (underscore-prefixed)
661
+
662
+ Raises:
663
+ ValueError: If project or run name is invalid
664
+ RunNotFoundError: If run does not exist
665
+ """
666
+ validate_name(project, "project name")
667
+ validate_name(run, "run name")
668
+
669
+ # Create storage using factory function and load metrics
670
+ storage = _open_metrics_storage(self.data_dir, project, run)
671
+
672
+ try:
673
+ df = storage.load()
674
+ except Exception as e:
675
+ logger.warning(f"Failed to load metrics for {project}/{run}: {e}")
676
+ return pl.DataFrame(
677
+ schema={
678
+ "timestamp": pl.Datetime,
679
+ "step": pl.Int64,
680
+ }
681
+ )
682
+
683
+ # Apply start_time filter if specified
684
+ if start_time is not None and len(df) > 0:
685
+ df = df.filter(pl.col("timestamp") >= start_time)
686
+
687
+ return df
688
+
689
+ def get_run_config(self, project: str, run: str) -> dict[str, Any]:
690
+ """Get run config from .meta.json file.
691
+
692
+ This reads the .meta.json file which contains params, config, status, etc.
693
+ Different from get_metadata which uses ProjectMetadataStorage for notes/tags.
694
+
695
+ Args:
696
+ project: Project name
697
+ run: Run name
698
+
699
+ Returns:
700
+ Dictionary containing run config (params, config, status, etc.)
701
+ """
702
+ validate_name(project, "project name")
703
+ validate_name(run, "run name")
704
+
705
+ metadata_file = self.data_dir / project / f"{run}.meta.json"
706
+ validate_safe_path(metadata_file, self.data_dir)
707
+
708
+ return _read_metadata_file(metadata_file)
709
+
710
+ async def get_run_config_async(self, project: str, run: str) -> dict[str, Any]:
711
+ """Get run config asynchronously using run_in_executor.
712
+
713
+ This reads the .meta.json file which contains params, config, status, etc.
714
+
715
+ Args:
716
+ project: Project name
717
+ run: Run name
718
+
719
+ Returns:
720
+ Dictionary containing run config (params, config, status, etc.)
721
+ """
722
+ loop = asyncio.get_event_loop()
723
+ return await loop.run_in_executor(None, self.get_run_config, project, run)
724
+
725
+ async def get_metadata_async(self, project: str, run: str) -> dict[str, Any]:
726
+ """Get run metadata asynchronously using run_in_executor.
727
+
728
+ Args:
729
+ project: Project name
730
+ run: Run name
731
+
732
+ Returns:
733
+ Dictionary containing run metadata (tags, notes, params, etc.)
734
+ """
735
+ loop = asyncio.get_event_loop()
736
+ return await loop.run_in_executor(None, self.get_metadata, project, run)
737
+
738
+ async def get_artifacts_async(self, project: str, run: str) -> list[dict[str, Any]]:
739
+ """Get artifacts for a run asynchronously using run_in_executor.
740
+
741
+ Args:
742
+ project: Project name
743
+ run: Run name
744
+
745
+ Returns:
746
+ List of artifact dictionaries
747
+ """
748
+ loop = asyncio.get_event_loop()
749
+ return await loop.run_in_executor(None, self.get_artifacts, project, run)
src/aspara/catalog/watcher.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DataDirWatcher - Singleton watcher for data directory.
3
+
4
+ This module provides a centralized file watcher service that uses a single
5
+ inotify watcher for the entire data directory. Multiple SSE connections
6
+ subscribe to this service, reducing inotify file descriptor usage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ import uuid
16
+ from collections.abc import AsyncGenerator, Mapping
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from watchfiles import awatch
22
+
23
+ from aspara.models import MetricRecord, RunStatus, StatusRecord
24
+ from aspara.utils.timestamp import parse_to_datetime
25
+ from aspara.utils.validators import validate_name
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class Subscription:
32
+ """Subscription to data directory changes."""
33
+
34
+ id: str
35
+ targets: Mapping[str, list[str] | None] # project -> runs (None means all runs)
36
+ since: datetime
37
+ queue: asyncio.Queue[MetricRecord | StatusRecord | None] = field(default_factory=asyncio.Queue)
38
+
39
+
40
+ class DataDirWatcher:
41
+ """Singleton watcher for data directory.
42
+
43
+ This class provides a single inotify watcher for the entire data directory,
44
+ allowing multiple SSE connections to subscribe without consuming additional
45
+ file descriptors.
46
+ """
47
+
48
+ # Size thresholds for initial read strategy
49
+ LARGE_FILE_THRESHOLD = 1 * 1024 * 1024 # 1MB
50
+ TAIL_READ_SIZE = 64 * 1024 # Read last 64KB for large files
51
+
52
+ _instance: DataDirWatcher | None = None
53
+ _lock: asyncio.Lock | None = None
54
+
55
+ def __init__(self, data_dir: Path) -> None:
56
+ """Initialize the watcher.
57
+
58
+ Note: Use get_instance() to get the singleton instance.
59
+
60
+ Args:
61
+ data_dir: Base directory for data storage
62
+ """
63
+ # Resolve to absolute path for consistent comparison with awatch paths
64
+ self.data_dir = data_dir.resolve()
65
+ self._subscriptions: dict[str, Subscription] = {}
66
+ self._task: asyncio.Task[None] | None = None
67
+ self._instance_lock = asyncio.Lock()
68
+ # Track file sizes for incremental reading
69
+ self._file_sizes: dict[Path, int] = {}
70
+ # Track run statuses for change detection
71
+ self._run_statuses: dict[tuple[str, str], str | None] = {}
72
+
73
+ @classmethod
74
+ async def get_instance(cls, data_dir: Path) -> DataDirWatcher:
75
+ """Get or create singleton instance.
76
+
77
+ Args:
78
+ data_dir: Base directory for data storage
79
+
80
+ Returns:
81
+ DataDirWatcher singleton instance
82
+ """
83
+ if cls._lock is None:
84
+ cls._lock = asyncio.Lock()
85
+
86
+ async with cls._lock:
87
+ if cls._instance is None:
88
+ cls._instance = cls(data_dir)
89
+ logger.info(f"[Watcher] Created singleton DataDirWatcher for {data_dir}")
90
+ return cls._instance
91
+
92
+ @classmethod
93
+ def reset_instance(cls) -> None:
94
+ """Reset the singleton instance. Used for testing."""
95
+ cls._instance = None
96
+ cls._lock = None
97
+
98
+ def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
99
+ """Parse file path to extract project, run name, and file type.
100
+
101
+ Args:
102
+ file_path: Absolute path to a file
103
+
104
+ Returns:
105
+ (project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
106
+ None if path doesn't match expected pattern
107
+ """
108
+ try:
109
+ relative = file_path.relative_to(self.data_dir)
110
+ except ValueError:
111
+ return None
112
+
113
+ parts = relative.parts
114
+ if len(parts) != 2:
115
+ return None
116
+
117
+ project = parts[0]
118
+ filename = parts[1]
119
+
120
+ if filename.endswith(".wal.jsonl"):
121
+ return (project, filename[:-10], "wal")
122
+ elif filename.endswith(".meta.json"):
123
+ return (project, filename[:-10], "meta")
124
+ elif filename.endswith(".jsonl"):
125
+ return (project, filename[:-6], "metrics")
126
+
127
+ return None
128
+
129
+ def _parse_metric_line(self, line: str, project: str, run: str, since: datetime) -> MetricRecord | None:
130
+ """Parse a JSONL line and return MetricRecord if it passes the since filter.
131
+
132
+ Args:
133
+ line: A single line from a JSONL file
134
+ project: Project name
135
+ run: Run name
136
+ since: Filter timestamp - only records with timestamp >= since are returned
137
+
138
+ Returns:
139
+ MetricRecord if parsing succeeds and passes filter, None otherwise
140
+ """
141
+ if not line.strip():
142
+ return None
143
+ try:
144
+ entry = json.loads(line)
145
+ ts_value = entry.get("timestamp")
146
+ record_ts = None
147
+ if ts_value is not None:
148
+ with contextlib.suppress(ValueError):
149
+ record_ts = parse_to_datetime(ts_value)
150
+ if record_ts is None or record_ts >= since:
151
+ entry["run"] = run
152
+ entry["project"] = project
153
+ return MetricRecord(**entry)
154
+ except Exception as e:
155
+ logger.debug(f"[Watcher] Error parsing line: {e}")
156
+ return None
157
+
158
+ def _read_file_with_strategy(self, file_path: Path) -> tuple[str, int]:
159
+ """Read file content with size-based strategy.
160
+
161
+ For large files, only the tail portion is read to improve initial load time.
162
+
163
+ Args:
164
+ file_path: Path to the file to read
165
+
166
+ Returns:
167
+ Tuple of (content, end_position) where end_position is the file position after reading
168
+ """
169
+ file_size = file_path.stat().st_size
170
+
171
+ if file_size < self.LARGE_FILE_THRESHOLD:
172
+ with open(file_path) as f:
173
+ content = f.read()
174
+ return content, f.tell()
175
+
176
+ # Large file: read tail only
177
+ logger.debug(f"[Watcher] Large file ({file_size} bytes), reading tail: {file_path}")
178
+ with open(file_path) as f:
179
+ read_start = max(0, file_size - self.TAIL_READ_SIZE)
180
+ f.seek(read_start)
181
+ content = f.read()
182
+ end_pos = f.tell()
183
+
184
+ # Skip partial first line if we didn't start at beginning
185
+ if read_start > 0:
186
+ first_newline = content.find("\n")
187
+ if first_newline != -1:
188
+ content = content[first_newline + 1 :]
189
+
190
+ return content, end_pos
191
+
192
+ def _init_run_status(self, project: str, run: str, meta_file: Path) -> None:
193
+ """Initialize run status tracking from meta file.
194
+
195
+ Args:
196
+ project: Project name
197
+ run: Run name
198
+ meta_file: Path to the metadata file
199
+ """
200
+ key = (project, run)
201
+ if meta_file.exists():
202
+ try:
203
+ with open(meta_file) as f:
204
+ meta = json.load(f)
205
+ self._run_statuses[key] = meta.get("status")
206
+ except Exception:
207
+ self._run_statuses[key] = None
208
+ else:
209
+ self._run_statuses[key] = None
210
+
211
+ def _matches_targets(self, targets: Mapping[str, list[str] | None], project: str, run: str) -> bool:
212
+ """Check if a project/run matches the subscription targets.
213
+
214
+ Args:
215
+ targets: Subscription targets
216
+ project: Project name
217
+ run: Run name
218
+
219
+ Returns:
220
+ True if the project/run matches the targets
221
+ """
222
+ if project not in targets:
223
+ return False
224
+
225
+ run_list = targets[project]
226
+ if run_list is None:
227
+ # None means watch all runs in the project
228
+ return True
229
+
230
+ return run in run_list
231
+
232
+ async def _read_initial_data(
233
+ self,
234
+ targets: Mapping[str, list[str] | None],
235
+ since: datetime,
236
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
237
+ """Read initial data from existing files.
238
+
239
+ Args:
240
+ targets: Dictionary mapping project names to run lists
241
+ since: Filter to only yield records with timestamp >= since
242
+
243
+ Yields:
244
+ MetricRecord objects from existing files
245
+ """
246
+ for project, run_names in targets.items():
247
+ try:
248
+ validate_name(project, "project name")
249
+ except ValueError as e:
250
+ logger.warning(f"[Watcher] Invalid project name {project}: {e}")
251
+ continue
252
+
253
+ project_dir = self.data_dir / project
254
+ if not project_dir.exists():
255
+ logger.warning(f"[Watcher] Project directory does not exist: {project_dir}")
256
+ continue
257
+
258
+ # If run_names is None, discover all runs
259
+ if run_names is None:
260
+ actual_runs = []
261
+ for f in project_dir.glob("*.jsonl"):
262
+ if f.name.endswith(".wal.jsonl"):
263
+ continue
264
+ # Skip symlinks to prevent symlink-based attacks
265
+ if f.is_symlink():
266
+ logger.warning(f"[Watcher] Skipping symlink: {f}")
267
+ continue
268
+ actual_runs.append(f.stem)
269
+ run_names = actual_runs
270
+
271
+ for run in run_names:
272
+ # Check which files exist for this run
273
+ wal_file = project_dir / f"{run}.wal.jsonl"
274
+ jsonl_file = project_dir / f"{run}.jsonl"
275
+ meta_file = project_dir / f"{run}.meta.json"
276
+
277
+ # Initialize status tracking
278
+ self._init_run_status(project, run, meta_file)
279
+
280
+ # Read metrics files
281
+ for file_path in [wal_file, jsonl_file]:
282
+ if not file_path.exists():
283
+ continue
284
+
285
+ resolved = file_path.resolve()
286
+
287
+ try:
288
+ content, end_pos = self._read_file_with_strategy(resolved)
289
+ self._file_sizes[resolved] = end_pos
290
+
291
+ for line in content.splitlines():
292
+ record = self._parse_metric_line(line, project, run, since)
293
+ if record is not None:
294
+ yield record
295
+ except Exception as e:
296
+ logger.warning(f"[Watcher] Error reading {resolved}: {e}")
297
+ if resolved.exists():
298
+ self._file_sizes[resolved] = resolved.stat().st_size
299
+
300
+ # Record meta file size
301
+ if meta_file.exists():
302
+ self._file_sizes[meta_file.resolve()] = meta_file.stat().st_size
303
+
304
+ async def _dispatch_loop(self) -> None:
305
+ """Main loop: watch data_dir and dispatch to subscribers."""
306
+ logger.info(f"[Watcher] Starting dispatch loop for {self.data_dir}")
307
+ watcher = None
308
+
309
+ try:
310
+ watcher = awatch(str(self.data_dir))
311
+ loop_count = 0
312
+ async for changes in watcher:
313
+ loop_count += 1
314
+ if loop_count % 10000 == 0:
315
+ logger.warning(f"[Watcher] Loop count: {loop_count}, changes: {len(changes)}")
316
+ logger.debug(f"[Watcher] Received {len(changes)} change(s)")
317
+
318
+ for _change_type, changed_path_str in changes:
319
+ raw_path = Path(changed_path_str)
320
+ # Skip symlinks to prevent reading files outside data_dir.
321
+ # The initial read in _read_initial_data already skips
322
+ # symlinks; the dispatch loop must do the same so a
323
+ # symlink created after subscription cannot bypass it.
324
+ if raw_path.is_symlink():
325
+ logger.warning(f"[Watcher] Skipping symlink in dispatch: {raw_path}")
326
+ continue
327
+
328
+ changed_path = raw_path.resolve()
329
+
330
+ # Parse file path to get project/run/type
331
+ parsed = self._parse_file_path(changed_path)
332
+ if parsed is None:
333
+ continue
334
+
335
+ project, run, file_type = parsed
336
+ logger.debug(f"[Watcher] File change: {changed_path} (project={project}, run={run}, type={file_type})")
337
+
338
+ # Dispatch to matching subscribers
339
+ async with self._instance_lock:
340
+ for sub in self._subscriptions.values():
341
+ if not self._matches_targets(sub.targets, project, run):
342
+ continue
343
+
344
+ try:
345
+ if file_type == "meta":
346
+ # Handle metadata/status update
347
+ status_record = await self._process_meta_change(changed_path, project, run)
348
+ if status_record:
349
+ await sub.queue.put(status_record)
350
+ else:
351
+ # Handle metrics update
352
+ metric_records = await self._process_metrics_change(changed_path, project, run, sub.since)
353
+ for metric_record in metric_records:
354
+ await sub.queue.put(metric_record)
355
+ except Exception as e:
356
+ logger.error(f"[Watcher] Error dispatching to subscription: {e}")
357
+
358
+ except asyncio.CancelledError:
359
+ logger.info("[Watcher] Dispatch loop cancelled")
360
+ raise
361
+ except Exception as e:
362
+ logger.error(f"[Watcher] Error in dispatch loop: {e}")
363
+ finally:
364
+ if watcher is not None:
365
+ logger.info("[Watcher] Closing awatch instance")
366
+ try:
367
+ await asyncio.wait_for(watcher.aclose(), timeout=2.0)
368
+ except asyncio.TimeoutError:
369
+ logger.warning("[Watcher] Timeout closing awatch instance")
370
+ except Exception as e:
371
+ logger.error(f"[Watcher] Error closing watcher: {e}")
372
+
373
+ async def _process_meta_change(self, file_path: Path, project: str, run: str) -> StatusRecord | None:
374
+ """Process a metadata file change.
375
+
376
+ Args:
377
+ file_path: Path to the metadata file
378
+ project: Project name
379
+ run: Run name
380
+
381
+ Returns:
382
+ StatusRecord if status changed, None otherwise
383
+ """
384
+ try:
385
+ with open(file_path) as f:
386
+ meta = json.load(f)
387
+ new_status = meta.get("status")
388
+
389
+ key = (project, run)
390
+ if new_status != self._run_statuses.get(key):
391
+ logger.info(f"[Watcher] Status change for {project}/{run}: {self._run_statuses.get(key)} -> {new_status}")
392
+ self._run_statuses[key] = new_status
393
+
394
+ return StatusRecord(
395
+ run=run,
396
+ project=project,
397
+ status=new_status or RunStatus.WIP.value,
398
+ is_finished=meta.get("is_finished", False),
399
+ exit_code=meta.get("exit_code"),
400
+ )
401
+ except Exception as e:
402
+ logger.error(f"[Watcher] Error reading metadata file {file_path}: {e}")
403
+
404
+ return None
405
+
406
+ async def _process_metrics_change(self, file_path: Path, project: str, run: str, since: datetime) -> list[MetricRecord]:
407
+ """Process a metrics file change.
408
+
409
+ Args:
410
+ file_path: Path to the metrics file
411
+ project: Project name
412
+ run: Run name
413
+ since: Filter timestamp
414
+
415
+ Returns:
416
+ List of MetricRecord objects
417
+ """
418
+ records: list[MetricRecord] = []
419
+
420
+ try:
421
+ # Determine where to resume reading. The tracked size may be stale
422
+ # if the file was truncated (e.g. PolarsMetricsStorage._clear_wal
423
+ # truncates the WAL to 0 bytes after archiving) or replaced. When
424
+ # the actual size is smaller than what we last read, rewind to the
425
+ # beginning so the newly appended content is not silently skipped.
426
+ actual_size = file_path.stat().st_size
427
+ tracked_size = self._file_sizes.get(file_path, 0)
428
+ read_from = 0 if actual_size < tracked_size else tracked_size
429
+
430
+ with open(file_path) as f:
431
+ f.seek(read_from)
432
+ new_content = f.read()
433
+ self._file_sizes[file_path] = f.tell()
434
+
435
+ for line in new_content.splitlines():
436
+ record = self._parse_metric_line(line, project, run, since)
437
+ if record is not None:
438
+ records.append(record)
439
+
440
+ except Exception as e:
441
+ logger.error(f"[Watcher] Error processing metrics file {file_path}: {e}")
442
+
443
+ return records
444
+
445
+ async def subscribe(
446
+ self,
447
+ targets: Mapping[str, list[str] | None],
448
+ since: datetime,
449
+ ) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
450
+ """Subscribe to file changes for specified targets.
451
+
452
+ Args:
453
+ targets: Dictionary mapping project names to list of run names.
454
+ If run list is None, all runs in the project are watched.
455
+ since: Filter to only yield records with timestamp >= since
456
+
457
+ Yields:
458
+ MetricRecord or StatusRecord as files are updated
459
+ """
460
+ # Ensure since is timezone-aware
461
+ if since.tzinfo is None:
462
+ since = since.replace(tzinfo=timezone.utc)
463
+
464
+ subscription_id = str(uuid.uuid4())
465
+ queue: asyncio.Queue[MetricRecord | StatusRecord | None] = asyncio.Queue()
466
+
467
+ subscription = Subscription(
468
+ id=subscription_id,
469
+ targets=targets,
470
+ since=since,
471
+ queue=queue,
472
+ )
473
+
474
+ logger.info(f"[Watcher] New subscription {subscription_id} for targets={targets}")
475
+
476
+ async with self._instance_lock:
477
+ self._subscriptions[subscription_id] = subscription
478
+ # Start watcher task if not running
479
+ if self._task is None or self._task.done():
480
+ logger.info("[Watcher] Starting dispatch task")
481
+ self._task = asyncio.create_task(self._dispatch_loop())
482
+
483
+ try:
484
+ # Yield initial data (existing records >= since)
485
+ async for record in self._read_initial_data(targets, since):
486
+ yield record
487
+
488
+ # Yield updates from queue
489
+ while True:
490
+ queued_record: MetricRecord | StatusRecord | None = await queue.get()
491
+ if queued_record is None: # Sentinel for unsubscribe
492
+ break
493
+ yield queued_record
494
+ finally:
495
+ await self._unsubscribe(subscription_id)
496
+
497
+ async def _unsubscribe(self, subscription_id: str) -> None:
498
+ """Unsubscribe from file changes.
499
+
500
+ Args:
501
+ subscription_id: Subscription ID to remove
502
+ """
503
+ logger.info(f"[Watcher] Unsubscribing {subscription_id}")
504
+
505
+ async with self._instance_lock:
506
+ if subscription_id in self._subscriptions:
507
+ del self._subscriptions[subscription_id]
508
+
509
+ # Stop watcher task if no more subscribers
510
+ if not self._subscriptions and self._task is not None:
511
+ logger.info("[Watcher] No more subscribers, stopping dispatch task")
512
+ self._task.cancel()
513
+ try:
514
+ await asyncio.wait_for(self._task, timeout=2.0)
515
+ except asyncio.TimeoutError:
516
+ logger.warning("[Watcher] Timeout waiting for dispatch task to finish")
517
+ except asyncio.CancelledError:
518
+ pass
519
+ self._task = None
520
+
521
+ @property
522
+ def subscription_count(self) -> int:
523
+ """Get the number of active subscriptions."""
524
+ return len(self._subscriptions)
src/aspara/cli.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aspara CLI tool
4
+
5
+ Command line interface for starting dashboard and tracker API
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import socket
13
+ import sys
14
+
15
+ import uvicorn
16
+
17
+ from aspara.config import get_data_dir, get_storage_backend
18
+
19
+
20
+ def parse_serve_components(components: list[str]) -> tuple[bool, bool]:
21
+ """
22
+ Parse and validate component list for serve command
23
+
24
+ Args:
25
+ components: List of component names
26
+
27
+ Returns:
28
+ Tuple of (enable_dashboard, enable_tracker)
29
+
30
+ Raises:
31
+ ValueError: If invalid component name is provided
32
+ """
33
+ valid_components = {"dashboard", "tracker", "together"}
34
+
35
+ # Default: dashboard only
36
+ if not components:
37
+ return (True, False)
38
+
39
+ # Normalize and validate
40
+ normalized = [c.lower() for c in components]
41
+ for comp in normalized:
42
+ if comp not in valid_components:
43
+ raise ValueError(f"Invalid component: {comp}. Valid options are: dashboard, tracker, together")
44
+
45
+ # Handle 'together' keyword
46
+ if "together" in normalized:
47
+ return (True, True)
48
+
49
+ # Handle explicit component list
50
+ enable_dashboard = "dashboard" in normalized
51
+ enable_tracker = "tracker" in normalized
52
+
53
+ # If both specified, enable both
54
+ if enable_dashboard and enable_tracker:
55
+ return (True, True)
56
+
57
+ return (enable_dashboard, enable_tracker)
58
+
59
+
60
+ def get_default_port(enable_dashboard: bool, enable_tracker: bool) -> int:
61
+ """
62
+ Get default port based on enabled components
63
+
64
+ Args:
65
+ enable_dashboard: Whether dashboard is enabled
66
+ enable_tracker: Whether tracker is enabled
67
+
68
+ Returns:
69
+ Default port number (3142 for tracker-only, 3141 otherwise)
70
+ """
71
+ if enable_tracker and not enable_dashboard:
72
+ return 3142
73
+ return 3141
74
+
75
+
76
+ def find_available_port(start_port: int = 3141, max_attempts: int = 100) -> int | None:
77
+ """
78
+ Find an available port number
79
+
80
+ Args:
81
+ start_port: Starting port number
82
+ max_attempts: Maximum number of attempts
83
+
84
+ Returns:
85
+ Available port number, None if not found
86
+ """
87
+ for port in range(start_port, start_port + max_attempts):
88
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
89
+ # If connection fails, that port is available
90
+ result = sock.connect_ex(("127.0.0.1", port))
91
+ if result != 0:
92
+ return port
93
+ return None
94
+
95
+
96
+ def run_dashboard(
97
+ host: str = "127.0.0.1",
98
+ port: int = 3141,
99
+ with_tracker: bool = False,
100
+ data_dir: str | None = None,
101
+ dev: bool = False,
102
+ project_search_mode: str = "realtime",
103
+ ) -> None:
104
+ """
105
+ Start dashboard server
106
+
107
+ Args:
108
+ host: Host name
109
+ port: Port number
110
+ with_tracker: Whether to run integrated tracker in same process
111
+ data_dir: Data directory for local data
112
+ dev: Enable development mode with auto-reload
113
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
114
+ """
115
+ # Set env vars for component mounting
116
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1"
117
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if with_tracker else "0"
118
+
119
+ if with_tracker:
120
+ os.environ["ASPARA_WITH_TRACKER"] = "1"
121
+
122
+ if dev:
123
+ os.environ["ASPARA_DEV_MODE"] = "1"
124
+
125
+ if data_dir is None:
126
+ data_dir = str(get_data_dir())
127
+
128
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
129
+
130
+ if project_search_mode:
131
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
132
+
133
+ from aspara.dashboard.router import configure_data_dir
134
+
135
+ configure_data_dir(data_dir)
136
+
137
+ print("Starting Aspara Dashboard server...")
138
+ print(f"Access http://{host}:{port} in your browser!")
139
+ print(f"Data directory: {os.path.abspath(data_dir)}")
140
+ backend = get_storage_backend() or "jsonl (default)"
141
+ print(f"Storage backend: {backend}")
142
+ if dev:
143
+ print("Development mode: auto-reload enabled")
144
+
145
+ try:
146
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
147
+ except ImportError:
148
+ print("Error: Dashboard functionality is not installed!")
149
+ print('To install: uv pip install "aspara[dashboard]"')
150
+ sys.exit(1)
151
+
152
+
153
+ def run_tui(data_dir: str | None = None) -> None:
154
+ """
155
+ Start TUI dashboard
156
+
157
+ Args:
158
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
159
+ """
160
+ if data_dir is None:
161
+ data_dir = str(get_data_dir())
162
+
163
+ print("Starting Aspara TUI...")
164
+ print(f"Data directory: {os.path.abspath(data_dir)}")
165
+
166
+ try:
167
+ from aspara.tui import run_tui as _run_tui
168
+
169
+ _run_tui(data_dir=data_dir)
170
+ except ImportError:
171
+ print("TUI functionality is not installed!")
172
+ print('To install: uv pip install "aspara[tui]"')
173
+ sys.exit(1)
174
+
175
+
176
+ def run_tracker(
177
+ host: str = "127.0.0.1",
178
+ port: int = 3142,
179
+ data_dir: str | None = None,
180
+ dev: bool = False,
181
+ storage_backend: str | None = None,
182
+ ) -> None:
183
+ """
184
+ Start tracker API server
185
+
186
+ Args:
187
+ host: Host name
188
+ port: Port number
189
+ data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
190
+ dev: Enable development mode with auto-reload
191
+ storage_backend: Metrics storage backend (jsonl or polars)
192
+ """
193
+ # Set env vars for backward compatibility
194
+ os.environ["ASPARA_SERVE_TRACKER"] = "1"
195
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "0"
196
+
197
+ if dev:
198
+ os.environ["ASPARA_DEV_MODE"] = "1"
199
+
200
+ if storage_backend is not None:
201
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
202
+
203
+ if data_dir is None:
204
+ data_dir = str(get_data_dir())
205
+
206
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
207
+
208
+ print("Starting Aspara Tracker API server...")
209
+ print(f"Endpoint: http://{host}:{port}/tracker/api/v1")
210
+ print(f"Data directory: {os.path.abspath(data_dir)}")
211
+ backend = get_storage_backend() or "jsonl (default)"
212
+ print(f"Storage backend: {backend}")
213
+ if dev:
214
+ print("Development mode: auto-reload enabled")
215
+
216
+ try:
217
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
218
+ except ImportError:
219
+ print("Error: Tracker functionality is not installed!")
220
+ print('To install: uv pip install "aspara[tracker]"')
221
+ sys.exit(1)
222
+
223
+
224
+ def run_serve(
225
+ components: list[str],
226
+ host: str = "127.0.0.1",
227
+ port: int | None = None,
228
+ data_dir: str | None = None,
229
+ dev: bool = False,
230
+ project_search_mode: str = "realtime",
231
+ storage_backend: str | None = None,
232
+ ) -> None:
233
+ """
234
+ Start Aspara server with specified components
235
+
236
+ Args:
237
+ components: List of components to enable (dashboard, tracker, together)
238
+ host: Host name
239
+ port: Port number (auto-detected if None)
240
+ data_dir: Data directory
241
+ dev: Enable development mode with auto-reload
242
+ project_search_mode: Project search mode on dashboard home (realtime or manual)
243
+ storage_backend: Metrics storage backend (jsonl or polars)
244
+ """
245
+ try:
246
+ enable_dashboard, enable_tracker = parse_serve_components(components)
247
+ except ValueError as e:
248
+ print(f"Error: {e}")
249
+ sys.exit(1)
250
+
251
+ # Set environment variables for component mounting
252
+ os.environ["ASPARA_SERVE_DASHBOARD"] = "1" if enable_dashboard else "0"
253
+ os.environ["ASPARA_SERVE_TRACKER"] = "1" if enable_tracker else "0"
254
+
255
+ if dev:
256
+ os.environ["ASPARA_DEV_MODE"] = "1"
257
+
258
+ if storage_backend is not None:
259
+ os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
260
+
261
+ # Determine port
262
+ if port is None:
263
+ port = get_default_port(enable_dashboard, enable_tracker)
264
+
265
+ # Configure data directory
266
+ if data_dir is None:
267
+ data_dir = str(get_data_dir())
268
+
269
+ os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
270
+
271
+ # Configure dashboard if enabled
272
+ if enable_dashboard:
273
+ if project_search_mode:
274
+ os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
275
+
276
+ from aspara.dashboard.router import configure_data_dir
277
+
278
+ configure_data_dir(data_dir)
279
+
280
+ # Build component description
281
+ if enable_dashboard and enable_tracker:
282
+ component_desc = "Dashboard + Tracker"
283
+ elif enable_dashboard:
284
+ component_desc = "Dashboard"
285
+ else:
286
+ component_desc = "Tracker"
287
+
288
+ print(f"Starting Aspara {component_desc} server...")
289
+ print(f"Access http://{host}:{port} in your browser!")
290
+ print(f"Data directory: {os.path.abspath(data_dir)}")
291
+ backend = get_storage_backend() or "jsonl (default)"
292
+ print(f"Storage backend: {backend}")
293
+ if dev:
294
+ print("Development mode: auto-reload enabled")
295
+
296
+ try:
297
+ uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
298
+ except ImportError as e:
299
+ print(f"Error: Required functionality is not installed: {e}")
300
+ sys.exit(1)
301
+
302
+
303
+ def main() -> None:
304
+ """
305
+ CLI main entry point
306
+ """
307
+ parser = argparse.ArgumentParser(description="Aspara management tool")
308
+ subparsers = parser.add_subparsers(dest="command", help="Subcommands")
309
+
310
+ dashboard_parser = subparsers.add_parser("dashboard", help="Start dashboard server")
311
+ dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
312
+ dashboard_parser.add_argument("--port", type=int, default=3141, help="Port number (default: 3141)")
313
+ dashboard_parser.add_argument("--with-tracker", action="store_true", help="Run dashboard with integrated tracker in same process")
314
+ dashboard_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
315
+ dashboard_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
316
+ dashboard_parser.add_argument(
317
+ "--project-search-mode",
318
+ choices=["realtime", "manual"],
319
+ default="realtime",
320
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
321
+ )
322
+
323
+ tracker_parser = subparsers.add_parser("tracker", help="Start tracker API server")
324
+ tracker_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
325
+ tracker_parser.add_argument("--port", type=int, default=3142, help="Port number (default: 3142)")
326
+ tracker_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
327
+ tracker_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
328
+ tracker_parser.add_argument(
329
+ "--storage-backend",
330
+ choices=["jsonl", "polars"],
331
+ default=None,
332
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
333
+ )
334
+
335
+ tui_parser = subparsers.add_parser("tui", help="Start terminal UI dashboard")
336
+ tui_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
337
+
338
+ serve_parser = subparsers.add_parser("serve", help="Start Aspara server")
339
+ serve_parser.add_argument(
340
+ "components",
341
+ nargs="*",
342
+ default=[],
343
+ help="Components to run: dashboard, tracker, together (default: dashboard only)",
344
+ )
345
+ serve_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
346
+ serve_parser.add_argument("--port", type=int, default=None, help="Port number (default: 3141 for dashboard, 3142 for tracker-only)")
347
+ serve_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
348
+ serve_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
349
+ serve_parser.add_argument(
350
+ "--project-search-mode",
351
+ choices=["realtime", "manual"],
352
+ default="realtime",
353
+ help="Project search mode on dashboard home (realtime or manual, default: realtime)",
354
+ )
355
+ serve_parser.add_argument(
356
+ "--storage-backend",
357
+ choices=["jsonl", "polars"],
358
+ default=None,
359
+ help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
360
+ )
361
+
362
+ args = parser.parse_args()
363
+
364
+ if args.command == "dashboard":
365
+ run_dashboard(
366
+ host=args.host,
367
+ port=args.port,
368
+ with_tracker=args.with_tracker,
369
+ data_dir=args.data_dir,
370
+ dev=args.dev,
371
+ project_search_mode=args.project_search_mode,
372
+ )
373
+ elif args.command == "tracker":
374
+ run_tracker(
375
+ host=args.host,
376
+ port=args.port,
377
+ data_dir=args.data_dir,
378
+ dev=args.dev,
379
+ storage_backend=args.storage_backend,
380
+ )
381
+ elif args.command == "tui":
382
+ run_tui(data_dir=args.data_dir)
383
+ elif args.command == "serve":
384
+ run_serve(
385
+ components=args.components,
386
+ host=args.host,
387
+ port=args.port,
388
+ data_dir=args.data_dir,
389
+ dev=args.dev,
390
+ project_search_mode=args.project_search_mode,
391
+ storage_backend=args.storage_backend,
392
+ )
393
+ else:
394
+ port = find_available_port(start_port=3141)
395
+ if port is None:
396
+ print("Error: No available port found!")
397
+ sys.exit(1)
398
+
399
+ run_dashboard(port=port)
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()
src/aspara/config.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration and environment handling for Aspara."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ __all__ = [
9
+ "ResourceLimits",
10
+ "get_data_dir",
11
+ "get_resource_limits",
12
+ "get_storage_backend",
13
+ "get_project_search_mode",
14
+ "is_dev_mode",
15
+ "is_read_only",
16
+ ]
17
+
18
+
19
+ class ResourceLimits(BaseModel):
20
+ """Resource limits configuration.
21
+
22
+ Includes security-related limits (file size, JSONL lines) and
23
+ performance/resource constraints (metric names, note length, tags count).
24
+
25
+ All limits can be customized via environment variables.
26
+ Defaults are set for internal use with generous limits.
27
+ """
28
+
29
+ max_file_size: int = Field(
30
+ default=1024 * 1024 * 1024, # 1024MB (1GB)
31
+ description="Maximum file size in bytes",
32
+ )
33
+
34
+ max_jsonl_lines: int = Field(
35
+ default=1_000_000, # 1M lines
36
+ description="Maximum number of lines when reading JSONL files",
37
+ )
38
+
39
+ max_zip_size: int = Field(
40
+ default=1024 * 1024 * 1024, # 1GB
41
+ description="Maximum ZIP file size in bytes",
42
+ )
43
+
44
+ max_metric_names: int = Field(
45
+ default=100,
46
+ description="Maximum number of metric names in comma-separated list",
47
+ )
48
+
49
+ max_notes_length: int = Field(
50
+ default=10 * 1024, # 10KB
51
+ description="Maximum notes text length in characters",
52
+ )
53
+
54
+ max_tags_count: int = Field(
55
+ default=100,
56
+ description="Maximum number of tags",
57
+ )
58
+
59
+ lttb_threshold: int = Field(
60
+ default=1_000,
61
+ description="Downsample metrics using LTTB algorithm when metric series length exceeds this threshold",
62
+ )
63
+
64
+ @classmethod
65
+ def from_env(cls) -> "ResourceLimits":
66
+ """Create ResourceLimits from environment variables.
67
+
68
+ Environment variables:
69
+ - ASPARA_MAX_FILE_SIZE: Maximum file size in bytes (default: 1GB)
70
+ - ASPARA_MAX_JSONL_LINES: Maximum JSONL lines (default: 1M)
71
+ - ASPARA_MAX_ZIP_SIZE: Maximum ZIP size in bytes (default: 1GB)
72
+ - ASPARA_MAX_METRIC_NAMES: Maximum metric names (default: 100)
73
+ - ASPARA_MAX_NOTES_LENGTH: Maximum notes length (default: 10KB)
74
+ - ASPARA_MAX_TAGS_COUNT: Maximum tags count (default: 100)
75
+ - ASPARA_LTTB_THRESHOLD: Threshold for LTTB downsampling (default: 1000)
76
+ """
77
+ return cls(
78
+ max_file_size=int(os.environ.get("ASPARA_MAX_FILE_SIZE", cls.model_fields["max_file_size"].default)),
79
+ max_jsonl_lines=int(os.environ.get("ASPARA_MAX_JSONL_LINES", cls.model_fields["max_jsonl_lines"].default)),
80
+ max_zip_size=int(os.environ.get("ASPARA_MAX_ZIP_SIZE", cls.model_fields["max_zip_size"].default)),
81
+ max_metric_names=int(os.environ.get("ASPARA_MAX_METRIC_NAMES", cls.model_fields["max_metric_names"].default)),
82
+ max_notes_length=int(os.environ.get("ASPARA_MAX_NOTES_LENGTH", cls.model_fields["max_notes_length"].default)),
83
+ max_tags_count=int(os.environ.get("ASPARA_MAX_TAGS_COUNT", cls.model_fields["max_tags_count"].default)),
84
+ lttb_threshold=int(os.environ.get("ASPARA_LTTB_THRESHOLD", cls.model_fields["lttb_threshold"].default)),
85
+ )
86
+
87
+
88
+ # Global resource limits instance
89
+ _resource_limits: ResourceLimits | None = None
90
+
91
+
92
+ def get_resource_limits() -> ResourceLimits:
93
+ """Get resource limits configuration.
94
+
95
+ Returns cached instance if already initialized.
96
+ """
97
+ global _resource_limits
98
+ if _resource_limits is None:
99
+ _resource_limits = ResourceLimits.from_env()
100
+ return _resource_limits
101
+
102
+
103
+ # Forbidden system directories that cannot be used as data directories
104
+ _FORBIDDEN_PATHS = frozenset(["/", "/etc", "/sys", "/dev", "/bin", "/sbin", "/usr", "/var", "/boot", "/proc"])
105
+
106
+
107
+ def _validate_data_dir(data_path: Path) -> None:
108
+ """Validate that data directory is not a dangerous system path.
109
+
110
+ Args:
111
+ data_path: Path to validate
112
+
113
+ Raises:
114
+ ValueError: If path is a forbidden system directory
115
+ """
116
+ resolved = data_path.resolve()
117
+ resolved_str = str(resolved)
118
+
119
+ for forbidden in _FORBIDDEN_PATHS:
120
+ if resolved_str == forbidden or resolved_str.rstrip("/") == forbidden:
121
+ raise ValueError(f"ASPARA_DATA_DIR cannot be set to system directory: {forbidden}")
122
+
123
+
124
+ def get_data_dir() -> Path:
125
+ """Get the default data directory for Aspara.
126
+
127
+ Resolution priority:
128
+ 1. ASPARA_DATA_DIR environment variable (if set)
129
+ 2. XDG_DATA_HOME/aspara (if XDG_DATA_HOME is set)
130
+ 3. ~/.local/share/aspara (fallback)
131
+
132
+ Returns:
133
+ Path object pointing to the data directory.
134
+
135
+ Raises:
136
+ ValueError: If ASPARA_DATA_DIR points to a system directory
137
+
138
+ Examples:
139
+ >>> # Using ASPARA_DATA_DIR
140
+ >>> os.environ["ASPARA_DATA_DIR"] = "/custom/path"
141
+ >>> get_data_dir()
142
+ Path('/custom/path')
143
+
144
+ >>> # Using XDG_DATA_HOME
145
+ >>> os.environ["XDG_DATA_HOME"] = "/home/user/.local/share"
146
+ >>> get_data_dir()
147
+ Path('/home/user/.local/share/aspara')
148
+
149
+ >>> # Using fallback
150
+ >>> get_data_dir()
151
+ Path('/home/user/.local/share/aspara')
152
+ """
153
+ # Priority 1: ASPARA_DATA_DIR environment variable
154
+ aspara_data_dir = os.environ.get("ASPARA_DATA_DIR")
155
+ if aspara_data_dir:
156
+ data_path = Path(aspara_data_dir).expanduser().resolve()
157
+ _validate_data_dir(data_path)
158
+ return data_path
159
+
160
+ # Priority 2: XDG_DATA_HOME/aspara
161
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
162
+ if xdg_data_home:
163
+ return Path(xdg_data_home).expanduser() / "aspara"
164
+
165
+ # Priority 3: ~/.local/share/aspara (fallback)
166
+ return Path.home() / ".local" / "share" / "aspara"
167
+
168
+
169
+ def get_project_search_mode() -> str:
170
+ """Get project search mode from environment variable.
171
+
172
+ Returns:
173
+ Project search mode ("realtime" or "manual"). Defaults to "realtime".
174
+ """
175
+ mode = os.environ.get("ASPARA_PROJECT_SEARCH_MODE", "realtime")
176
+ if mode not in ("realtime", "manual"):
177
+ return "realtime"
178
+ return mode
179
+
180
+
181
+ def get_storage_backend() -> str | None:
182
+ """Get storage backend from environment variable.
183
+
184
+ Returns:
185
+ Storage backend name if ASPARA_STORAGE_BACKEND is set, None otherwise.
186
+ """
187
+ return os.environ.get("ASPARA_STORAGE_BACKEND")
188
+
189
+
190
+ def use_lttb_fast() -> bool:
191
+ """Check if fast LTTB implementation should be used.
192
+
193
+ Returns:
194
+ True if ASPARA_LTTB_FAST is set to "1", False otherwise.
195
+ """
196
+ return os.environ.get("ASPARA_LTTB_FAST") == "1"
197
+
198
+
199
+ def is_dev_mode() -> bool:
200
+ """Check if running in development mode.
201
+
202
+ Returns:
203
+ True if ASPARA_DEV_MODE is set to "1", False otherwise.
204
+ """
205
+ return os.environ.get("ASPARA_DEV_MODE") == "1"
206
+
207
+
208
+ def is_read_only() -> bool:
209
+ """Check if running in read-only mode.
210
+
211
+ Returns:
212
+ True if ASPARA_READ_ONLY is set to "1", False otherwise.
213
+ """
214
+ return os.environ.get("ASPARA_READ_ONLY") == "1"
src/aspara/dashboard/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """
2
+ Aspara metrics visualization dashboard package!
3
+ """
4
+
5
+ __version__ = "0.1.0"
src/aspara/dashboard/dependencies.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependency injection for Aspara Dashboard.
3
+
4
+ This module provides reusable dependencies for:
5
+ - Catalog instance management (ProjectCatalog, RunCatalog)
6
+ - Path parameter validation (project names, run names)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ from fastapi import Depends, HTTPException
16
+ from fastapi import Path as PathParam
17
+
18
+ from aspara.catalog import ProjectCatalog, RunCatalog
19
+ from aspara.config import get_data_dir
20
+ from aspara.utils import validators
21
+
22
+ # Mutable container for custom data directory configuration
23
+ _custom_data_dir: list[str | None] = [None]
24
+
25
+
26
+ def _get_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
27
+ """Get or create catalog instances.
28
+
29
+ Returns:
30
+ Tuple of (ProjectCatalog, RunCatalog, data_dir Path)
31
+ """
32
+ if _custom_data_dir[0] is not None:
33
+ data_dir = Path(_custom_data_dir[0])
34
+ else:
35
+ data_dir = Path(get_data_dir())
36
+ return ProjectCatalog(str(data_dir)), RunCatalog(str(data_dir)), data_dir
37
+
38
+
39
+ # Cached version for performance
40
+ @lru_cache(maxsize=1)
41
+ def _get_cached_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
42
+ """Get cached catalog instances."""
43
+ return _get_catalogs()
44
+
45
+
46
+ def get_project_catalog() -> ProjectCatalog:
47
+ """Get the ProjectCatalog singleton instance."""
48
+ return _get_cached_catalogs()[0]
49
+
50
+
51
+ def get_run_catalog() -> RunCatalog:
52
+ """Get the RunCatalog singleton instance."""
53
+ return _get_cached_catalogs()[1]
54
+
55
+
56
+ def get_data_dir_path() -> Path:
57
+ """Get the data directory path."""
58
+ return _get_cached_catalogs()[2]
59
+
60
+
61
+ def configure_data_dir(data_dir: str | None = None) -> None:
62
+ """Configure data directory and reinitialize catalogs.
63
+
64
+ This function clears the cached catalogs and reinitializes them
65
+ with the specified data directory.
66
+
67
+ Args:
68
+ data_dir: Custom data directory path. If None, uses default.
69
+ """
70
+ # Clear the cache to force reinitialization
71
+ _get_cached_catalogs.cache_clear()
72
+
73
+ # Set custom data directory
74
+ _custom_data_dir[0] = data_dir
75
+
76
+
77
+ def get_validated_project(project: Annotated[str, PathParam(description="Project name")]) -> str:
78
+ """Validate project name path parameter.
79
+
80
+ Args:
81
+ project: Project name from URL path.
82
+
83
+ Returns:
84
+ Validated project name.
85
+
86
+ Raises:
87
+ HTTPException: 400 if project name is invalid.
88
+ """
89
+ try:
90
+ validators.validate_project_name(project)
91
+ except ValueError as e:
92
+ raise HTTPException(status_code=400, detail=str(e)) from None
93
+ return project
94
+
95
+
96
+ def get_validated_run(run: Annotated[str, PathParam(description="Run name")]) -> str:
97
+ """Validate run name path parameter.
98
+
99
+ Args:
100
+ run: Run name from URL path.
101
+
102
+ Returns:
103
+ Validated run name.
104
+
105
+ Raises:
106
+ HTTPException: 400 if run name is invalid.
107
+ """
108
+ try:
109
+ validators.validate_run_name(run)
110
+ except ValueError as e:
111
+ raise HTTPException(status_code=400, detail=str(e)) from None
112
+ return run
113
+
114
+
115
+ # Type aliases for dependency injection
116
+ ValidatedProject = Annotated[str, Depends(get_validated_project)]
117
+ ValidatedRun = Annotated[str, Depends(get_validated_run)]
118
+ ProjectCatalogDep = Annotated[ProjectCatalog, Depends(get_project_catalog)]
119
+ RunCatalogDep = Annotated[RunCatalog, Depends(get_run_catalog)]
120
+ DataDirDep = Annotated[Path, Depends(get_data_dir_path)]
src/aspara/dashboard/main.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for Aspara Dashboard
3
+ """
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
+ from starlette.responses import Response
17
+
18
+ from aspara.config import is_dev_mode
19
+
20
+ from .router import router
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Global state for SSE connection management
26
+ class AppState:
27
+ """Application state for managing SSE connections during shutdown."""
28
+
29
+ def __init__(self) -> None:
30
+ self.active_sse_connections: set[asyncio.Queue] = set()
31
+ self.active_sse_tasks: set[asyncio.Task] = set()
32
+ self.shutting_down = False
33
+
34
+
35
+ app_state = AppState()
36
+
37
+
38
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
39
+ """Middleware to add security headers to all responses."""
40
+
41
+ async def dispatch(self, request: Request, call_next) -> Response:
42
+ response = await call_next(request)
43
+
44
+ # Prevent MIME type sniffing
45
+ response.headers["X-Content-Type-Options"] = "nosniff"
46
+
47
+ # Prevent clickjacking by denying framing
48
+ response.headers["X-Frame-Options"] = "DENY"
49
+
50
+ # Enable XSS filter in browsers (legacy but still useful)
51
+ response.headers["X-XSS-Protection"] = "1; mode=block"
52
+
53
+ # Content Security Policy - basic policy
54
+ # Allows self-origin scripts/styles, inline styles for chart libraries,
55
+ # and data: URIs for images (used by chart exports)
56
+ response.headers["Content-Security-Policy"] = (
57
+ "default-src 'self'; "
58
+ "script-src 'self'; "
59
+ "style-src 'self' 'unsafe-inline'; "
60
+ "img-src 'self' data:; "
61
+ "font-src 'self'; "
62
+ "connect-src 'self'; "
63
+ "frame-ancestors 'none'"
64
+ )
65
+
66
+ # Allow iframe embedding when ASPARA_ALLOW_IFRAME=1 (e.g., HF Spaces)
67
+ if os.environ.get("ASPARA_ALLOW_IFRAME") == "1":
68
+ del response.headers["X-Frame-Options"]
69
+ response.headers["Content-Security-Policy"] = (
70
+ "default-src 'self'; "
71
+ "script-src 'self'; "
72
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
73
+ "img-src 'self' data:; "
74
+ "font-src 'self' https://fonts.gstatic.com; "
75
+ "connect-src 'self'; "
76
+ "frame-ancestors https://huggingface.co https://*.hf.space"
77
+ )
78
+
79
+ return response
80
+
81
+
82
+ @asynccontextmanager
83
+ async def lifespan(app: FastAPI):
84
+ """Manage application lifecycle.
85
+
86
+ On shutdown, signal all active SSE connections to close gracefully.
87
+ In development mode, forcefully cancel SSE tasks for fast restart.
88
+ """
89
+ # Startup
90
+ yield
91
+
92
+ # Shutdown
93
+ app_state.shutting_down = True
94
+
95
+ # Signal all active SSE connections to stop
96
+ for queue in list(app_state.active_sse_connections):
97
+ # Queue might already be closed or event loop shutting down
98
+ with contextlib.suppress(RuntimeError, OSError):
99
+ await queue.put(None) # Sentinel value to signal shutdown
100
+
101
+ if is_dev_mode():
102
+ # Development mode: forcefully cancel SSE tasks for fast restart
103
+ logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks")
104
+ for task in list(app_state.active_sse_tasks):
105
+ task.cancel()
106
+
107
+ # Wait briefly for tasks to be cancelled
108
+ if app_state.active_sse_tasks:
109
+ with contextlib.suppress(asyncio.TimeoutError):
110
+ await asyncio.wait_for(
111
+ asyncio.gather(*app_state.active_sse_tasks, return_exceptions=True),
112
+ timeout=0.1,
113
+ )
114
+ logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete")
115
+ else:
116
+ # Production mode: graceful shutdown with 30 second timeout
117
+ await asyncio.sleep(0.5)
118
+
119
+
120
+ app = FastAPI(
121
+ title="Aspara Dashboard",
122
+ description="Real-time metrics visualization for machine learning experiments",
123
+ docs_url="/docs/dashboard", # /docs/dashboard としてアクセスできるようにする
124
+ redoc_url=None, # ReDocは使わない
125
+ lifespan=lifespan,
126
+ )
127
+
128
+ # Security headers middleware
129
+ app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type]
130
+
131
+ # CORS middleware - credentials disabled for security with wildcard origins
132
+ # Note: allow_credentials=True with allow_origins=["*"] is a security vulnerability
133
+ # as it allows any site to make credentialed requests to our API
134
+ app.add_middleware(
135
+ CORSMiddleware, # ty: ignore[invalid-argument-type]
136
+ allow_origins=["*"],
137
+ allow_credentials=False,
138
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
139
+ allow_headers=["Content-Type", "X-Requested-With"],
140
+ )
141
+
142
+ BASE_DIR = Path(__file__).parent
143
+ app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
144
+
145
+ app.include_router(router)
src/aspara/dashboard/models/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Aspara dashboard data model definitions!
3
+ """
src/aspara/dashboard/models/metrics.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Models for metrics data.
3
+
4
+ This module defines the data models for the dashboard API.
5
+ Note: experiment concept has been removed - data structure is now project/run.
6
+ """
7
+
8
+ from datetime import datetime
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from aspara.catalog.project_catalog import ProjectInfo
13
+ from aspara.catalog.run_catalog import RunInfo
14
+
15
+ __all__ = [
16
+ "Metadata",
17
+ "MetadataUpdateRequest",
18
+ "MetricSeries",
19
+ "ProjectInfo",
20
+ "RunInfo",
21
+ ]
22
+
23
+
24
+ class Metadata(BaseModel):
25
+ """Metadata for projects and runs."""
26
+
27
+ notes: str = ""
28
+ tags: list[str] = []
29
+ created_at: datetime | None = None
30
+ updated_at: datetime | None = None
31
+
32
+
33
+ class MetadataUpdateRequest(BaseModel):
34
+ """Request model for updating metadata."""
35
+
36
+ notes: str | None = None
37
+ tags: list[str] | None = None
38
+
39
+
40
+ class MetricSeries(BaseModel):
41
+ """A single metric time series with steps, values, and timestamps.
42
+
43
+ Used in the metrics API response to represent one metric's data.
44
+ Arrays are delta-compressed where applicable.
45
+ """
46
+
47
+ steps: list[int | float]
48
+ values: list[int | float]
49
+ timestamps: list[int | float]
src/aspara/dashboard/router.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard APIRouter aggregation.
3
+
4
+ This module aggregates all route handlers from sub-modules:
5
+ - html_routes: HTML page endpoints
6
+ - api_routes: REST API endpoints
7
+ - sse_routes: Server-Sent Events streaming endpoints
8
+
9
+ Note: experiment concept has been removed - URL structure is now /projects/{project}/runs/{run}
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import APIRouter
15
+
16
+ # Re-export configure_data_dir for backwards compatibility
17
+ from .dependencies import configure_data_dir
18
+ from .routes import api_router, html_router, sse_router
19
+
20
+ router = APIRouter()
21
+ router.include_router(html_router)
22
+ router.include_router(api_router)
23
+ router.include_router(sse_router)
24
+
25
+ __all__ = ["router", "configure_data_dir"]
src/aspara/dashboard/routes/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard routes.
3
+
4
+ This package contains route handlers organized by type:
5
+ - html_routes: HTML page endpoints
6
+ - api_routes: REST API endpoints
7
+ - sse_routes: Server-Sent Events streaming endpoints
8
+ """
9
+
10
+ from .api_routes import router as api_router
11
+ from .html_routes import router as html_router
12
+ from .sse_routes import router as sse_router
13
+
14
+ __all__ = ["html_router", "api_router", "sse_router"]
src/aspara/dashboard/routes/api_routes.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ REST API routes for Aspara Dashboard.
3
+
4
+ This module handles all REST API endpoints:
5
+ - Artifacts download API
6
+ - Bulk metrics API
7
+ - Project/Run metadata APIs
8
+ - Delete APIs
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import io
15
+ import logging
16
+ import os
17
+ import zipfile
18
+ from collections import defaultdict
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+
22
+ import msgpack
23
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query
24
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
25
+
26
+ from aspara.config import get_resource_limits, is_read_only
27
+ from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
28
+ from aspara.utils import validators
29
+
30
+ from ..dependencies import (
31
+ DataDirDep,
32
+ ProjectCatalogDep,
33
+ RunCatalogDep,
34
+ ValidatedProject,
35
+ ValidatedRun,
36
+ )
37
+ from ..models.metrics import Metadata, MetadataUpdateRequest
38
+ from ..utils import parse_and_validate_run_list
39
+ from ..utils.compression import compress_metrics
40
+
41
+
42
+ async def verify_csrf_header(x_requested_with: str | None = Header(None, alias="X-Requested-With")) -> None:
43
+ """CSRF protection via custom header check.
44
+
45
+ Verifies that requests include the X-Requested-With header, which cannot be set
46
+ by cross-origin requests without CORS preflight. This prevents CSRF attacks.
47
+
48
+ Args:
49
+ x_requested_with: The X-Requested-With header value
50
+
51
+ Raises:
52
+ HTTPException: 403 if header is missing
53
+ """
54
+ if x_requested_with is None:
55
+ raise HTTPException(status_code=403, detail="Missing X-Requested-With header")
56
+
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ router = APIRouter()
61
+
62
+
63
+ @router.get("/api/projects/{project}/runs/{run}/artifacts/download")
64
+ async def download_artifacts_zip(
65
+ project: ValidatedProject,
66
+ run: ValidatedRun,
67
+ data_dir: DataDirDep,
68
+ ) -> StreamingResponse:
69
+ """Download all artifacts for a run as a ZIP file.
70
+
71
+ Args:
72
+ project: Project name.
73
+ run: Run name.
74
+
75
+ Returns:
76
+ StreamingResponse with ZIP file containing all artifacts.
77
+ Filename format: `{project}_{run}_artifacts_{timestamp}.zip`
78
+
79
+ Raises:
80
+ HTTPException: 400 if project/run name is invalid or total size exceeds limit,
81
+ 404 if no artifacts found.
82
+ """
83
+ # Get the artifacts directory path
84
+ artifacts_dir = data_dir / project / run / "artifacts"
85
+
86
+ # Validate path to prevent path traversal
87
+ try:
88
+ validators.validate_safe_path(artifacts_dir, data_dir)
89
+ except ValueError as e:
90
+ raise HTTPException(status_code=400, detail=f"Invalid artifacts directory path: {e}") from None
91
+
92
+ artifacts_dir_str = str(artifacts_dir)
93
+
94
+ if not os.path.exists(artifacts_dir_str):
95
+ raise HTTPException(status_code=404, detail="No artifacts found for this run")
96
+
97
+ # Single-pass: collect file info using scandir (caches stat results)
98
+ artifact_entries: list[tuple[str, str, int]] = [] # (name, path, size)
99
+ total_size = 0
100
+
101
+ with os.scandir(artifacts_dir_str) as entries:
102
+ for entry in entries:
103
+ if entry.is_file():
104
+ size = entry.stat().st_size # Uses cached stat
105
+ artifact_entries.append((entry.name, entry.path, size))
106
+ total_size += size
107
+
108
+ if not artifact_entries:
109
+ raise HTTPException(status_code=404, detail="No artifact files found")
110
+
111
+ # Check total size
112
+ limits = get_resource_limits()
113
+ if total_size > limits.max_zip_size:
114
+ raise HTTPException(
115
+ status_code=400,
116
+ detail=(f"Total artifacts size ({total_size} bytes) exceeds maximum ZIP size limit ({limits.max_zip_size} bytes)"),
117
+ )
118
+
119
+ # Create ZIP file in memory
120
+ zip_buffer = io.BytesIO()
121
+
122
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
123
+ for filename, file_path, _ in artifact_entries:
124
+ # Add file to zip with just the filename (no directory structure)
125
+ zip_file.write(file_path, filename)
126
+
127
+ zip_buffer.seek(0)
128
+
129
+ # Generate filename with timestamp
130
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
131
+ zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip"
132
+
133
+ # Return as streaming response
134
+ # Encode filename for Content-Disposition header to prevent header injection
135
+ # Use RFC 5987 encoding for non-ASCII characters
136
+ import urllib.parse
137
+
138
+ encoded_filename = urllib.parse.quote(zip_filename, safe="")
139
+ return StreamingResponse(
140
+ io.BytesIO(zip_buffer.read()),
141
+ media_type="application/zip",
142
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
143
+ )
144
+
145
+
146
+ @router.get("/api/projects/{project}/runs/metrics")
147
+ async def runs_metrics_api(
148
+ project: ValidatedProject,
149
+ run_catalog: RunCatalogDep,
150
+ runs: str,
151
+ format: str = "json",
152
+ since: int | None = Query(
153
+ default=None,
154
+ description="Filter metrics since this UNIX timestamp in milliseconds",
155
+ ),
156
+ ) -> Response:
157
+ """Get metrics for multiple runs in a single request.
158
+
159
+ Useful for comparing metrics across runs. Returns data in metric-first structure
160
+ where each metric contains data from all requested runs.
161
+
162
+ Args:
163
+ project: Project name.
164
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
165
+ format: Response format - "json" (default) or "msgpack".
166
+ since: Optional filter to only return metrics with timestamp >= since (UNIX ms).
167
+
168
+ Returns:
169
+ Response with structure: `{"project": str, "metrics": {metric: {run: {...}}}}`
170
+ - For "json" format: JSONResponse
171
+ - For "msgpack" format: Response with application/x-msgpack content type
172
+
173
+ Raises:
174
+ HTTPException: 400 if project name is invalid, format is invalid,
175
+ or too many runs specified.
176
+ """
177
+ # Validate format parameter
178
+ if format not in ("json", "msgpack"):
179
+ raise HTTPException(
180
+ status_code=400,
181
+ detail=f"Invalid format: {format}. Must be 'json' or 'msgpack'",
182
+ )
183
+
184
+ try:
185
+ run_list = parse_and_validate_run_list(runs)
186
+ except ValueError as e:
187
+ if format == "msgpack":
188
+ raise HTTPException(status_code=400, detail=str(e)) from None
189
+ return JSONResponse(content={"error": str(e)}, status_code=400)
190
+
191
+ # Convert since (UNIX ms) to datetime if provided
192
+ # Create timezone-naive datetime (matches DataFrame storage)
193
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc).replace(tzinfo=None) if since is not None else None
194
+
195
+ # Load and downsample metrics for all runs in parallel
196
+ async def load_and_downsample(
197
+ run_name: str,
198
+ ) -> tuple[str, dict[str, dict[str, list]] | None]:
199
+ """Load and downsample metrics for a single run."""
200
+ try:
201
+ df = await asyncio.to_thread(run_catalog.load_metrics, project, run_name, since_dt)
202
+ return (run_name, compress_metrics(df))
203
+ except Exception as e:
204
+ logger.warning(f"Failed to load metrics for {project}/{run_name}: {type(e).__name__}: {e}")
205
+ return (run_name, None)
206
+
207
+ # Execute all loads in parallel
208
+ results = await asyncio.gather(*[load_and_downsample(run_name) for run_name in run_list])
209
+
210
+ # Build metrics_by_run from results
211
+ metrics_by_run: dict[str, dict[str, dict[str, list]]] = {}
212
+ for run_name, metrics in results:
213
+ if metrics is not None:
214
+ metrics_by_run[run_name] = metrics
215
+
216
+ # Reorganize to metric-first structure using defaultdict for O(1) key insertion
217
+ metrics_data: dict[str, dict[str, dict[str, list]]] = defaultdict(dict)
218
+ for run_name, run_metrics in metrics_by_run.items():
219
+ for metric_name, metric_arrays in run_metrics.items():
220
+ metrics_data[metric_name][run_name] = metric_arrays
221
+
222
+ response_data = {"project": project, "metrics": metrics_data}
223
+
224
+ # Return response based on format
225
+ if format == "msgpack":
226
+ # Serialize to MessagePack
227
+ packed_data = msgpack.packb(response_data, use_single_float=True)
228
+ return Response(content=packed_data, media_type="application/x-msgpack")
229
+
230
+ return JSONResponse(content=response_data)
231
+
232
+
233
+ @router.get("/api/projects/{project}/metadata")
234
+ async def get_project_metadata_api(
235
+ project: ValidatedProject,
236
+ project_catalog: ProjectCatalogDep,
237
+ ) -> Metadata:
238
+ """Get project metadata.
239
+
240
+ Args:
241
+ project: Project name.
242
+
243
+ Returns:
244
+ Metadata object containing project metadata (tags, notes, etc.).
245
+
246
+ Raises:
247
+ HTTPException: 400 if project name is invalid.
248
+ """
249
+ # Use ProjectCatalog metadata API (synchronous call inside async endpoint)
250
+ metadata = project_catalog.get_metadata(project)
251
+ return Metadata.model_validate(metadata)
252
+
253
+
254
+ @router.put("/api/projects/{project}/metadata")
255
+ async def update_project_metadata_api(
256
+ project: ValidatedProject,
257
+ metadata: MetadataUpdateRequest,
258
+ project_catalog: ProjectCatalogDep,
259
+ _csrf: None = Depends(verify_csrf_header),
260
+ ) -> Metadata:
261
+ """Update project metadata.
262
+
263
+ Args:
264
+ project: Project name.
265
+ metadata: MetadataUpdateRequest containing fields to update.
266
+
267
+ Returns:
268
+ Metadata object containing the updated project metadata.
269
+
270
+ Raises:
271
+ HTTPException: 400 if project name is invalid.
272
+ """
273
+ if is_read_only():
274
+ existing = project_catalog.get_metadata(project)
275
+ return Metadata.model_validate(existing)
276
+
277
+ update_data = metadata.model_dump(exclude_none=True)
278
+
279
+ # Use ProjectCatalog metadata API
280
+ updated_metadata = project_catalog.update_metadata(project, update_data)
281
+ return Metadata.model_validate(updated_metadata)
282
+
283
+
284
+ @router.delete("/api/projects/{project}")
285
+ async def delete_project(
286
+ project: ValidatedProject,
287
+ project_catalog: ProjectCatalogDep,
288
+ _csrf: None = Depends(verify_csrf_header),
289
+ ) -> Response:
290
+ """Delete a project and all its runs.
291
+
292
+ **Warning**: This operation is irreversible.
293
+
294
+ Args:
295
+ project: Project name.
296
+
297
+ Returns:
298
+ 204 No Content on success.
299
+
300
+ Raises:
301
+ HTTPException: 400 if project name is invalid, 403 if permission denied,
302
+ 404 if project not found, 500 for unexpected errors.
303
+ """
304
+ if is_read_only():
305
+ return Response(status_code=204)
306
+
307
+ try:
308
+ project_catalog.delete(project)
309
+ logger.info(f"Deleted project: {project}")
310
+ return Response(status_code=204)
311
+ except ProjectNotFoundError as e:
312
+ raise HTTPException(status_code=404, detail=str(e)) from e
313
+ except PermissionError as e:
314
+ logger.warning(f"Permission denied deleting project {project}: {e}")
315
+ raise HTTPException(status_code=403, detail="Permission denied") from e
316
+ except Exception as e:
317
+ logger.error(f"Error deleting project {project}: {type(e).__name__}: {e}")
318
+ raise HTTPException(status_code=500, detail="Failed to delete project") from e
319
+
320
+
321
+ @router.get("/api/projects/{project}/runs/{run}/metadata")
322
+ async def get_run_metadata_api(
323
+ project: ValidatedProject,
324
+ run: ValidatedRun,
325
+ run_catalog: RunCatalogDep,
326
+ ) -> dict[str, Any]:
327
+ """Get run metadata.
328
+
329
+ Args:
330
+ project: Project name.
331
+ run: Run name.
332
+
333
+ Returns:
334
+ Dictionary containing run metadata (tags, notes, params, etc.).
335
+
336
+ Raises:
337
+ HTTPException: 400 if project/run name is invalid.
338
+ """
339
+ # Use RunCatalog metadata API
340
+ metadata = run_catalog.get_metadata(project, run)
341
+ return metadata
342
+
343
+
344
+ @router.put("/api/projects/{project}/runs/{run}/metadata")
345
+ async def update_run_metadata_api(
346
+ project: ValidatedProject,
347
+ run: ValidatedRun,
348
+ metadata: MetadataUpdateRequest,
349
+ run_catalog: RunCatalogDep,
350
+ _csrf: None = Depends(verify_csrf_header),
351
+ ) -> dict[str, Any]:
352
+ """Update run metadata.
353
+
354
+ Args:
355
+ project: Project name.
356
+ run: Run name.
357
+ metadata: MetadataUpdateRequest containing fields to update.
358
+
359
+ Returns:
360
+ Dictionary containing the updated run metadata.
361
+
362
+ Raises:
363
+ HTTPException: 400 if project/run name is invalid.
364
+ """
365
+ if is_read_only():
366
+ existing = run_catalog.get_metadata(project, run)
367
+ return existing
368
+
369
+ update_data = metadata.model_dump(exclude_none=True)
370
+
371
+ # Use RunCatalog metadata API
372
+ updated_metadata = run_catalog.update_metadata(project, run, update_data)
373
+ return updated_metadata
374
+
375
+
376
+ @router.delete("/api/projects/{project}/runs/{run}")
377
+ async def delete_run(
378
+ project: ValidatedProject,
379
+ run: ValidatedRun,
380
+ run_catalog: RunCatalogDep,
381
+ _csrf: None = Depends(verify_csrf_header),
382
+ ) -> Response:
383
+ """Delete a run and its artifacts.
384
+
385
+ **Warning**: This operation is irreversible.
386
+
387
+ Args:
388
+ project: Project name.
389
+ run: Run name.
390
+
391
+ Returns:
392
+ 204 No Content on success.
393
+
394
+ Raises:
395
+ HTTPException: 400 if project/run name is invalid, 403 if permission denied,
396
+ 404 if project or run not found, 500 for unexpected errors.
397
+ """
398
+ if is_read_only():
399
+ return Response(status_code=204)
400
+
401
+ try:
402
+ run_catalog.delete(project, run)
403
+ logger.info(f"Deleted run: {project}/{run}")
404
+ return Response(status_code=204)
405
+ except ProjectNotFoundError as e:
406
+ raise HTTPException(status_code=404, detail=str(e)) from e
407
+ except RunNotFoundError as e:
408
+ raise HTTPException(status_code=404, detail=str(e)) from e
409
+ except PermissionError as e:
410
+ logger.warning(f"Permission denied deleting run {project}/{run}: {e}")
411
+ raise HTTPException(status_code=403, detail="Permission denied") from e
412
+ except Exception as e:
413
+ logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}: {e}")
414
+ raise HTTPException(status_code=500, detail="Failed to delete run") from e
src/aspara/dashboard/routes/html_routes.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HTML page routes for Aspara Dashboard.
3
+
4
+ This module handles all HTML page rendering endpoints:
5
+ - Home page (projects list)
6
+ - Project detail page
7
+ - Runs list page
8
+ - Run detail page
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from fastapi import APIRouter, HTTPException
18
+ from fastapi.responses import HTMLResponse
19
+ from starlette.requests import Request
20
+
21
+ from aspara.config import is_read_only
22
+ from aspara.exceptions import RunNotFoundError
23
+
24
+ from ..dependencies import (
25
+ ProjectCatalogDep,
26
+ RunCatalogDep,
27
+ ValidatedProject,
28
+ ValidatedRun,
29
+ )
30
+ from ..services.template_service import (
31
+ TemplateService,
32
+ create_breadcrumbs,
33
+ render_mustache_response,
34
+ )
35
+
36
+ router = APIRouter()
37
+
38
+
39
+ @router.get("/")
40
+ async def home(
41
+ request: Request,
42
+ project_catalog: ProjectCatalogDep,
43
+ ) -> HTMLResponse:
44
+ """Render the projects list page."""
45
+ projects = project_catalog.get_projects()
46
+
47
+ # Format projects for template, including metadata tags
48
+ formatted_projects = []
49
+ for project in projects:
50
+ metadata = project_catalog.get_metadata(project.name)
51
+ tags = metadata.get("tags") or []
52
+ formatted_projects.append(TemplateService.format_project_for_template(project, tags))
53
+
54
+ from aspara.config import get_project_search_mode
55
+
56
+ project_search_mode = get_project_search_mode()
57
+
58
+ context = {
59
+ "page_title": "Aspara",
60
+ "breadcrumbs": create_breadcrumbs([{"label": "Home", "is_home": True}]),
61
+ "projects": formatted_projects,
62
+ "has_projects": len(formatted_projects) > 0,
63
+ "project_search_mode": project_search_mode,
64
+ "read_only": is_read_only(),
65
+ }
66
+
67
+ html = render_mustache_response("projects_list", context)
68
+ return HTMLResponse(content=html)
69
+
70
+
71
+ @router.get("/projects/{project}")
72
+ async def project_detail(
73
+ request: Request,
74
+ project: ValidatedProject,
75
+ project_catalog: ProjectCatalogDep,
76
+ run_catalog: RunCatalogDep,
77
+ ) -> HTMLResponse:
78
+ """Project detail page - shows metrics charts."""
79
+ # Check if project exists
80
+ if not project_catalog.exists(project):
81
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
82
+
83
+ runs = run_catalog.get_runs(project)
84
+
85
+ # Format runs for template (excluding corrupted runs)
86
+ formatted_runs = []
87
+ for run in runs:
88
+ formatted = TemplateService.format_run_for_project_detail(run)
89
+ if formatted is not None:
90
+ formatted_runs.append(formatted)
91
+
92
+ # Find the most recent last_update from all runs
93
+ project_last_update = None
94
+ if runs:
95
+ last_updates = [r.last_update for r in runs if r.last_update is not None]
96
+ if last_updates:
97
+ project_last_update = max(last_updates)
98
+
99
+ context = {
100
+ "page_title": f"{project} - Metrics",
101
+ "breadcrumbs": create_breadcrumbs([
102
+ {"label": "Home", "url": "/", "is_home": True},
103
+ {"label": project},
104
+ ]),
105
+ "project": project,
106
+ "runs": formatted_runs,
107
+ "has_runs": len(formatted_runs) > 0,
108
+ "run_count": len(formatted_runs),
109
+ "formatted_project_last_update": (project_last_update.strftime("%b %d, %Y at %I:%M %p") if project_last_update else "N/A"),
110
+ "read_only": is_read_only(),
111
+ }
112
+
113
+ html = render_mustache_response("project_detail", context)
114
+ return HTMLResponse(content=html)
115
+
116
+
117
+ @router.get("/projects/{project}/runs")
118
+ async def list_project_runs(
119
+ request: Request,
120
+ project: ValidatedProject,
121
+ project_catalog: ProjectCatalogDep,
122
+ run_catalog: RunCatalogDep,
123
+ ) -> HTMLResponse:
124
+ """List runs in a project."""
125
+ # Check if project exists
126
+ if not project_catalog.exists(project):
127
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
128
+
129
+ runs = run_catalog.get_runs(project)
130
+
131
+ # Format runs for template
132
+ formatted_runs = [TemplateService.format_run_for_list(run) for run in runs]
133
+
134
+ context = {
135
+ "page_title": f"{project} - Runs",
136
+ "breadcrumbs": create_breadcrumbs([
137
+ {"label": "Home", "url": "/", "is_home": True},
138
+ {"label": project, "url": f"/projects/{project}"},
139
+ {"label": "Runs"},
140
+ ]),
141
+ "project": project,
142
+ "runs": formatted_runs,
143
+ "has_runs": len(formatted_runs) > 0,
144
+ "read_only": is_read_only(),
145
+ }
146
+
147
+ html = render_mustache_response("runs_list", context)
148
+ return HTMLResponse(content=html)
149
+
150
+
151
+ @router.get("/projects/{project}/runs/{run}")
152
+ async def get_run(
153
+ request: Request,
154
+ project: ValidatedProject,
155
+ run: ValidatedRun,
156
+ project_catalog: ProjectCatalogDep,
157
+ run_catalog: RunCatalogDep,
158
+ ) -> HTMLResponse:
159
+ """Get run details including parameters and metrics."""
160
+ # Check if project exists
161
+ if not project_catalog.exists(project):
162
+ raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
163
+
164
+ # Get Run information and check if it's corrupted
165
+ try:
166
+ current_run = run_catalog.get(project, run)
167
+ except RunNotFoundError as e:
168
+ raise HTTPException(status_code=404, detail=f"Run '{run}' not found in project '{project}'") from e
169
+
170
+ is_corrupted = current_run.is_corrupted
171
+ error_message = current_run.error_message
172
+ run_tags = current_run.tags
173
+
174
+ # Load metrics, artifacts, and metadata in parallel
175
+ df_metrics, artifacts, metadata = await asyncio.gather(
176
+ asyncio.to_thread(run_catalog.load_metrics, project, run),
177
+ run_catalog.get_artifacts_async(project, run),
178
+ run_catalog.get_run_config_async(project, run),
179
+ )
180
+
181
+ # Extract params from metadata
182
+ params: dict[str, Any] = {}
183
+ params.update(metadata.get("params", {}))
184
+ params.update(metadata.get("config", {}))
185
+
186
+ # Format data for template
187
+ formatted_params = [{"key": k, "value": v} for k, v in params.items()]
188
+
189
+ # Get latest metrics for scalar display from wide-format DataFrame
190
+ latest_metrics: dict[str, Any] = {}
191
+ if len(df_metrics) > 0:
192
+ # Get last row (latest metrics)
193
+ last_row = df_metrics.tail(1).to_dicts()[0]
194
+ # Extract metric columns (those starting with underscore)
195
+ for col, value in last_row.items():
196
+ if col.startswith("_") and value is not None:
197
+ # Remove underscore prefix
198
+ metric_name = col[1:]
199
+ latest_metrics[metric_name] = value
200
+
201
+ formatted_latest_metrics = [{"key": k, "value": f"{v:.4f}" if isinstance(v, int | float) else str(v)} for k, v in latest_metrics.items()]
202
+
203
+ # Get run start time from DataFrame
204
+ start_time = None
205
+ if len(df_metrics) > 0 and "timestamp" in df_metrics.columns:
206
+ start_time = df_metrics.select("timestamp").to_series().min()
207
+
208
+ context = {
209
+ "page_title": f"{run} - Details",
210
+ "breadcrumbs": create_breadcrumbs([
211
+ {"label": "Home", "url": "/", "is_home": True},
212
+ {"label": project, "url": f"/projects/{project}"},
213
+ {"label": "Runs", "url": f"/projects/{project}/runs"},
214
+ {"label": run},
215
+ ]),
216
+ "project": project,
217
+ "run_name": run,
218
+ "params": formatted_params,
219
+ "has_params": len(formatted_params) > 0,
220
+ "latest_metrics": formatted_latest_metrics,
221
+ "has_latest_metrics": len(formatted_latest_metrics) > 0,
222
+ "formatted_start_time": (start_time.strftime("%B %d, %Y at %I:%M %p") if isinstance(start_time, datetime) else "N/A"),
223
+ "duration": "N/A", # We don't have duration data in current format
224
+ "has_tags": len(run_tags) > 0,
225
+ "tags": run_tags,
226
+ "artifacts": [TemplateService.format_artifact_for_template(artifact) for artifact in artifacts],
227
+ "has_artifacts": len(artifacts) > 0,
228
+ "is_corrupted": is_corrupted,
229
+ "error_message": error_message,
230
+ "read_only": is_read_only(),
231
+ }
232
+
233
+ html = render_mustache_response("run_detail", context)
234
+ return HTMLResponse(content=html)
src/aspara/dashboard/routes/sse_routes.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 is_dev_mode
21
+ from aspara.models import MetricRecord, StatusRecord
22
+
23
+ from ..dependencies import RunCatalogDep, ValidatedProject
24
+ from ..utils import parse_and_validate_run_list
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ @router.get("/api/projects/{project}/runs/stream")
32
+ async def stream_multiple_runs(
33
+ project: ValidatedProject,
34
+ run_catalog: RunCatalogDep,
35
+ runs: str,
36
+ since: int = Query(
37
+ ...,
38
+ description="Filter metrics since this UNIX timestamp in milliseconds",
39
+ ),
40
+ ) -> EventSourceResponse:
41
+ """Stream metrics for multiple runs using Server-Sent Events (SSE).
42
+
43
+ Args:
44
+ project: Project name.
45
+ runs: Comma-separated list of run names (e.g., "run1,run2,run3").
46
+ since: Filter to only stream metrics with timestamp >= since (required, UNIX ms).
47
+
48
+ Returns:
49
+ EventSourceResponse streaming metric and status events from all specified runs.
50
+ Event types:
51
+ - `metric`: `{"event": "metric", "data": <MetricRecord JSON>}`
52
+ - `status`: `{"event": "status", "data": <StatusRecord JSON>}`
53
+
54
+ Raises:
55
+ HTTPException: 400 if project/run name is invalid, 422 if since is missing.
56
+ """
57
+ logger.info(f"[SSE ENDPOINT] Called with project={project}, runs={runs}")
58
+
59
+ from ..main import app_state
60
+
61
+ try:
62
+ run_list = parse_and_validate_run_list(runs)
63
+ except ValueError as e:
64
+
65
+ async def validation_error_generator(msg: str = str(e)):
66
+ yield {"event": "error", "data": msg}
67
+
68
+ return EventSourceResponse(validation_error_generator())
69
+
70
+ # Convert UNIX ms to datetime
71
+ since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc)
72
+
73
+ async def event_generator():
74
+ logger.info(f"[SSE] event_generator started for project={project}, runs={run_list}")
75
+
76
+ # Register current task for dev mode forced cancellation
77
+ current_task = asyncio.current_task()
78
+ if current_task is not None:
79
+ app_state.active_sse_tasks.add(current_task)
80
+
81
+ # Create shutdown queue for this connection
82
+ shutdown_queue: asyncio.Queue[None] = asyncio.Queue()
83
+ app_state.active_sse_connections.add(shutdown_queue)
84
+
85
+ # Use new subscribe() method with singleton watcher
86
+ targets = {project: run_list}
87
+ metrics_iterator = run_catalog.subscribe(targets, since=since_dt).__aiter__()
88
+ logger.info("[SSE] Created metrics_iterator using subscribe()")
89
+
90
+ # In dev mode, use shorter timeout for faster shutdown detection
91
+ dev_mode = is_dev_mode()
92
+ wait_timeout = 1.0 if dev_mode else None
93
+
94
+ # Track pending metric task to avoid re-creating it after timeout
95
+ # IMPORTANT: Cancelling a task that's awaiting inside an async generator
96
+ # will close the generator. We must NOT cancel metric_task on timeout.
97
+ pending_metric_task: asyncio.Task[MetricRecord | StatusRecord] | None = None
98
+
99
+ try:
100
+ while True:
101
+ # Check shutdown flag in dev mode
102
+ if dev_mode and app_state.shutting_down:
103
+ logger.info("[SSE] Dev mode: shutdown flag detected")
104
+ # Cancel pending metric_task before exiting
105
+ if pending_metric_task is not None:
106
+ pending_metric_task.cancel()
107
+ with suppress(asyncio.CancelledError):
108
+ await pending_metric_task
109
+ break
110
+
111
+ # Create metric_task only if we don't have a pending one
112
+ if pending_metric_task is None:
113
+ metric_coro = cast(
114
+ "Coroutine[Any, Any, MetricRecord | StatusRecord]",
115
+ metrics_iterator.__anext__(),
116
+ )
117
+ pending_metric_task = asyncio.create_task(metric_coro, name="metric_task")
118
+
119
+ # Always create a new shutdown_task
120
+ shutdown_coro = cast("Coroutine[Any, Any, Any]", shutdown_queue.get())
121
+ shutdown_task = asyncio.create_task(shutdown_coro, name="shutdown_task")
122
+
123
+ try:
124
+ done, pending = await asyncio.wait(
125
+ [pending_metric_task, shutdown_task],
126
+ return_when=asyncio.FIRST_COMPLETED,
127
+ timeout=wait_timeout,
128
+ )
129
+ except asyncio.CancelledError:
130
+ # Cancelled by lifespan handler in dev mode
131
+ logger.info("[SSE] Task cancelled (dev mode shutdown)")
132
+ pending_metric_task.cancel()
133
+ shutdown_task.cancel()
134
+ with suppress(asyncio.CancelledError):
135
+ await pending_metric_task
136
+ with suppress(asyncio.CancelledError):
137
+ await shutdown_task
138
+ raise
139
+
140
+ # Handle timeout (dev mode only)
141
+ if not done:
142
+ # Timeout occurred - only cancel shutdown_task, NOT metric_task
143
+ # Cancelling metric_task would close the async generator!
144
+ shutdown_task.cancel()
145
+ with suppress(asyncio.CancelledError):
146
+ await shutdown_task
147
+ # pending_metric_task is kept and will be reused in next iteration
148
+ continue
149
+
150
+ logger.debug(f"[SSE] asyncio.wait returned: done={[t.get_name() for t in done]}, pending={[t.get_name() for t in pending]}")
151
+
152
+ # Cancel pending tasks (but NOT metric_task if it's pending)
153
+ if shutdown_task in pending:
154
+ shutdown_task.cancel()
155
+ with suppress(asyncio.CancelledError):
156
+ await shutdown_task
157
+
158
+ # Check which task completed
159
+ if pending_metric_task in done:
160
+ # Reset so we create a new task in next iteration
161
+ completed_task = pending_metric_task
162
+ pending_metric_task = None
163
+ try:
164
+ record = completed_task.result()
165
+ if isinstance(record, MetricRecord):
166
+ logger.debug(f"[SSE] Sending metric to client: run={record.run}, step={record.step}")
167
+ yield {"event": "metric", "data": record.model_dump_json()}
168
+ elif isinstance(record, StatusRecord):
169
+ logger.info(f"[SSE] Sending status update to client: run={record.run}, status={record.status}")
170
+ yield {"event": "status", "data": record.model_dump_json()}
171
+ except StopAsyncIteration:
172
+ logger.info("[SSE] No more records (StopAsyncIteration)")
173
+ break
174
+ elif shutdown_task in done:
175
+ logger.info("[SSE] Shutdown requested")
176
+ # Cancel metric_task since we're shutting down
177
+ if pending_metric_task is not None:
178
+ pending_metric_task.cancel()
179
+ with suppress(asyncio.CancelledError):
180
+ await pending_metric_task
181
+ break
182
+
183
+ except asyncio.CancelledError:
184
+ logger.info("[SSE] Generator cancelled")
185
+ raise
186
+ except Exception as e:
187
+ # Log the full exception internally. In production, send a generic
188
+ # message to the client so internal paths, library internals, or
189
+ # other sensitive details are not leaked over the wire. In dev
190
+ # mode, include the exception text for easier debugging.
191
+ logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True)
192
+ error_data = str(e) if dev_mode else "Internal server error"
193
+ yield {"event": "error", "data": error_data}
194
+ finally:
195
+ # Clean up: remove this connection from active set
196
+ logger.info("[SSE] event_generator finished, cleaning up")
197
+ app_state.active_sse_connections.discard(shutdown_queue)
198
+ if current_task is not None:
199
+ app_state.active_sse_tasks.discard(current_task)
200
+ # Cancel pending metric task if still running
201
+ if pending_metric_task is not None and not pending_metric_task.done():
202
+ pending_metric_task.cancel()
203
+ with suppress(asyncio.CancelledError):
204
+ await pending_metric_task
205
+ # Close the async generator to trigger watcher unsubscribe
206
+ try:
207
+ await asyncio.wait_for(metrics_iterator.aclose(), timeout=1.0)
208
+ except asyncio.TimeoutError:
209
+ logger.warning("[SSE] Timeout closing metrics_iterator")
210
+ except Exception as e:
211
+ logger.warning(f"[SSE] Error closing metrics_iterator: {e}")
212
+
213
+ logger.info(f"[SSE ENDPOINT] Returning EventSourceResponse for runs={run_list}")
214
+ return EventSourceResponse(event_generator())
src/aspara/dashboard/services/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aspara Dashboard services.
3
+
4
+ This package contains business logic services for the dashboard.
5
+ """
6
+
7
+ from .template_service import TemplateService, create_breadcrumbs, render_mustache_response
8
+
9
+ __all__ = ["TemplateService", "create_breadcrumbs", "render_mustache_response"]
src/aspara/dashboard/services/template_service.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 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
+ })
63
+
64
+ # Render content template
65
+ content = _mustache_renderer.render_name(template_name, context)
66
+
67
+ # Render layout with content
68
+ layout_context = context.copy()
69
+ layout_context["content"] = content
70
+
71
+ return _mustache_renderer.render_name("layout", layout_context)
72
+
73
+
74
+ class TemplateService:
75
+ """Service for template rendering and data formatting.
76
+
77
+ This class provides methods for formatting data objects for template rendering.
78
+ """
79
+
80
+ @staticmethod
81
+ def format_project_for_template(project: ProjectInfo, tags: list[str] | None = None) -> dict[str, Any]:
82
+ """Format a ProjectInfo for template rendering.
83
+
84
+ Args:
85
+ project: ProjectInfo object.
86
+ tags: Optional list of tags from metadata.
87
+
88
+ Returns:
89
+ Dictionary suitable for template rendering.
90
+ """
91
+ return {
92
+ "name": project.name,
93
+ "run_count": project.run_count or 0,
94
+ "last_update": int(project.last_update.timestamp() * 1000) if project.last_update else 0,
95
+ "formatted_last_update": (project.last_update.strftime("%B %d, %Y at %I:%M %p") if project.last_update else "N/A"),
96
+ "tags": tags or [],
97
+ }
98
+
99
+ @staticmethod
100
+ def format_run_for_list(run: RunInfo) -> dict[str, Any]:
101
+ """Format a RunInfo for runs list template.
102
+
103
+ Args:
104
+ run: RunInfo object.
105
+
106
+ Returns:
107
+ Dictionary suitable for runs list template rendering.
108
+ """
109
+ return {
110
+ "name": run.name,
111
+ "param_count": run.param_count or 0,
112
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
113
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
114
+ "is_corrupted": run.is_corrupted,
115
+ "error_message": run.error_message,
116
+ "tags": run.tags,
117
+ "has_tags": len(run.tags) > 0,
118
+ "is_finished": run.is_finished,
119
+ "is_wip": run.status.value == "wip",
120
+ "status": run.status.value,
121
+ }
122
+
123
+ @staticmethod
124
+ def format_run_for_project_detail(run: RunInfo) -> dict[str, Any] | None:
125
+ """Format a RunInfo for project detail template (excludes corrupted runs).
126
+
127
+ Args:
128
+ run: RunInfo object.
129
+
130
+ Returns:
131
+ Dictionary suitable for project detail template rendering,
132
+ or None if the run is corrupted.
133
+ """
134
+ if run.is_corrupted:
135
+ return None
136
+
137
+ return {
138
+ "name": run.name,
139
+ "last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
140
+ "formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
141
+ "is_finished": run.is_finished,
142
+ "is_wip": run.status.value == "wip",
143
+ "status": run.status.value,
144
+ }
145
+
146
+ @staticmethod
147
+ def format_artifact_for_template(artifact: dict[str, Any]) -> dict[str, Any]:
148
+ """Format an artifact for template rendering with category flags.
149
+
150
+ Args:
151
+ artifact: Artifact dictionary.
152
+
153
+ Returns:
154
+ Dictionary with category boolean flags added.
155
+ """
156
+ category = artifact.get("category")
157
+ return {
158
+ **artifact,
159
+ "is_code": category == "code",
160
+ "is_config": category == "config",
161
+ "is_model": category == "model",
162
+ "is_data": category == "data",
163
+ "is_other": category == "other" or category is None,
164
+ }
src/aspara/dashboard/static/css/input.css ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @source "../../templates/**/*.mustache";
3
+
4
+ @theme {
5
+ --color-action: #2C2520;
6
+ --color-action-hover: #1a1512;
7
+ --color-action-disabled: #d4cfc9;
8
+
9
+ --color-secondary: #8B7F75;
10
+ --color-secondary-hover: #6B5F55;
11
+
12
+ --color-accent: #CC785C;
13
+ --color-accent-hover: #B5654A;
14
+ --color-accent-light: #E8A892;
15
+
16
+ --color-base-bg: #F5F3F0;
17
+ --color-base-border: #E6E3E0;
18
+ --color-base-surface: #FDFCFB;
19
+
20
+ --color-text-primary: #2C2520;
21
+ --color-text-secondary: #6B5F55;
22
+ --color-text-muted: #9B8F85;
23
+
24
+ --color-status-error: #C84C3C;
25
+ --color-status-success: #5A8B6F;
26
+ --color-status-warning: #D4864E;
27
+
28
+ --font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
29
+ --font-family-mono: "JetBrains Mono", Consolas, Monaco, monospace;
30
+
31
+ --radius-button: 0.5rem;
32
+ }
33
+
34
+ /* Custom styles beyond Tailwind */
35
+ .plot-container {
36
+ height: 24rem;
37
+ background: #FDFCFB;
38
+ padding: 1rem;
39
+ border-radius: 0;
40
+ border: 1px solid #E6E3E0;
41
+ box-shadow: none;
42
+ }
43
+
44
+ .sidebar {
45
+ width: 16rem;
46
+ background: #FDFCFB;
47
+ border: 1px solid #E6E3E0;
48
+ box-shadow: none;
49
+ }
50
+
51
+ /* Sidebar animation - optimized for responsiveness */
52
+ #runs-sidebar {
53
+ transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
54
+ }
55
+
56
+ /* Accessibility: respect reduced motion preference */
57
+ @media (prefers-reduced-motion: reduce) {
58
+ #runs-sidebar {
59
+ transition: none;
60
+ }
61
+ }
62
+
63
+ .content-area {
64
+ padding: 2rem;
65
+ flex: 1;
66
+ }
67
+
68
+ /* === @jcubic/tagger Aspara Theme Override === */
69
+
70
+ /* Container - border and background only */
71
+ .tagger {
72
+ border: 1px solid var(--color-base-border);
73
+ border-radius: 0.375rem;
74
+ background: var(--color-base-surface);
75
+ }
76
+
77
+ /* Tags - color and border-radius only, keep original padding/display */
78
+ .tagger > ul > li:not(.tagger-new) > :first-child {
79
+ background: var(--color-base-bg);
80
+ border: 1px solid var(--color-base-border);
81
+ border-radius: 9999px;
82
+ /* padding is kept as original 4px 4px 4px 8px - do not override */
83
+ }
84
+
85
+ /* Tag text color */
86
+ .tagger > ul > li:not(.tagger-new) span.label {
87
+ color: var(--color-text-muted);
88
+ }
89
+
90
+ /* Close button */
91
+ .tagger li a.close {
92
+ color: var(--color-text-muted);
93
+ }
94
+ .tagger li a.close:hover {
95
+ color: var(--color-text-primary);
96
+ }
97
+
98
+ /* Input field */
99
+ .tagger .tagger-new input {
100
+ font-size: 0.75rem;
101
+ color: var(--color-text-primary);
102
+ }
103
+ .tagger .tagger-new input::placeholder {
104
+ color: var(--color-text-muted);
105
+ }
106
+
107
+ /* === Status Icon Styles (based on data-status attribute) === */
108
+
109
+ [data-status="wip"] {
110
+ @apply animate-pulse text-status-warning;
111
+ }
112
+
113
+ [data-status="completed"] {
114
+ @apply text-status-success;
115
+ }
116
+
117
+ [data-status="failed"] {
118
+ @apply text-status-error;
119
+ }
120
+
121
+ [data-status="maybe_failed"] {
122
+ @apply text-status-warning;
123
+ }
124
+
125
+ /* === Note Editor Cursor Styles === */
126
+
127
+ /* Placeholder (Add note...) cursor */
128
+ .note-content .text-text-muted.italic {
129
+ cursor: pointer;
130
+ }
131
+
132
+ /* Edit link cursor */
133
+ .note-edit-btn {
134
+ cursor: pointer;
135
+ }
136
+
137
+ /* === Dialog Styles === */
138
+
139
+ dialog.delete-dialog {
140
+ position: fixed;
141
+ top: 50%;
142
+ left: 50%;
143
+ transform: translate(-50%, -50%);
144
+ margin: 0;
145
+ border: 1px solid var(--color-base-border);
146
+ border-radius: 0.5rem;
147
+ background: var(--color-base-surface);
148
+ box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
149
+ max-width: 28rem;
150
+ width: calc(100% - 2rem);
151
+ }
152
+
153
+ dialog.delete-dialog::backdrop {
154
+ background: rgb(0 0 0 / 0.5);
155
+ }
156
+
157
+ /* === Card Interactive Styles === */
158
+
159
+ /* Common card styles */
160
+ .card-interactive {
161
+ @apply transition-colors duration-150 outline-none;
162
+ @apply hover:border-accent;
163
+ @apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2;
164
+ }
165
+
166
+ /* Reduced motion support */
167
+ @media (prefers-reduced-motion: reduce) {
168
+ .card-interactive {
169
+ @apply transition-none;
170
+ }
171
+ }
src/aspara/dashboard/static/css/tagger.css ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**@license
2
+ * _____
3
+ * |_ _|___ ___ ___ ___ ___
4
+ * | | | .'| . | . | -_| _|
5
+ * |_| |__,|_ |_ |___|_|
6
+ * |___|___| version 0.6.2
7
+ *
8
+ * Tagger - Zero dependency, Vanilla JavaScript Tag Editor
9
+ *
10
+ * Copyright (c) 2018-2024 Jakub T. Jankiewicz <https://jcubic.pl/me>
11
+ * Released under the MIT license
12
+ */
13
+ /* Border/background defined in input.css */
14
+ .tagger input[type="hidden"] {
15
+ /* fix for bootstrap */
16
+ display: none;
17
+ }
18
+ .tagger > ul {
19
+ display: flex;
20
+ width: 100%;
21
+ align-items: center;
22
+ padding: 4px 5px 0;
23
+ justify-content: space-between;
24
+ box-sizing: border-box;
25
+ height: auto;
26
+ flex: 0 0 auto;
27
+ overflow-y: auto;
28
+ margin: 0;
29
+ list-style: none;
30
+ }
31
+ .tagger > ul > li {
32
+ padding-bottom: 0.4rem;
33
+ margin: 0.4rem 5px 4px;
34
+ }
35
+ .tagger > ul > li:not(.tagger-new) a,
36
+ .tagger > ul > li:not(.tagger-new) a:visited {
37
+ text-decoration: none;
38
+ /* color defined in input.css */
39
+ }
40
+ .tagger > ul > li:not(.tagger-new) > :first-child {
41
+ padding: 4px 4px 4px 8px;
42
+ /* background, border, border-radius defined in input.css */
43
+ }
44
+ .tagger > ul > li:not(.tagger-new) > span,
45
+ .tagger > ul > li:not(.tagger-new) > a > span {
46
+ white-space: nowrap;
47
+ }
48
+ .tagger li a.close {
49
+ padding: 4px;
50
+ margin-left: 4px;
51
+ /* for bootstrap */
52
+ float: none;
53
+ filter: alpha(opacity=100);
54
+ opacity: 1;
55
+ font-size: 16px;
56
+ line-height: 16px;
57
+ }
58
+ .tagger li a.close:hover {
59
+ /* color defined in input.css */
60
+ }
61
+ .tagger .tagger-new input {
62
+ border: none;
63
+ outline: none;
64
+ box-shadow: none;
65
+ width: 100%;
66
+ padding-left: 0;
67
+ box-sizing: border-box;
68
+ background: transparent;
69
+ }
70
+ .tagger .tagger-new {
71
+ flex-grow: 1;
72
+ position: relative;
73
+ min-width: 40px;
74
+ width: 1px;
75
+ }
76
+ .tagger.wrap > ul {
77
+ flex-wrap: wrap;
78
+ justify-content: start;
79
+ }
src/aspara/dashboard/static/favicon.ico ADDED
src/aspara/dashboard/static/images/aspara-icon.png ADDED
src/aspara/dashboard/static/js/api/delete-api.js ADDED
@@ -0,0 +1,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,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Canvas-based chart component for metrics visualization.
3
+ * Supports multiple series, zoom, hover tooltips, and data export.
4
+ */
5
+ import { ChartColorPalette } from './chart/color-palette.js';
6
+ import { ChartControls } from './chart/controls.js';
7
+ import { ChartExport } from './chart/export.js';
8
+ import { ChartInteraction } from './chart/interaction.js';
9
+ import { ChartRenderer } from './chart/renderer.js';
10
+
11
+ export class Chart {
12
+ // Chart layout constants
13
+ static MARGIN = 60;
14
+ static CANVAS_SCALE_FACTOR = 1.5; // Reduced from 2.5 for better performance
15
+ static SIZE_UPDATE_RETRY_DELAY_MS = 100;
16
+ static FULLSCREEN_UPDATE_DELAY_MS = 100;
17
+ static MIN_DRAG_DISTANCE = 10;
18
+
19
+ // Grid constants
20
+ static X_GRID_COUNT = 10;
21
+ static Y_GRID_COUNT = 8;
22
+ static Y_PADDING_RATIO = 0.1;
23
+
24
+ // Style constants
25
+ static LINE_WIDTH = 1.5; // Normal view
26
+ static LINE_WIDTH_FULLSCREEN = 2.5; // Fullscreen view
27
+ static GRID_LINE_WIDTH = 0.5;
28
+ static LEGEND_ITEM_SPACING = 16;
29
+ static LEGEND_LINE_LENGTH = 16;
30
+ static LEGEND_TEXT_OFFSET = 4;
31
+ static LEGEND_Y_OFFSET = 30;
32
+
33
+ // Animation constants
34
+ static ANIMATION_PULSE_DURATION_MS = 1000;
35
+
36
+ constructor(containerId, options = {}) {
37
+ this.container = document.querySelector(containerId);
38
+ if (!this.container) {
39
+ throw new Error(`Container ${containerId} not found`);
40
+ }
41
+
42
+ this.data = null;
43
+ this.width = 0;
44
+ this.height = 0;
45
+ this.onZoomChange = options.onZoomChange || null;
46
+
47
+ // Color palette for managing series styles
48
+ this.colorPalette = new ChartColorPalette();
49
+
50
+ // Initialize modules
51
+ this.renderer = new ChartRenderer(this);
52
+ this.chartExport = new ChartExport(this);
53
+ this.interaction = new ChartInteraction(this, this.renderer);
54
+ this.controls = new ChartControls(this, this.chartExport);
55
+
56
+ this.hoverPoint = null;
57
+
58
+ this.zoomState = {
59
+ active: false,
60
+ startX: null,
61
+ startY: null,
62
+ currentX: null,
63
+ currentY: null,
64
+ };
65
+ this.zoom = { x: null, y: null };
66
+
67
+ // Fullscreen event handler (stored for cleanup)
68
+ this.fullscreenChangeHandler = null;
69
+
70
+ // Data range cache for performance optimization
71
+ this._cachedDataRanges = null;
72
+ this._lastDataRef = null;
73
+
74
+ this.init();
75
+ }
76
+
77
+ init() {
78
+ this.container.innerHTML = '';
79
+
80
+ this.canvas = document.createElement('canvas');
81
+ this.canvas.style.border = '1px solid #e5e7eb';
82
+ this.canvas.style.display = 'block';
83
+ this.canvas.style.maxWidth = '100%';
84
+
85
+ this.container.appendChild(this.canvas);
86
+ this.ctx = this.canvas.getContext('2d');
87
+
88
+ this.ctx.imageSmoothingEnabled = true;
89
+ this.ctx.imageSmoothingQuality = 'high';
90
+
91
+ // For throttling draw calls
92
+ this.pendingDraw = false;
93
+
94
+ this.updateSize();
95
+ this.interaction.setupEventListeners();
96
+ this.setupFullscreenListener();
97
+ this.controls.create();
98
+ }
99
+
100
+ updateSize() {
101
+ // Use clientWidth/clientHeight to get size excluding border
102
+ const rect = this.container.getBoundingClientRect?.();
103
+ const width = this.container.clientWidth || rect?.width || 0;
104
+ const height = this.container.clientHeight || rect?.height || 0;
105
+
106
+ // Retry later if container is not yet visible
107
+ if (width === 0 || height === 0) {
108
+ // Avoid infinite retry loops - only retry if we haven't set a size yet
109
+ if (this.width === 0 && this.height === 0) {
110
+ setTimeout(() => this.updateSize(), Chart.SIZE_UPDATE_RETRY_DELAY_MS);
111
+ }
112
+ return;
113
+ }
114
+
115
+ this.width = width;
116
+ this.height = height;
117
+
118
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
119
+
120
+ const dpr = window.devicePixelRatio || 1;
121
+ const totalScale = dpr * Chart.CANVAS_SCALE_FACTOR;
122
+
123
+ // Set internal canvas resolution (high-DPI)
124
+ this.canvas.width = this.width * totalScale;
125
+ this.canvas.height = this.height * totalScale;
126
+
127
+ // Set CSS display size to exact pixel values (matching internal aspect ratio)
128
+ this.canvas.style.width = `${this.width}px`;
129
+ this.canvas.style.height = `${this.height}px`;
130
+ this.canvas.style.display = 'block';
131
+
132
+ this.ctx.scale(totalScale, totalScale);
133
+
134
+ // Redraw if data is already set
135
+ if (this.data) {
136
+ this.draw();
137
+ }
138
+ }
139
+
140
+ setData(data) {
141
+ this.data = data;
142
+ // Invalidate data range cache when data changes
143
+ this._cachedDataRanges = null;
144
+ this._lastDataRef = null;
145
+ if (data?.series) {
146
+ this.colorPalette.ensureRunStyles(data.series.map((s) => s.name));
147
+ }
148
+ this.draw();
149
+ }
150
+
151
+ /**
152
+ * Get cached data ranges, recalculating only when data changes.
153
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
154
+ */
155
+ _getDataRanges() {
156
+ // Check if data reference changed
157
+ if (this.data?.series !== this._lastDataRef) {
158
+ this._lastDataRef = this.data?.series;
159
+ this._cachedDataRanges = this._calculateDataRanges();
160
+ }
161
+ return this._cachedDataRanges;
162
+ }
163
+
164
+ /**
165
+ * Calculate data ranges from all series.
166
+ * @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
167
+ */
168
+ _calculateDataRanges() {
169
+ if (!this.data?.series?.length) return null;
170
+
171
+ let xMin = Number.POSITIVE_INFINITY;
172
+ let xMax = Number.NEGATIVE_INFINITY;
173
+ let yMin = Number.POSITIVE_INFINITY;
174
+ let yMax = Number.NEGATIVE_INFINITY;
175
+
176
+ for (const series of this.data.series) {
177
+ if (!series.data?.steps?.length) continue;
178
+ const { steps, values } = series.data;
179
+
180
+ // steps are sorted, so O(1) for min/max
181
+ xMin = Math.min(xMin, steps[0]);
182
+ xMax = Math.max(xMax, steps[steps.length - 1]);
183
+
184
+ // values min/max
185
+ for (let i = 0; i < values.length; i++) {
186
+ if (values[i] < yMin) yMin = values[i];
187
+ if (values[i] > yMax) yMax = values[i];
188
+ }
189
+ }
190
+
191
+ if (xMin === Number.POSITIVE_INFINITY) return null;
192
+
193
+ return { xMin, xMax, yMin, yMax };
194
+ }
195
+
196
+ draw() {
197
+ // Skip drawing if canvas size is not yet initialized
198
+ if (this.width === 0 || this.height === 0) {
199
+ return;
200
+ }
201
+
202
+ this.ctx.fillStyle = 'white';
203
+ this.ctx.fillRect(0, 0, this.width, this.height);
204
+
205
+ if (!this.data) {
206
+ console.warn('Chart.draw(): No data set');
207
+ return;
208
+ }
209
+
210
+ if (!this.data.series || !Array.isArray(this.data.series)) {
211
+ console.error('Chart.draw(): Invalid data format - series must be an array');
212
+ return;
213
+ }
214
+
215
+ if (this.data.series.length === 0) {
216
+ console.warn('Chart.draw(): Empty series array');
217
+ return;
218
+ }
219
+
220
+ const margin = Chart.MARGIN;
221
+ const plotWidth = this.width - margin * 2;
222
+ const plotHeight = this.height - margin * 2;
223
+
224
+ // Use cached data ranges for performance
225
+ const ranges = this._getDataRanges();
226
+ if (!ranges) {
227
+ console.warn('Chart.draw(): No valid data points in series');
228
+ return;
229
+ }
230
+
231
+ let { xMin, xMax, yMin, yMax } = ranges;
232
+
233
+ if (this.zoom.x) {
234
+ xMin = this.zoom.x.min;
235
+ xMax = this.zoom.x.max;
236
+ }
237
+ if (this.zoom.y) {
238
+ yMin = this.zoom.y.min;
239
+ yMax = this.zoom.y.max;
240
+ }
241
+
242
+ const yRange = yMax - yMin;
243
+ const yMinPadded = yMin - yRange * Chart.Y_PADDING_RATIO;
244
+ const yMaxPadded = yMax + yRange * Chart.Y_PADDING_RATIO;
245
+
246
+ this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
247
+ this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
248
+
249
+ // Clip to plot area
250
+ this.ctx.save();
251
+ this.ctx.beginPath();
252
+ this.ctx.rect(margin, margin, plotWidth, plotHeight);
253
+ this.ctx.clip();
254
+
255
+ for (const series of this.data.series) {
256
+ if (!series.data?.steps?.length) continue;
257
+ const { steps, values } = series.data;
258
+
259
+ const style = this.colorPalette.getRunStyle(series.name);
260
+
261
+ this.ctx.strokeStyle = style.borderColor;
262
+ this.ctx.lineWidth = this.getLineWidth();
263
+ this.ctx.lineCap = 'round';
264
+ this.ctx.lineJoin = 'round';
265
+
266
+ // Apply border dash pattern
267
+ if (style.borderDash && style.borderDash.length > 0) {
268
+ this.ctx.setLineDash(style.borderDash);
269
+ } else {
270
+ this.ctx.setLineDash([]);
271
+ }
272
+
273
+ this.ctx.beginPath();
274
+
275
+ for (let i = 0; i < steps.length; i++) {
276
+ const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth;
277
+ const y = margin + plotHeight - ((values[i] - yMinPadded) / (yMaxPadded - yMinPadded)) * plotHeight;
278
+
279
+ if (i === 0) {
280
+ this.ctx.moveTo(x, y);
281
+ } else {
282
+ this.ctx.lineTo(x, y);
283
+ }
284
+ }
285
+
286
+ this.ctx.stroke();
287
+ this.ctx.setLineDash([]); // Reset dash pattern
288
+ }
289
+
290
+ this.ctx.restore();
291
+ this.renderer.drawLegend();
292
+ this.interaction.drawHoverEffects();
293
+ this.interaction.drawZoomSelection();
294
+ }
295
+
296
+ getLineWidth() {
297
+ if (document.fullscreenElement === this.container) {
298
+ return Chart.LINE_WIDTH_FULLSCREEN;
299
+ }
300
+ return Chart.LINE_WIDTH;
301
+ }
302
+
303
+ getRunStyle(seriesName) {
304
+ return this.colorPalette.getRunStyle(seriesName);
305
+ }
306
+
307
+ setupFullscreenListener() {
308
+ // Store handler for cleanup
309
+ this.fullscreenChangeHandler = () => {
310
+ setTimeout(() => {
311
+ this.updateSize();
312
+ }, Chart.FULLSCREEN_UPDATE_DELAY_MS);
313
+ };
314
+ document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
315
+ }
316
+
317
+ resetZoom() {
318
+ this.zoom.x = null;
319
+ this.zoom.y = null;
320
+ this.draw();
321
+ }
322
+
323
+ setExternalZoom(zoomState) {
324
+ if (zoomState?.x) {
325
+ this.zoom.x = { ...zoomState.x };
326
+ this.draw();
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Add a new data point to an existing series (SoA format)
332
+ * @param {string} runName - Name of the run
333
+ * @param {number} step - Step number
334
+ * @param {number} value - Metric value
335
+ */
336
+ addDataPoint(runName, step, value) {
337
+ console.log(`[Chart] addDataPoint called: run=${runName}, step=${step}, value=${value}`);
338
+
339
+ if (!this.data || !this.data.series) {
340
+ console.warn('[Chart] No data or series available');
341
+ return;
342
+ }
343
+
344
+ // Find the series for this run
345
+ let series = this.data.series.find((s) => s.name === runName);
346
+
347
+ if (!series) {
348
+ // Create new series if it doesn't exist (SoA format)
349
+ series = {
350
+ name: runName,
351
+ data: { steps: [], values: [] },
352
+ };
353
+ this.data.series.push(series);
354
+ }
355
+
356
+ const { steps, values } = series.data;
357
+
358
+ // Binary search to find insertion position
359
+ let left = 0;
360
+ let right = steps.length;
361
+ while (left < right) {
362
+ const mid = (left + right) >> 1;
363
+ if (steps[mid] < step) {
364
+ left = mid + 1;
365
+ } else if (steps[mid] > step) {
366
+ right = mid;
367
+ } else {
368
+ // Exact match - update existing value
369
+ values[mid] = value;
370
+ this.scheduleDraw();
371
+ return;
372
+ }
373
+ }
374
+
375
+ // Insert at the found position (usually at the end, so O(1) in practice)
376
+ steps.splice(left, 0, step);
377
+ values.splice(left, 0, value);
378
+
379
+ // Invalidate data range cache when data changes
380
+ this._cachedDataRanges = null;
381
+
382
+ // Schedule redraw using requestAnimationFrame to throttle updates
383
+ this.scheduleDraw();
384
+ }
385
+
386
+ /**
387
+ * Schedule a draw operation using requestAnimationFrame
388
+ * This prevents excessive redraws when multiple data points arrive rapidly
389
+ */
390
+ scheduleDraw() {
391
+ console.log('[Chart] scheduleDraw called, pendingDraw:', this.pendingDraw);
392
+
393
+ if (this.pendingDraw) {
394
+ return; // Draw already scheduled
395
+ }
396
+
397
+ this.pendingDraw = true;
398
+ requestAnimationFrame(() => {
399
+ console.log('[Chart] requestAnimationFrame callback executing');
400
+ this.pendingDraw = false;
401
+ this.draw();
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Clean up event listeners and resources.
407
+ */
408
+ destroy() {
409
+ if (this.fullscreenChangeHandler) {
410
+ document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
411
+ this.fullscreenChangeHandler = null;
412
+ }
413
+ if (this.interaction) {
414
+ this.interaction.removeEventListeners();
415
+ }
416
+ if (this.controls) {
417
+ this.controls.destroy();
418
+ }
419
+ }
420
+ }
src/aspara/dashboard/static/js/chart/color-palette.js ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChartColorPalette - Color management for chart series
3
+ * Handles color generation, style assignment, and run-to-style mapping
4
+ */
5
+ export class ChartColorPalette {
6
+ constructor() {
7
+ // Modern 16-color base palette with well-distributed hues for easy differentiation
8
+ // Colors are arranged by hue (0-360°) with ~22.5° spacing for maximum visual distinction
9
+ // Red-family colors and dark blues have varied saturation for better distinction
10
+ this.baseColors = [
11
+ '#FF3B47', // red (0°) - high saturation
12
+ '#F77F00', // orange (30°)
13
+ '#FCBF49', // yellow (45°)
14
+ '#06D6A0', // mint/turquoise (165°)
15
+ '#118AB2', // blue (195°)
16
+ '#69808b', // dark blue (200°) - higher saturation, more vivid
17
+ '#4361EE', // bright blue (225°)
18
+ '#7209B7', // purple (270°)
19
+ '#E85D9A', // magenta (330°) - medium saturation, lighter
20
+ '#B8252D', // crimson (355°) - lower saturation, darker
21
+ '#F4A261', // peach (35°)
22
+ '#2A9D8F', // teal (170°)
23
+ '#408828', // dark teal (190°) - lower saturation, more muted
24
+ '#3A86FF', // sky blue (215°)
25
+ '#8338EC', // violet (265°)
26
+ '#FF1F7D', // hot pink (340°) - very high saturation
27
+ ];
28
+
29
+ // Border dash patterns for additional differentiation
30
+ this.borderDashPatterns = [
31
+ [], // solid
32
+ [6, 4], // dashed
33
+ [2, 3], // dotted
34
+ [10, 3, 2, 3], // dash-dot
35
+ ];
36
+
37
+ // Registry to maintain stable run->style mapping
38
+ this.runStyleRegistry = new Map();
39
+ this.nextStyleIndex = 0;
40
+ }
41
+
42
+ /**
43
+ * Convert hex color to RGB
44
+ * @param {string} hex - Hex color string
45
+ * @returns {Object|null} RGB object or null if invalid
46
+ */
47
+ hexToRgb(hex) {
48
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
49
+ return result
50
+ ? {
51
+ r: Number.parseInt(result[1], 16),
52
+ g: Number.parseInt(result[2], 16),
53
+ b: Number.parseInt(result[3], 16),
54
+ }
55
+ : null;
56
+ }
57
+
58
+ /**
59
+ * Convert RGB to HSL
60
+ * @param {number} r - Red (0-255)
61
+ * @param {number} g - Green (0-255)
62
+ * @param {number} b - Blue (0-255)
63
+ * @returns {Object} HSL object
64
+ */
65
+ rgbToHsl(r, g, b) {
66
+ const rNorm = r / 255;
67
+ const gNorm = g / 255;
68
+ const bNorm = b / 255;
69
+
70
+ const max = Math.max(rNorm, gNorm, bNorm);
71
+ const min = Math.min(rNorm, gNorm, bNorm);
72
+ let h;
73
+ let s;
74
+ const l = (max + min) / 2;
75
+
76
+ if (max === min) {
77
+ h = 0;
78
+ s = 0;
79
+ } else {
80
+ const d = max - min;
81
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
82
+
83
+ switch (max) {
84
+ case rNorm:
85
+ h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
86
+ break;
87
+ case gNorm:
88
+ h = ((bNorm - rNorm) / d + 2) / 6;
89
+ break;
90
+ case bNorm:
91
+ h = ((rNorm - gNorm) / d + 4) / 6;
92
+ break;
93
+ }
94
+ }
95
+
96
+ return { h: h * 360, s: s * 100, l: l * 100 };
97
+ }
98
+
99
+ /**
100
+ * Apply variant transformation to HSL color
101
+ * @param {Object} hsl - HSL color object
102
+ * @param {number} variantIndex - Variant index (0-2)
103
+ * @returns {Object} Modified HSL object
104
+ */
105
+ applyVariant(hsl, variantIndex) {
106
+ const variants = [
107
+ { sDelta: 0, lDelta: 0 }, // normal
108
+ { sDelta: -15, lDelta: -6 }, // muted
109
+ { sDelta: 8, lDelta: 6 }, // bright
110
+ ];
111
+
112
+ const variant = variants[variantIndex];
113
+ let s = hsl.s + variant.sDelta;
114
+ let l = hsl.l + variant.lDelta;
115
+
116
+ // Clamp to safe ranges
117
+ s = Math.max(35, Math.min(95, s));
118
+ l = Math.max(30, Math.min(70, l));
119
+
120
+ return { h: hsl.h, s, l };
121
+ }
122
+
123
+ /**
124
+ * Convert HSL to CSS string
125
+ * @param {Object} hsl - HSL color object
126
+ * @returns {string} CSS HSL string
127
+ */
128
+ hslToString(hsl) {
129
+ return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`;
130
+ }
131
+
132
+ /**
133
+ * Generate style for a given style index
134
+ * @param {number} styleIndex - Style index
135
+ * @returns {Object} Style object with borderColor, backgroundColor, borderDash
136
+ */
137
+ generateStyle(styleIndex) {
138
+ const M = this.baseColors.length; // 16
139
+ const V = 3; // variants
140
+ const D = this.borderDashPatterns.length; // 4
141
+
142
+ const baseIndex = styleIndex % M;
143
+ const variantIndex = Math.floor(styleIndex / M) % V;
144
+ const dashIndex = Math.floor(styleIndex / (M * V)) % D;
145
+
146
+ // Get base color and convert to HSL
147
+ const hex = this.baseColors[baseIndex];
148
+ const rgb = this.hexToRgb(hex);
149
+ const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b);
150
+
151
+ // Apply variant
152
+ const variantHsl = this.applyVariant(hsl, variantIndex);
153
+ const borderColor = this.hslToString(variantHsl);
154
+
155
+ // Get border dash pattern
156
+ const borderDash = this.borderDashPatterns[dashIndex];
157
+
158
+ return {
159
+ borderColor,
160
+ backgroundColor: borderColor,
161
+ borderDash,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Ensure all runs have stable styles assigned
167
+ * @param {Array<string>} runIds - Array of run IDs
168
+ */
169
+ ensureRunStyles(runIds) {
170
+ // Sort run IDs for stable ordering
171
+ const sortedRunIds = [...new Set(runIds)].sort();
172
+
173
+ for (const runId of sortedRunIds) {
174
+ if (!this.runStyleRegistry.has(runId)) {
175
+ const style = this.generateStyle(this.nextStyleIndex);
176
+ this.runStyleRegistry.set(runId, style);
177
+ this.nextStyleIndex++;
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get style for a specific run
184
+ * @param {string} runId - Run ID
185
+ * @returns {Object} Style object
186
+ */
187
+ getRunStyle(runId) {
188
+ return this.runStyleRegistry.get(runId) || this.generateStyle(0);
189
+ }
190
+
191
+ /**
192
+ * Reset the style registry
193
+ */
194
+ reset() {
195
+ this.runStyleRegistry.clear();
196
+ this.nextStyleIndex = 0;
197
+ }
198
+ }
src/aspara/dashboard/static/js/chart/controls.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_RESET_ZOOM } from '../html-utils.js';
2
+
3
+ export class ChartControls {
4
+ constructor(chart, chartExport) {
5
+ this.chart = chart;
6
+ this.chartExport = chartExport;
7
+ this.buttonContainer = null;
8
+ this.resetButton = null;
9
+ this.fullSizeButton = null;
10
+ this.downloadButton = null;
11
+ this.downloadMenu = null;
12
+
13
+ // Document click handler (stored for cleanup)
14
+ this.documentClickHandler = null;
15
+ }
16
+
17
+ create() {
18
+ this.chart.container.style.position = 'relative';
19
+
20
+ this.buttonContainer = document.createElement('div');
21
+ this.buttonContainer.style.cssText = `
22
+ position: absolute;
23
+ top: 10px;
24
+ right: 10px;
25
+ display: flex;
26
+ gap: 8px;
27
+ z-index: 10;
28
+ `;
29
+
30
+ this.createResetButton();
31
+ this.createFullSizeButton();
32
+ this.createDownloadButton();
33
+
34
+ this.buttonContainer.appendChild(this.resetButton);
35
+ this.buttonContainer.appendChild(this.fullSizeButton);
36
+ this.buttonContainer.appendChild(this.downloadButton);
37
+ this.chart.container.appendChild(this.buttonContainer);
38
+ }
39
+
40
+ createResetButton() {
41
+ this.resetButton = document.createElement('button');
42
+ this.resetButton.innerHTML = ICON_RESET_ZOOM;
43
+ this.resetButton.title = 'Reset zoom';
44
+ this.resetButton.style.cssText = `
45
+ width: 32px;
46
+ height: 32px;
47
+ border: 1px solid #ddd;
48
+ background: white;
49
+ cursor: pointer;
50
+ border-radius: 6px;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ color: #555;
55
+ `;
56
+
57
+ this.attachButtonHover(this.resetButton);
58
+ this.resetButton.addEventListener('click', () => this.chart.resetZoom());
59
+ }
60
+
61
+ createFullSizeButton() {
62
+ this.fullSizeButton = document.createElement('button');
63
+ this.fullSizeButton.innerHTML = ICON_FULLSCREEN;
64
+ this.fullSizeButton.title = 'Fit to full size';
65
+ this.fullSizeButton.style.cssText = `
66
+ width: 32px;
67
+ height: 32px;
68
+ border: 1px solid #ddd;
69
+ background: white;
70
+ cursor: pointer;
71
+ border-radius: 6px;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ color: #555;
76
+ `;
77
+
78
+ this.attachButtonHover(this.fullSizeButton);
79
+ this.fullSizeButton.addEventListener('click', () => this.fitToFullSize());
80
+ }
81
+
82
+ createDownloadButton() {
83
+ this.downloadButton = document.createElement('button');
84
+ this.downloadButton.innerHTML = ICON_DOWNLOAD;
85
+ this.downloadButton.title = 'Download data';
86
+ this.downloadButton.style.cssText = `
87
+ width: 32px;
88
+ height: 32px;
89
+ border: 1px solid #ddd;
90
+ background: white;
91
+ cursor: pointer;
92
+ border-radius: 6px;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ color: #555;
97
+ position: relative;
98
+ `;
99
+
100
+ this.downloadMenu = document.createElement('div');
101
+ this.downloadMenu.style.cssText = `
102
+ position: absolute;
103
+ top: 100%;
104
+ right: 0;
105
+ background: white;
106
+ border: 1px solid #ddd;
107
+ border-radius: 6px;
108
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
109
+ display: none;
110
+ flex-direction: column;
111
+ width: 120px;
112
+ z-index: 20;
113
+ `;
114
+
115
+ const downloadOptions = [
116
+ { format: 'CSV', label: 'CSV format' },
117
+ { format: 'SVG', label: 'SVG image' },
118
+ { format: 'PNG', label: 'PNG image' },
119
+ ];
120
+
121
+ for (const option of downloadOptions) {
122
+ const menuItem = document.createElement('button');
123
+ menuItem.textContent = option.label;
124
+ menuItem.style.cssText = `
125
+ padding: 8px 12px;
126
+ text-align: left;
127
+ background: none;
128
+ border: none;
129
+ cursor: pointer;
130
+ font-size: 13px;
131
+ color: #333;
132
+ `;
133
+ menuItem.addEventListener('mouseenter', () => {
134
+ menuItem.style.background = '#f5f5f5';
135
+ });
136
+ menuItem.addEventListener('mouseleave', () => {
137
+ menuItem.style.background = 'none';
138
+ });
139
+ menuItem.addEventListener('click', (e) => {
140
+ e.stopPropagation();
141
+ this.chartExport.downloadData(option.format);
142
+ this.toggleDownloadMenu(false);
143
+ });
144
+ this.downloadMenu.appendChild(menuItem);
145
+ }
146
+
147
+ this.downloadButton.appendChild(this.downloadMenu);
148
+
149
+ this.attachButtonHover(this.downloadButton);
150
+ this.downloadButton.addEventListener('click', () => this.toggleDownloadMenu());
151
+
152
+ // Store handler for cleanup
153
+ this.documentClickHandler = (e) => {
154
+ if (!this.downloadButton.contains(e.target)) {
155
+ this.toggleDownloadMenu(false);
156
+ }
157
+ };
158
+ document.addEventListener('click', this.documentClickHandler);
159
+ }
160
+
161
+ fitToFullSize() {
162
+ if (!document.fullscreenElement) {
163
+ if (this.chart.container.requestFullscreen) {
164
+ this.chart.container.requestFullscreen();
165
+ } else if (this.chart.container.webkitRequestFullscreen) {
166
+ this.chart.container.webkitRequestFullscreen();
167
+ } else if (this.chart.container.mozRequestFullScreen) {
168
+ this.chart.container.mozRequestFullScreen();
169
+ } else if (this.chart.container.msRequestFullscreen) {
170
+ this.chart.container.msRequestFullscreen();
171
+ }
172
+ } else {
173
+ if (document.exitFullscreen) {
174
+ document.exitFullscreen();
175
+ } else if (document.webkitExitFullscreen) {
176
+ document.webkitExitFullscreen();
177
+ } else if (document.mozCancelFullScreen) {
178
+ document.mozCancelFullScreen();
179
+ } else if (document.msExitFullscreen) {
180
+ document.msExitFullscreen();
181
+ }
182
+ }
183
+ }
184
+
185
+ toggleDownloadMenu(forceState) {
186
+ const isVisible = this.downloadMenu.style.display === 'flex';
187
+ const newState = forceState !== undefined ? forceState : !isVisible;
188
+
189
+ this.downloadMenu.style.display = newState ? 'flex' : 'none';
190
+ }
191
+
192
+ /**
193
+ * Attach hover effect to a button element
194
+ * @param {HTMLButtonElement} button - Button element to attach hover effect
195
+ */
196
+ attachButtonHover(button) {
197
+ button.addEventListener('mouseenter', () => {
198
+ button.style.background = '#f5f5f5';
199
+ button.style.borderColor = '#bbb';
200
+ });
201
+ button.addEventListener('mouseleave', () => {
202
+ button.style.background = 'white';
203
+ button.style.borderColor = '#ddd';
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Clean up event listeners and remove DOM elements.
209
+ */
210
+ destroy() {
211
+ if (this.documentClickHandler) {
212
+ document.removeEventListener('click', this.documentClickHandler);
213
+ this.documentClickHandler = null;
214
+ }
215
+ if (this.buttonContainer?.parentNode) {
216
+ this.buttonContainer.parentNode.removeChild(this.buttonContainer);
217
+ }
218
+ this.buttonContainer = null;
219
+ this.resetButton = null;
220
+ this.fullSizeButton = null;
221
+ this.downloadButton = null;
222
+ this.downloadMenu = null;
223
+ }
224
+ }
src/aspara/dashboard/static/js/chart/export-utils.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure utility functions for chart export
3
+ * These functions have no side effects and are easy to test
4
+ */
5
+
6
+ /**
7
+ * Generate CSV content from series data (SoA format)
8
+ * @param {Array} series - Array of series objects with name and data in SoA format
9
+ * @returns {string} CSV formatted string
10
+ */
11
+ export function generateCSVContent(series) {
12
+ const lines = ['series,step,value'];
13
+
14
+ for (const s of series) {
15
+ if (!s.data?.steps?.length) continue;
16
+ const { steps, values } = s.data;
17
+
18
+ const seriesName = s.name.replace(/"/g, '""');
19
+
20
+ for (let i = 0; i < steps.length; i++) {
21
+ lines.push(`"${seriesName}",${steps[i]},${values[i]}`);
22
+ }
23
+ }
24
+
25
+ return `${lines.join('\n')}\n`;
26
+ }
27
+
28
+ /**
29
+ * Sanitize a string for use as a filename
30
+ * @param {string} name - Original name
31
+ * @returns {string} Sanitized filename
32
+ */
33
+ export function sanitizeFileName(name) {
34
+ return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
35
+ }
36
+
37
+ /**
38
+ * Get export filename from chart data
39
+ * @param {Object} data - Chart data object with optional title and series
40
+ * @returns {string} Filename without extension
41
+ */
42
+ export function getExportFileName(data) {
43
+ if (data.title) {
44
+ return sanitizeFileName(data.title);
45
+ }
46
+ if (data.series && data.series.length === 1) {
47
+ return sanitizeFileName(data.series[0].name);
48
+ }
49
+ return 'chart';
50
+ }
51
+
52
+ /**
53
+ * Calculate dimensions for zoomed/unzoomed export
54
+ * @param {Object} chart - Chart object with zoom, width, height, and MARGIN
55
+ * @returns {Object} Dimensions info including useZoomedArea, margin, plotWidth, plotHeight
56
+ */
57
+ export function calculateExportDimensions(chart) {
58
+ const useZoomedArea = chart.zoom.x !== null || chart.zoom.y !== null;
59
+ const margin = chart.constructor.MARGIN;
60
+ const plotWidth = chart.width - margin * 2;
61
+ const plotHeight = chart.height - margin * 2;
62
+
63
+ return { useZoomedArea, margin, plotWidth, plotHeight };
64
+ }
65
+
66
+ /**
67
+ * Build filename with optional zoom suffix
68
+ * @param {string} baseName - Base filename
69
+ * @param {boolean} isZoomed - Whether to add zoomed suffix
70
+ * @returns {string} Final filename
71
+ */
72
+ export function buildExportFileName(baseName, isZoomed) {
73
+ return isZoomed ? `${baseName}_zoomed` : baseName;
74
+ }
src/aspara/dashboard/static/js/chart/export.js ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { buildExportFileName, calculateExportDimensions, generateCSVContent, getExportFileName } from './export-utils.js';
2
+
3
+ export class ChartExport {
4
+ constructor(chart) {
5
+ this.chart = chart;
6
+ }
7
+
8
+ downloadData(format) {
9
+ if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) {
10
+ return;
11
+ }
12
+
13
+ switch (format) {
14
+ case 'CSV':
15
+ this.downloadCSV();
16
+ break;
17
+ case 'SVG':
18
+ this.downloadSVG();
19
+ break;
20
+ case 'PNG':
21
+ this.downloadPNG();
22
+ break;
23
+ }
24
+ }
25
+
26
+ downloadCSV() {
27
+ const csvContent = generateCSVContent(this.chart.data.series);
28
+
29
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
30
+ const url = URL.createObjectURL(blob);
31
+ const link = document.createElement('a');
32
+
33
+ const fileName = getExportFileName(this.chart.data);
34
+
35
+ link.setAttribute('href', url);
36
+ link.setAttribute('download', `${fileName}.csv`);
37
+ link.style.display = 'none';
38
+
39
+ document.body.appendChild(link);
40
+ link.click();
41
+ document.body.removeChild(link);
42
+ URL.revokeObjectURL(url);
43
+ }
44
+
45
+ downloadSVG() {
46
+ const svgNamespace = 'http://www.w3.org/2000/svg';
47
+ const svg = document.createElementNS(svgNamespace, 'svg');
48
+
49
+ const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
50
+
51
+ if (useZoomedArea) {
52
+ svg.setAttribute('width', plotWidth);
53
+ svg.setAttribute('height', plotHeight);
54
+ svg.setAttribute('viewBox', `0 0 ${plotWidth} ${plotHeight}`);
55
+
56
+ const background = document.createElementNS(svgNamespace, 'rect');
57
+ background.setAttribute('width', plotWidth);
58
+ background.setAttribute('height', plotHeight);
59
+ background.setAttribute('fill', 'white');
60
+ svg.appendChild(background);
61
+
62
+ const tempCanvas = document.createElement('canvas');
63
+ tempCanvas.width = plotWidth;
64
+ tempCanvas.height = plotHeight;
65
+ const tempCtx = tempCanvas.getContext('2d');
66
+
67
+ tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
68
+
69
+ const canvasImage = document.createElementNS(svgNamespace, 'image');
70
+ canvasImage.setAttribute('width', plotWidth);
71
+ canvasImage.setAttribute('height', plotHeight);
72
+ canvasImage.setAttribute('href', tempCanvas.toDataURL('image/png'));
73
+ svg.appendChild(canvasImage);
74
+ } else {
75
+ svg.setAttribute('width', this.chart.width);
76
+ svg.setAttribute('height', this.chart.height);
77
+ svg.setAttribute('viewBox', `0 0 ${this.chart.width} ${this.chart.height}`);
78
+
79
+ const background = document.createElementNS(svgNamespace, 'rect');
80
+ background.setAttribute('width', this.chart.width);
81
+ background.setAttribute('height', this.chart.height);
82
+ background.setAttribute('fill', 'white');
83
+ svg.appendChild(background);
84
+
85
+ const canvasImage = document.createElementNS(svgNamespace, 'image');
86
+ canvasImage.setAttribute('width', this.chart.width);
87
+ canvasImage.setAttribute('height', this.chart.height);
88
+ canvasImage.setAttribute('href', this.chart.canvas.toDataURL('image/png'));
89
+ svg.appendChild(canvasImage);
90
+ }
91
+
92
+ const serializer = new XMLSerializer();
93
+ const svgString = serializer.serializeToString(svg);
94
+
95
+ const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
96
+ const url = URL.createObjectURL(blob);
97
+ const link = document.createElement('a');
98
+
99
+ const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
100
+
101
+ link.setAttribute('href', url);
102
+ link.setAttribute('download', `${fileName}.svg`);
103
+ link.style.display = 'none';
104
+
105
+ document.body.appendChild(link);
106
+ link.click();
107
+ document.body.removeChild(link);
108
+ URL.revokeObjectURL(url);
109
+ }
110
+
111
+ downloadPNG() {
112
+ const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
113
+
114
+ let dataURL;
115
+
116
+ if (useZoomedArea) {
117
+ const tempCanvas = document.createElement('canvas');
118
+ tempCanvas.width = plotWidth;
119
+ tempCanvas.height = plotHeight;
120
+ const tempCtx = tempCanvas.getContext('2d');
121
+
122
+ tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
123
+
124
+ dataURL = tempCanvas.toDataURL('image/png');
125
+ } else {
126
+ dataURL = this.chart.canvas.toDataURL('image/png');
127
+ }
128
+
129
+ const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
130
+
131
+ const link = document.createElement('a');
132
+ link.setAttribute('href', dataURL);
133
+ link.setAttribute('download', `${fileName}.png`);
134
+ link.style.display = 'none';
135
+
136
+ document.body.appendChild(link);
137
+ link.click();
138
+ document.body.removeChild(link);
139
+ }
140
+ }