github-actions[bot] commited on
Commit ·
1b50562
0
Parent(s):
Deploy to HF Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +38 -0
- .github/workflows/ci.yml +86 -0
- .github/workflows/hf-deploy.yml +47 -0
- .gitignore +56 -0
- .readthedocs.yml +16 -0
- DEVELOPMENT.md +114 -0
- Dockerfile +63 -0
- LICENSE +178 -0
- README.md +27 -0
- biome.json +35 -0
- examples/generate_random_runs.py +228 -0
- examples/slow_metrics_writer.py +85 -0
- icons.config.json +82 -0
- mkdocs.yml +89 -0
- package.json +46 -0
- playwright.config.js +68 -0
- pnpm-lock.yaml +2831 -0
- pyproject.toml +123 -0
- scripts/build-icons.js +119 -0
- space_README.md +27 -0
- src/aspara/__init__.py +31 -0
- src/aspara/catalog/__init__.py +18 -0
- src/aspara/catalog/project_catalog.py +202 -0
- src/aspara/catalog/run_catalog.py +738 -0
- src/aspara/catalog/watcher.py +507 -0
- src/aspara/cli.py +403 -0
- src/aspara/config.py +214 -0
- src/aspara/dashboard/__init__.py +5 -0
- src/aspara/dashboard/dependencies.py +120 -0
- src/aspara/dashboard/main.py +145 -0
- src/aspara/dashboard/models/__init__.py +3 -0
- src/aspara/dashboard/models/metrics.py +49 -0
- src/aspara/dashboard/router.py +25 -0
- src/aspara/dashboard/routes/__init__.py +14 -0
- src/aspara/dashboard/routes/api_routes.py +432 -0
- src/aspara/dashboard/routes/html_routes.py +234 -0
- src/aspara/dashboard/routes/sse_routes.py +239 -0
- src/aspara/dashboard/services/__init__.py +9 -0
- src/aspara/dashboard/services/template_service.py +162 -0
- src/aspara/dashboard/static/css/input.css +171 -0
- src/aspara/dashboard/static/css/tagger.css +79 -0
- src/aspara/dashboard/static/favicon.ico +0 -0
- src/aspara/dashboard/static/images/aspara-icon.png +0 -0
- src/aspara/dashboard/static/js/api/delete-api.js +61 -0
- src/aspara/dashboard/static/js/chart.js +420 -0
- src/aspara/dashboard/static/js/chart/color-palette.js +198 -0
- src/aspara/dashboard/static/js/chart/controls.js +224 -0
- src/aspara/dashboard/static/js/chart/export-utils.js +74 -0
- src/aspara/dashboard/static/js/chart/export.js +140 -0
- src/aspara/dashboard/static/js/chart/interaction-utils.js +131 -0
.dockerignore
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Version control
|
| 2 |
+
.git/
|
| 3 |
+
|
| 4 |
+
# Virtual environments
|
| 5 |
+
.venv/
|
| 6 |
+
|
| 7 |
+
# Node modules (reinstalled in build)
|
| 8 |
+
node_modules/
|
| 9 |
+
|
| 10 |
+
# Build outputs (rebuilt in Docker)
|
| 11 |
+
src/aspara/dashboard/static/dist/
|
| 12 |
+
|
| 13 |
+
# Test artifacts
|
| 14 |
+
test-results/
|
| 15 |
+
playwright-report/
|
| 16 |
+
.coverage
|
| 17 |
+
htmlcov/
|
| 18 |
+
coverage/
|
| 19 |
+
|
| 20 |
+
# Documentation build
|
| 21 |
+
site/
|
| 22 |
+
docs/
|
| 23 |
+
|
| 24 |
+
# IDE / OS
|
| 25 |
+
.idea/
|
| 26 |
+
.DS_Store
|
| 27 |
+
Thumbs.db
|
| 28 |
+
*.swp
|
| 29 |
+
*.swo
|
| 30 |
+
|
| 31 |
+
# Cache
|
| 32 |
+
.mypy_cache/
|
| 33 |
+
.ruff_cache/
|
| 34 |
+
__pycache__/
|
| 35 |
+
|
| 36 |
+
# Environment files
|
| 37 |
+
.env
|
| 38 |
+
.env.*
|
.github/workflows/ci.yml
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI
|
| 2 |
+
|
| 3 |
+
# Security note for public repositories:
|
| 4 |
+
# If this repository becomes public, configure the following in
|
| 5 |
+
# Settings > Actions > General > "Fork pull request workflows from outside collaborators":
|
| 6 |
+
# Set to "Require approval for all outside collaborators"
|
| 7 |
+
|
| 8 |
+
on:
|
| 9 |
+
pull_request:
|
| 10 |
+
branches: [main]
|
| 11 |
+
|
| 12 |
+
concurrency:
|
| 13 |
+
group: ${{ github.workflow }}-${{ github.ref }}
|
| 14 |
+
cancel-in-progress: true
|
| 15 |
+
|
| 16 |
+
jobs:
|
| 17 |
+
lint:
|
| 18 |
+
runs-on: ubuntu-latest
|
| 19 |
+
steps:
|
| 20 |
+
- uses: actions/checkout@v4
|
| 21 |
+
- uses: astral-sh/setup-uv@v7
|
| 22 |
+
with:
|
| 23 |
+
enable-cache: true
|
| 24 |
+
- run: uv sync --dev --locked
|
| 25 |
+
- run: uv run ruff check .
|
| 26 |
+
- run: uv run ruff format --check .
|
| 27 |
+
- uses: pnpm/action-setup@v4
|
| 28 |
+
with:
|
| 29 |
+
version: 10.6.3
|
| 30 |
+
- uses: actions/setup-node@v4
|
| 31 |
+
with:
|
| 32 |
+
node-version: '22'
|
| 33 |
+
cache: 'pnpm'
|
| 34 |
+
- run: pnpm install --frozen-lockfile
|
| 35 |
+
- run: pnpm lint
|
| 36 |
+
|
| 37 |
+
python-test:
|
| 38 |
+
runs-on: ubuntu-latest
|
| 39 |
+
strategy:
|
| 40 |
+
matrix:
|
| 41 |
+
python-version: ['3.10', '3.14']
|
| 42 |
+
steps:
|
| 43 |
+
- uses: actions/checkout@v4
|
| 44 |
+
- uses: pnpm/action-setup@v4
|
| 45 |
+
with:
|
| 46 |
+
version: 10.6.3
|
| 47 |
+
- uses: actions/setup-node@v4
|
| 48 |
+
with:
|
| 49 |
+
node-version: '22'
|
| 50 |
+
cache: 'pnpm'
|
| 51 |
+
- run: pnpm install --frozen-lockfile
|
| 52 |
+
- run: pnpm build
|
| 53 |
+
- uses: astral-sh/setup-uv@v7
|
| 54 |
+
with:
|
| 55 |
+
enable-cache: true
|
| 56 |
+
python-version: ${{ matrix.python-version }}
|
| 57 |
+
- run: uv sync --dev --locked
|
| 58 |
+
- run: uv run pytest
|
| 59 |
+
|
| 60 |
+
js-test:
|
| 61 |
+
runs-on: ubuntu-latest
|
| 62 |
+
steps:
|
| 63 |
+
- uses: actions/checkout@v4
|
| 64 |
+
- uses: pnpm/action-setup@v4
|
| 65 |
+
with:
|
| 66 |
+
version: 10.6.3
|
| 67 |
+
- uses: actions/setup-node@v4
|
| 68 |
+
with:
|
| 69 |
+
node-version: '22'
|
| 70 |
+
cache: 'pnpm'
|
| 71 |
+
- run: pnpm install --frozen-lockfile
|
| 72 |
+
- run: pnpm test:ci
|
| 73 |
+
|
| 74 |
+
build:
|
| 75 |
+
runs-on: ubuntu-latest
|
| 76 |
+
steps:
|
| 77 |
+
- uses: actions/checkout@v4
|
| 78 |
+
- uses: pnpm/action-setup@v4
|
| 79 |
+
with:
|
| 80 |
+
version: 10.6.3
|
| 81 |
+
- uses: actions/setup-node@v4
|
| 82 |
+
with:
|
| 83 |
+
node-version: '22'
|
| 84 |
+
cache: 'pnpm'
|
| 85 |
+
- run: pnpm install --frozen-lockfile
|
| 86 |
+
- run: pnpm build
|
.github/workflows/hf-deploy.yml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to HF Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
paths-ignore:
|
| 7 |
+
- "docs/**"
|
| 8 |
+
- "mkdocs.yml"
|
| 9 |
+
- "tests/**"
|
| 10 |
+
- "*.md"
|
| 11 |
+
workflow_dispatch:
|
| 12 |
+
|
| 13 |
+
concurrency:
|
| 14 |
+
group: hf-deploy
|
| 15 |
+
cancel-in-progress: true
|
| 16 |
+
|
| 17 |
+
jobs:
|
| 18 |
+
deploy:
|
| 19 |
+
runs-on: ubuntu-latest
|
| 20 |
+
steps:
|
| 21 |
+
- uses: actions/checkout@v4
|
| 22 |
+
|
| 23 |
+
- name: Fetch HF Space config
|
| 24 |
+
run: |
|
| 25 |
+
git fetch origin hf-space
|
| 26 |
+
git checkout origin/hf-space -- Dockerfile .dockerignore space_README.md
|
| 27 |
+
|
| 28 |
+
- name: Prepare HF Space
|
| 29 |
+
run: |
|
| 30 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 31 |
+
git config user.name "github-actions[bot]"
|
| 32 |
+
|
| 33 |
+
# Create orphan branch (no history) to avoid binary files in past commits
|
| 34 |
+
git checkout --orphan hf-deploy
|
| 35 |
+
git rm -rf --cached docs/ tests/ >/dev/null 2>&1 || true
|
| 36 |
+
rm -rf docs/ tests/
|
| 37 |
+
cp space_README.md README.md
|
| 38 |
+
git add -A
|
| 39 |
+
git commit -m "Deploy to HF Spaces"
|
| 40 |
+
|
| 41 |
+
- name: Push to Hugging Face
|
| 42 |
+
env:
|
| 43 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 44 |
+
run: |
|
| 45 |
+
git push --force \
|
| 46 |
+
https://hf:${HF_TOKEN}@huggingface.co/spaces/PredNext/aspara \
|
| 47 |
+
HEAD:main
|
.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.egg-info/
|
| 6 |
+
*.egg
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
*.whl
|
| 10 |
+
|
| 11 |
+
# Virtual environment
|
| 12 |
+
.venv/
|
| 13 |
+
|
| 14 |
+
# Node.js
|
| 15 |
+
node_modules/
|
| 16 |
+
|
| 17 |
+
# Build output
|
| 18 |
+
src/aspara/dashboard/static/dist/
|
| 19 |
+
|
| 20 |
+
# Test
|
| 21 |
+
test-results/
|
| 22 |
+
playwright-report/
|
| 23 |
+
.coverage
|
| 24 |
+
htmlcov/
|
| 25 |
+
coverage/
|
| 26 |
+
|
| 27 |
+
# Logs
|
| 28 |
+
logs/
|
| 29 |
+
*.log
|
| 30 |
+
|
| 31 |
+
# OS
|
| 32 |
+
.DS_Store
|
| 33 |
+
Thumbs.db
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.idea/
|
| 37 |
+
*.swp
|
| 38 |
+
*.swo
|
| 39 |
+
|
| 40 |
+
# Linter / Type checker cache
|
| 41 |
+
.mypy_cache/
|
| 42 |
+
.ruff_cache/
|
| 43 |
+
|
| 44 |
+
# Environment variables
|
| 45 |
+
.env
|
| 46 |
+
.env.*
|
| 47 |
+
|
| 48 |
+
# Database
|
| 49 |
+
*.sqlite
|
| 50 |
+
*.db
|
| 51 |
+
|
| 52 |
+
# uv
|
| 53 |
+
.python-version
|
| 54 |
+
|
| 55 |
+
# MkDocs
|
| 56 |
+
site/
|
.readthedocs.yml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
|
| 3 |
+
build:
|
| 4 |
+
os: "ubuntu-24.04"
|
| 5 |
+
tools:
|
| 6 |
+
python: "3.12"
|
| 7 |
+
|
| 8 |
+
mkdocs:
|
| 9 |
+
configuration: mkdocs.yml
|
| 10 |
+
|
| 11 |
+
python:
|
| 12 |
+
install:
|
| 13 |
+
- method: pip
|
| 14 |
+
path: .
|
| 15 |
+
extra_requirements:
|
| 16 |
+
- docs
|
DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development Guide
|
| 2 |
+
|
| 3 |
+
This document is a developer guide for Aspara.
|
| 4 |
+
|
| 5 |
+
## Setup
|
| 6 |
+
|
| 7 |
+
### Python dependencies
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
uv sync --dev
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
### JavaScript dependencies
|
| 14 |
+
|
| 15 |
+
```bash
|
| 16 |
+
pnpm install
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Building Assets
|
| 20 |
+
|
| 21 |
+
After cloning the repository, you must build frontend assets before running Aspara.
|
| 22 |
+
These build artifacts are not tracked in git, but are included in pip packages.
|
| 23 |
+
|
| 24 |
+
### Build all assets (CSS + JavaScript)
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
pnpm build
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
This command generates:
|
| 31 |
+
- CSS: `src/aspara/dashboard/static/dist/css/styles.css`
|
| 32 |
+
- JavaScript: `src/aspara/dashboard/static/dist/*.js`
|
| 33 |
+
|
| 34 |
+
### Build CSS only
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
pnpm run build:css
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### Build JavaScript only
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
pnpm run build:js
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Development mode (watch mode)
|
| 47 |
+
|
| 48 |
+
To automatically detect file changes and rebuild during development:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
# Watch CSS
|
| 52 |
+
pnpm run watch:css
|
| 53 |
+
|
| 54 |
+
# Watch JavaScript
|
| 55 |
+
pnpm run watch:js
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Testing
|
| 59 |
+
|
| 60 |
+
### Python tests
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
uv run pytest
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### JavaScript tests
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
pnpm test
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### E2E tests
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
npx playwright test
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
## Linting and Formatting
|
| 79 |
+
|
| 80 |
+
### Python
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
# Lint
|
| 84 |
+
ruff check .
|
| 85 |
+
|
| 86 |
+
# Format
|
| 87 |
+
ruff format .
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
### JavaScript
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
# Lint
|
| 94 |
+
pnpm lint
|
| 95 |
+
|
| 96 |
+
# Format
|
| 97 |
+
pnpm format
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Documentation
|
| 101 |
+
|
| 102 |
+
### Build documentation
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
uv run mkdocs build
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Serve documentation locally
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
uv run mkdocs serve
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
You can view the documentation by accessing http://localhost:8000 in your browser.
|
Dockerfile
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================================
|
| 2 |
+
# Aspara Demo - Hugging Face Spaces
|
| 3 |
+
# Multi-stage build: frontend (Node.js) + backend (Python/FastAPI)
|
| 4 |
+
# ==============================================================================
|
| 5 |
+
|
| 6 |
+
# ------------------------------------------------------------------------------
|
| 7 |
+
# Stage 1: Frontend build (JS + CSS + icons)
|
| 8 |
+
# ------------------------------------------------------------------------------
|
| 9 |
+
FROM node:22-slim AS frontend-builder
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
# Enable pnpm via corepack
|
| 14 |
+
RUN corepack enable && corepack prepare pnpm@10.6.3 --activate
|
| 15 |
+
|
| 16 |
+
# Install JS dependencies (cache layer)
|
| 17 |
+
COPY package.json pnpm-lock.yaml ./
|
| 18 |
+
RUN pnpm install --frozen-lockfile
|
| 19 |
+
|
| 20 |
+
# Copy source and build frontend assets
|
| 21 |
+
COPY vite.config.js icons.config.json ./
|
| 22 |
+
COPY scripts/ ./scripts/
|
| 23 |
+
COPY src/aspara/dashboard/ ./src/aspara/dashboard/
|
| 24 |
+
RUN pnpm run build:icons && pnpm run build:js && pnpm run build:css
|
| 25 |
+
|
| 26 |
+
# ------------------------------------------------------------------------------
|
| 27 |
+
# Stage 2: Python runtime + sample data generation
|
| 28 |
+
# ------------------------------------------------------------------------------
|
| 29 |
+
FROM python:3.12-slim
|
| 30 |
+
|
| 31 |
+
WORKDIR /app
|
| 32 |
+
|
| 33 |
+
# Install uv
|
| 34 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
| 35 |
+
|
| 36 |
+
# Copy Python project files
|
| 37 |
+
COPY pyproject.toml uv.lock ./
|
| 38 |
+
COPY space_README.md ./README.md
|
| 39 |
+
COPY src/ ./src/
|
| 40 |
+
|
| 41 |
+
# Install Python dependencies (dashboard extra only, no dev deps)
|
| 42 |
+
RUN uv sync --frozen --extra dashboard --no-dev
|
| 43 |
+
|
| 44 |
+
# Overwrite with built frontend assets
|
| 45 |
+
COPY --from=frontend-builder /app/src/aspara/dashboard/static/dist/ ./src/aspara/dashboard/static/dist/
|
| 46 |
+
|
| 47 |
+
# Generate sample data during build
|
| 48 |
+
COPY examples/generate_random_runs.py ./examples/
|
| 49 |
+
ENV ASPARA_DATA_DIR=/data/aspara
|
| 50 |
+
ENV ASPARA_ALLOW_IFRAME=1
|
| 51 |
+
ENV ASPARA_READ_ONLY=1
|
| 52 |
+
RUN mkdir -p /data/aspara && uv run python examples/generate_random_runs.py
|
| 53 |
+
|
| 54 |
+
# Create non-root user (HF Spaces best practice)
|
| 55 |
+
RUN useradd -m -u 1000 user && \
|
| 56 |
+
chown -R user:user /data /app
|
| 57 |
+
USER user
|
| 58 |
+
|
| 59 |
+
# HF Spaces uses port 7860
|
| 60 |
+
EXPOSE 7860
|
| 61 |
+
|
| 62 |
+
# Start dashboard only (no tracker = no external write API)
|
| 63 |
+
CMD ["uv", "run", "aspara", "serve", "--host", "0.0.0.0", "--port", "7860", "--data-dir", "/data/aspara"]
|
LICENSE
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no theory of
|
| 154 |
+
liability, whether in contract, strict liability, or tort
|
| 155 |
+
(including negligence or otherwise) arising in any way out of
|
| 156 |
+
the use or inability to use the Work (even if such Holder has
|
| 157 |
+
been advised of the possibility of such damages), shall any
|
| 158 |
+
Contributor be liable to You for damages, including any direct,
|
| 159 |
+
indirect, special, incidental, or consequential damages of any
|
| 160 |
+
character arising as a result of this License or out of the use
|
| 161 |
+
or inability to use the Work (including but not limited to
|
| 162 |
+
damages for loss of goodwill, work stoppage, computer failure or
|
| 163 |
+
malfunction, or any and all other commercial damages or losses),
|
| 164 |
+
even if such Contributor has been advised of the possibility of
|
| 165 |
+
such damages.
|
| 166 |
+
|
| 167 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 168 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 169 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 170 |
+
or other liability obligations and/or rights consistent with this
|
| 171 |
+
License. However, in accepting such obligations, You may act only
|
| 172 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 173 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 174 |
+
defend, and hold each Contributor harmless for any liability
|
| 175 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 176 |
+
of your accepting any such warranty or additional liability.
|
| 177 |
+
|
| 178 |
+
END OF TERMS AND CONDITIONS
|
README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aspara Demo
|
| 3 |
+
emoji: 🌱
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Aspara Demo
|
| 12 |
+
|
| 13 |
+
Aspara — a blazingly fast metrics tracker for machine learning experiments.
|
| 14 |
+
|
| 15 |
+
This Space runs a demo dashboard with pre-generated sample data.
|
| 16 |
+
Browse projects, compare runs, and explore metrics to see what Aspara can do.
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- LTTB-based metric downsampling for responsive charts
|
| 21 |
+
- Run comparison with overlay charts
|
| 22 |
+
- Tag and note editing
|
| 23 |
+
- Real-time updates via SSE
|
| 24 |
+
|
| 25 |
+
## Links
|
| 26 |
+
|
| 27 |
+
- [GitHub Repository](https://github.com/prednext/aspara)
|
biome.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
| 3 |
+
"organizeImports": {
|
| 4 |
+
"enabled": true
|
| 5 |
+
},
|
| 6 |
+
"linter": {
|
| 7 |
+
"enabled": true,
|
| 8 |
+
"rules": {
|
| 9 |
+
"recommended": true,
|
| 10 |
+
"suspicious": {
|
| 11 |
+
"noExplicitAny": "off"
|
| 12 |
+
},
|
| 13 |
+
"style": {
|
| 14 |
+
"useImportType": "off"
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
},
|
| 18 |
+
"formatter": {
|
| 19 |
+
"enabled": true,
|
| 20 |
+
"indentStyle": "space",
|
| 21 |
+
"indentWidth": 2,
|
| 22 |
+
"lineWidth": 160
|
| 23 |
+
},
|
| 24 |
+
"javascript": {
|
| 25 |
+
"formatter": {
|
| 26 |
+
"quoteStyle": "single",
|
| 27 |
+
"semicolons": "always",
|
| 28 |
+
"trailingCommas": "es5"
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"files": {
|
| 32 |
+
"include": ["src/**/*.js", "tests/**/*.js", "*.js"],
|
| 33 |
+
"ignore": ["node_modules/**", "dist/**", "build/**", "docs/**", "site/**", "site.old/**", "playwright-report/**", ".venv", "coverage/**"]
|
| 34 |
+
}
|
| 35 |
+
}
|
examples/generate_random_runs.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Sample script to generate multiple random experiment runs.
|
| 3 |
+
Creates 4 different runs, each recording 100 steps of metrics.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
import aspara
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def generate_metrics_with_trend(
|
| 13 |
+
step: int,
|
| 14 |
+
total_steps: int,
|
| 15 |
+
base_values: dict[str, float],
|
| 16 |
+
noise_levels: dict[str, float],
|
| 17 |
+
trends: dict[str, float],
|
| 18 |
+
) -> dict[str, float]:
|
| 19 |
+
"""
|
| 20 |
+
Generate metrics with trend and noise.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
step: Current step
|
| 24 |
+
total_steps: Total number of steps
|
| 25 |
+
base_values: Initial values for each metric
|
| 26 |
+
noise_levels: Noise level for each metric
|
| 27 |
+
trends: Final change amount for each metric
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Generated metrics
|
| 31 |
+
"""
|
| 32 |
+
progress = step / total_steps
|
| 33 |
+
metrics = {}
|
| 34 |
+
|
| 35 |
+
for metric_name, base_value in base_values.items():
|
| 36 |
+
# Change due to trend (linear + slight exponential component)
|
| 37 |
+
trend_factor = progress * (1.0 + 0.2 * math.log(1 + 5 * progress))
|
| 38 |
+
trend_change = trends[metric_name] * trend_factor
|
| 39 |
+
|
| 40 |
+
# Random noise (sine wave + Gaussian noise)
|
| 41 |
+
noise = (
|
| 42 |
+
noise_levels[metric_name] * math.sin(step * 0.2) * 0.3 # Periodic noise
|
| 43 |
+
+ noise_levels[metric_name] * random.gauss(0, 0.5) # Random noise
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Calculate final value
|
| 47 |
+
value = base_value + trend_change + noise
|
| 48 |
+
|
| 49 |
+
# Limit value range (accuracy between 0-1, loss >= 0)
|
| 50 |
+
if "accuracy" in metric_name:
|
| 51 |
+
value = max(0.0, min(1.0, value))
|
| 52 |
+
elif "loss" in metric_name:
|
| 53 |
+
value = max(0.01, value)
|
| 54 |
+
|
| 55 |
+
metrics[metric_name] = value
|
| 56 |
+
|
| 57 |
+
return metrics
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def create_run_config(run_id: int) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
|
| 61 |
+
"""
|
| 62 |
+
Create configuration for each run.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
run_id: Run number
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Tuple of (initial values, noise levels, trends)
|
| 69 |
+
"""
|
| 70 |
+
# Set slightly different initial values for each run
|
| 71 |
+
base_values = {
|
| 72 |
+
"accuracy": 0.3 + random.uniform(-0.1, 0.1),
|
| 73 |
+
"loss": 1.0 + random.uniform(-0.2, 0.2),
|
| 74 |
+
"val_accuracy": 0.25 + random.uniform(-0.1, 0.1),
|
| 75 |
+
"val_loss": 1.1 + random.uniform(-0.2, 0.2),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
# Set noise levels
|
| 79 |
+
noise_levels = {
|
| 80 |
+
"accuracy": 0.02 + 0.01 * run_id,
|
| 81 |
+
"loss": 0.05 + 0.02 * run_id,
|
| 82 |
+
"val_accuracy": 0.03 + 0.01 * run_id,
|
| 83 |
+
"val_loss": 0.07 + 0.02 * run_id,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# Set trends (accuracy increases, loss decreases)
|
| 87 |
+
trends = {
|
| 88 |
+
"accuracy": 0.5 + random.uniform(-0.1, 0.1), # Upward trend
|
| 89 |
+
"loss": -0.8 + random.uniform(-0.1, 0.1), # Downward trend
|
| 90 |
+
"val_accuracy": 0.45 + random.uniform(-0.1, 0.1), # Upward trend (slightly lower than train)
|
| 91 |
+
"val_loss": -0.75 + random.uniform(-0.1, 0.1), # Downward trend (slightly higher than train)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return base_values, noise_levels, trends
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def generate_run(
|
| 98 |
+
project: str,
|
| 99 |
+
run_id: int,
|
| 100 |
+
total_steps: int = 100,
|
| 101 |
+
project_tags: list[str] | None = None,
|
| 102 |
+
run_name: str | None = None,
|
| 103 |
+
) -> None:
|
| 104 |
+
"""
|
| 105 |
+
Generate an experiment run with the specified ID.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
project: Project name
|
| 109 |
+
run_id: Run number
|
| 110 |
+
total_steps: Number of steps to generate
|
| 111 |
+
project_tags: Common tags for the project
|
| 112 |
+
run_name: Run name (generated from run_id if not specified)
|
| 113 |
+
"""
|
| 114 |
+
# Initialize run
|
| 115 |
+
if run_name is None:
|
| 116 |
+
run_name = f"random_training_run_{run_id}"
|
| 117 |
+
|
| 118 |
+
print(f"Starting generation of run {run_id} for project '{project}'! ({run_name})")
|
| 119 |
+
|
| 120 |
+
# Create run configuration
|
| 121 |
+
base_values, noise_levels, trends = create_run_config(run_id)
|
| 122 |
+
|
| 123 |
+
# Add run-specific tags (fruits) to project-common tags (animals)
|
| 124 |
+
fruits = ["apple", "pear", "orange", "grape", "banana", "mango"]
|
| 125 |
+
num_fruit_tags = random.randint(1, len(fruits))
|
| 126 |
+
run_tags = random.sample(fruits, k=num_fruit_tags)
|
| 127 |
+
|
| 128 |
+
aspara.init(
|
| 129 |
+
project=project,
|
| 130 |
+
name=run_name,
|
| 131 |
+
config={
|
| 132 |
+
"learning_rate": 0.01 * (1 + 0.2 * run_id),
|
| 133 |
+
"batch_size": 32 * (1 + run_id % 2),
|
| 134 |
+
"optimizer": ["adam", "sgd", "rmsprop", "adagrad"][run_id % 4],
|
| 135 |
+
"model_type": "mlp",
|
| 136 |
+
"hidden_layers": [128, 64, 32],
|
| 137 |
+
"dropout": 0.2 + 0.05 * run_id,
|
| 138 |
+
"epochs": 10,
|
| 139 |
+
"run_id": run_id,
|
| 140 |
+
},
|
| 141 |
+
tags=run_tags,
|
| 142 |
+
project_tags=project_tags,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Simulate training loop
|
| 146 |
+
print(f"Generating metrics for {total_steps} steps...")
|
| 147 |
+
for step in range(total_steps):
|
| 148 |
+
# Generate metrics
|
| 149 |
+
metrics = generate_metrics_with_trend(step, total_steps, base_values, noise_levels, trends)
|
| 150 |
+
|
| 151 |
+
# Log metrics
|
| 152 |
+
aspara.log(metrics, step=step)
|
| 153 |
+
|
| 154 |
+
# Show progress (every 10 steps)
|
| 155 |
+
if step % 10 == 0 or step == total_steps - 1:
|
| 156 |
+
print(f" Step {step}/{total_steps - 1}: accuracy={metrics['accuracy']:.3f}, loss={metrics['loss']:.3f}")
|
| 157 |
+
|
| 158 |
+
# Finish run
|
| 159 |
+
aspara.finish()
|
| 160 |
+
|
| 161 |
+
print(f"Completed generation of run {run_id} for project '{project}'!")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def main() -> None:
|
| 165 |
+
"""Main function: Generate multiple runs."""
|
| 166 |
+
steps_per_run = 100
|
| 167 |
+
|
| 168 |
+
# Cool secret project names
|
| 169 |
+
project_names = [
|
| 170 |
+
"Project_Phoenix",
|
| 171 |
+
"Operation_Midnight",
|
| 172 |
+
"Genesis_Initiative",
|
| 173 |
+
"Project_Prometheus",
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
# Famous SF titles (mix of Western and Japanese works)
|
| 177 |
+
sf_titles = [
|
| 178 |
+
"AKIRA",
|
| 179 |
+
"Ghost_in_the_Shell",
|
| 180 |
+
"Planetes",
|
| 181 |
+
"Steins_Gate",
|
| 182 |
+
"Paprika",
|
| 183 |
+
"Blade_Runner",
|
| 184 |
+
"Dune",
|
| 185 |
+
"Neuromancer",
|
| 186 |
+
"Foundation",
|
| 187 |
+
"The_Martian",
|
| 188 |
+
"Interstellar",
|
| 189 |
+
"Solaris",
|
| 190 |
+
"Hyperion",
|
| 191 |
+
"Snow_Crash",
|
| 192 |
+
"Contact",
|
| 193 |
+
"Arrival",
|
| 194 |
+
"Gravity",
|
| 195 |
+
"Moon",
|
| 196 |
+
"Ex_Machina",
|
| 197 |
+
"Tenet",
|
| 198 |
+
]
|
| 199 |
+
|
| 200 |
+
print(f"Generating {len(project_names)} projects!")
|
| 201 |
+
print(f" Each project has 4-5 runs! ({steps_per_run} steps per run)")
|
| 202 |
+
animals = ["dog", "cat", "rabbit", "coala", "bear", "goat"]
|
| 203 |
+
|
| 204 |
+
# Shuffle SF titles before using
|
| 205 |
+
shuffled_sf_titles = sf_titles.copy()
|
| 206 |
+
random.shuffle(shuffled_sf_titles)
|
| 207 |
+
sf_title_index = 0
|
| 208 |
+
|
| 209 |
+
# Generate multiple projects, create 4-5 runs for each project
|
| 210 |
+
for project_name in project_names:
|
| 211 |
+
# Project-common tags (animals)
|
| 212 |
+
num_project_tags = random.randint(1, len(animals))
|
| 213 |
+
project_tags = random.sample(animals, k=num_project_tags)
|
| 214 |
+
|
| 215 |
+
num_runs = random.randint(4, 5)
|
| 216 |
+
for run_id in range(num_runs):
|
| 217 |
+
# Use SF title as run name
|
| 218 |
+
run_name = shuffled_sf_titles[sf_title_index % len(shuffled_sf_titles)]
|
| 219 |
+
sf_title_index += 1
|
| 220 |
+
generate_run(project_name, run_id, steps_per_run, project_tags, run_name)
|
| 221 |
+
print("") # Insert blank line
|
| 222 |
+
|
| 223 |
+
print("All runs have been generated!")
|
| 224 |
+
print(" Check them out on the dashboard!")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
if __name__ == "__main__":
|
| 228 |
+
main()
|
examples/slow_metrics_writer.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple slow metrics writer for testing SSE real-time updates.
|
| 3 |
+
|
| 4 |
+
This is a simpler version that's easy to customize.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
# Terminal 1: Start dashboard
|
| 8 |
+
aspara dashboard
|
| 9 |
+
|
| 10 |
+
# Terminal 2: Run this script
|
| 11 |
+
uv run python examples/slow_metrics_writer.py
|
| 12 |
+
|
| 13 |
+
# Terminal 3 (optional): Run again with different run name
|
| 14 |
+
uv run python examples/slow_metrics_writer.py --run experiment_2
|
| 15 |
+
|
| 16 |
+
# Open browser and watch metrics update in real-time!
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import math
|
| 21 |
+
import random
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
|
| 25 |
+
from aspara import Run
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main():
|
| 29 |
+
parser = argparse.ArgumentParser(description="Slow metrics writer for SSE testing")
|
| 30 |
+
parser.add_argument("--project", default="sse_test", help="Project name")
|
| 31 |
+
parser.add_argument("--run", default="experiment_1", help="Run name")
|
| 32 |
+
parser.add_argument("--steps", type=int, default=30, help="Number of steps")
|
| 33 |
+
parser.add_argument("--delay", type=float, default=2.0, help="Delay between steps (seconds)")
|
| 34 |
+
args = parser.parse_args()
|
| 35 |
+
|
| 36 |
+
# Random parameters for this run
|
| 37 |
+
run_seed = hash(args.run) % 1000
|
| 38 |
+
random.seed(run_seed)
|
| 39 |
+
|
| 40 |
+
loss_base = 1.2 + random.uniform(-0.2, 0.2)
|
| 41 |
+
acc_base = 0.3 + random.uniform(-0.1, 0.1)
|
| 42 |
+
noise_level = 0.015 + random.uniform(0, 0.01)
|
| 43 |
+
|
| 44 |
+
# Create run
|
| 45 |
+
run = Run(
|
| 46 |
+
project=args.project,
|
| 47 |
+
name=args.run,
|
| 48 |
+
tags=["sse", "test", "realtime"],
|
| 49 |
+
notes="Testing SSE real-time updates",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
print(f"🚀 Writing metrics to {args.project}/{args.run}")
|
| 53 |
+
print(f" Steps: {args.steps}, Delay: {args.delay}s")
|
| 54 |
+
print(f" Base Loss: {loss_base:.3f}, Base Acc: {acc_base:.3f}")
|
| 55 |
+
print(" Open http://localhost:3141 to watch in real-time!\n")
|
| 56 |
+
|
| 57 |
+
# Write metrics gradually
|
| 58 |
+
for step in range(args.steps):
|
| 59 |
+
# Add noise (periodic + random)
|
| 60 |
+
noise = noise_level * (math.sin(step * 0.4) * 0.5 + random.gauss(0, 0.5))
|
| 61 |
+
|
| 62 |
+
# Simulate training metrics with noise
|
| 63 |
+
loss = max(0.01, (loss_base / (step + 1)) + noise)
|
| 64 |
+
accuracy = min(0.99, acc_base + (0.6 * (1.0 - 1.0 / (step + 1))) + noise * 0.3)
|
| 65 |
+
|
| 66 |
+
run.log(
|
| 67 |
+
{
|
| 68 |
+
"loss": loss,
|
| 69 |
+
"accuracy": accuracy,
|
| 70 |
+
"step_time": 0.1 + (step * 0.01) + random.uniform(-0.01, 0.01),
|
| 71 |
+
},
|
| 72 |
+
step=step,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 76 |
+
print(f"[{timestamp}] Step {step:3d}/{args.steps} | loss={loss:.4f} acc={accuracy:.4f}")
|
| 77 |
+
|
| 78 |
+
time.sleep(args.delay)
|
| 79 |
+
|
| 80 |
+
run.finish(exit_code=0)
|
| 81 |
+
print(f"\n✅ Completed! Total time: {args.steps * args.delay:.1f}s")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
icons.config.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"icons": [
|
| 3 |
+
{
|
| 4 |
+
"name": "stop",
|
| 5 |
+
"style": "solid",
|
| 6 |
+
"id": "status-icon-wip",
|
| 7 |
+
"comment": "Work in progress status"
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"name": "x-mark",
|
| 11 |
+
"style": "outline",
|
| 12 |
+
"id": "status-icon-failed",
|
| 13 |
+
"comment": "Failed status"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"name": "check",
|
| 17 |
+
"style": "outline",
|
| 18 |
+
"id": "status-icon-completed",
|
| 19 |
+
"comment": "Completed status"
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"name": "pencil-square",
|
| 23 |
+
"style": "outline",
|
| 24 |
+
"id": "icon-edit",
|
| 25 |
+
"comment": "Edit/pencil icon for note editing"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"name": "arrow-uturn-left",
|
| 29 |
+
"style": "outline",
|
| 30 |
+
"id": "icon-reset-zoom",
|
| 31 |
+
"comment": "Reset zoom for chart controls"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"name": "arrows-pointing-out",
|
| 35 |
+
"style": "outline",
|
| 36 |
+
"id": "icon-fullscreen",
|
| 37 |
+
"comment": "Full screen/expand for chart controls"
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"name": "arrow-down-tray",
|
| 41 |
+
"style": "outline",
|
| 42 |
+
"id": "icon-download",
|
| 43 |
+
"comment": "Download for chart controls"
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"name": "trash",
|
| 47 |
+
"style": "outline",
|
| 48 |
+
"id": "icon-delete",
|
| 49 |
+
"comment": "Delete/trash icon for delete buttons"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"name": "chevron-left",
|
| 53 |
+
"style": "outline",
|
| 54 |
+
"id": "icon-chevron-left",
|
| 55 |
+
"comment": "Chevron left for sidebar collapse"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"name": "chevron-right",
|
| 59 |
+
"style": "outline",
|
| 60 |
+
"id": "icon-chevron-right",
|
| 61 |
+
"comment": "Chevron right for sidebar expand"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"name": "bars-3",
|
| 65 |
+
"style": "outline",
|
| 66 |
+
"id": "icon-menu",
|
| 67 |
+
"comment": "Hamburger menu icon for settings"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"name": "exclamation-triangle",
|
| 71 |
+
"style": "outline",
|
| 72 |
+
"id": "icon-exclamation-triangle",
|
| 73 |
+
"comment": "Warning/danger icon for confirm modal and maybe_failed status"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"name": "information-circle",
|
| 77 |
+
"style": "outline",
|
| 78 |
+
"id": "icon-information-circle",
|
| 79 |
+
"comment": "Info icon for confirm modal"
|
| 80 |
+
}
|
| 81 |
+
]
|
| 82 |
+
}
|
mkdocs.yml
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
site_name: Aspara User Manual
|
| 2 |
+
site_description: Aspara, blazingly fast metrics tracker for machine learning experiments
|
| 3 |
+
site_author: Aspara Development Group
|
| 4 |
+
copyright: Copyright © 2026 Aspara Development Group
|
| 5 |
+
use_directory_urls: false
|
| 6 |
+
|
| 7 |
+
theme:
|
| 8 |
+
name: material
|
| 9 |
+
language: en
|
| 10 |
+
logo: aspara-icon.png
|
| 11 |
+
favicon: aspara-icon.png
|
| 12 |
+
palette:
|
| 13 |
+
primary: indigo
|
| 14 |
+
accent: indigo
|
| 15 |
+
features:
|
| 16 |
+
- navigation.tabs
|
| 17 |
+
- navigation.sections
|
| 18 |
+
- navigation.top
|
| 19 |
+
- search.highlight
|
| 20 |
+
- content.code.copy
|
| 21 |
+
|
| 22 |
+
extra_css:
|
| 23 |
+
- aspara-theme.css
|
| 24 |
+
|
| 25 |
+
plugins:
|
| 26 |
+
- search
|
| 27 |
+
- mkdocstrings:
|
| 28 |
+
handlers:
|
| 29 |
+
python:
|
| 30 |
+
paths:
|
| 31 |
+
- src
|
| 32 |
+
selection:
|
| 33 |
+
docstring_style: google
|
| 34 |
+
rendering:
|
| 35 |
+
show_source: true
|
| 36 |
+
show_if_no_docstring: false
|
| 37 |
+
show_root_heading: false
|
| 38 |
+
show_root_toc_entry: false
|
| 39 |
+
heading_level: 2
|
| 40 |
+
show_signature_annotations: true
|
| 41 |
+
separate_signature: true
|
| 42 |
+
merge_init_into_class: false
|
| 43 |
+
docstring_section_style: "spacy"
|
| 44 |
+
show_symbol_type_heading: true
|
| 45 |
+
show_symbol_type_toc: true
|
| 46 |
+
|
| 47 |
+
markdown_extensions:
|
| 48 |
+
- pymdownx.highlight:
|
| 49 |
+
anchor_linenums: true
|
| 50 |
+
- pymdownx.superfences
|
| 51 |
+
- pymdownx.inlinehilite
|
| 52 |
+
- admonition
|
| 53 |
+
- pymdownx.details
|
| 54 |
+
- pymdownx.tabbed:
|
| 55 |
+
alternate_style: true
|
| 56 |
+
- tables
|
| 57 |
+
- footnotes
|
| 58 |
+
|
| 59 |
+
nav:
|
| 60 |
+
- Home: index.md
|
| 61 |
+
- Getting Started:
|
| 62 |
+
- Overview: getting-started.md
|
| 63 |
+
- User Guide:
|
| 64 |
+
- Overview: user-guide/basics.md
|
| 65 |
+
- Core Concepts: user-guide/concepts.md
|
| 66 |
+
- Metadata and Notes: user-guide/metadata.md
|
| 67 |
+
- Visualizing Results in Dashboard: user-guide/dashboard-visualization.md
|
| 68 |
+
- Terminal UI: user-guide/terminal-ui.md
|
| 69 |
+
- Best Practices: user-guide/best-practices.md
|
| 70 |
+
- Troubleshooting: user-guide/troubleshooting.md
|
| 71 |
+
- Advanced:
|
| 72 |
+
- Configuration: advanced/configuration.md
|
| 73 |
+
- LocalRun vs RemoteRun: advanced/local-vs-remote.md
|
| 74 |
+
- Storage: advanced/storage.md
|
| 75 |
+
- Dashboard: advanced/dashboard.md
|
| 76 |
+
- Tracker API: advanced/tracker-api.md
|
| 77 |
+
- Read-only Mode: advanced/read-only-mode.md
|
| 78 |
+
- Examples:
|
| 79 |
+
- Overview: examples/index.md
|
| 80 |
+
- PyTorch: examples/pytorch_example.md
|
| 81 |
+
- TensorFlow / Keras: examples/tensorflow_example.md
|
| 82 |
+
- scikit-learn: examples/sklearn_example.md
|
| 83 |
+
- API Reference:
|
| 84 |
+
- Overview: api/index.md
|
| 85 |
+
- aspara: api/aspara.md
|
| 86 |
+
- Run: api/run.md
|
| 87 |
+
- Dashboard API: api/dashboard.md
|
| 88 |
+
- Tracker API: api/tracker.md
|
| 89 |
+
- Contributing: contributing.md
|
package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "aspara",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"type": "module",
|
| 5 |
+
"description": "",
|
| 6 |
+
"main": "index.js",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"test": "vitest run",
|
| 9 |
+
"test:watch": "vitest",
|
| 10 |
+
"test:coverage": "vitest run --coverage",
|
| 11 |
+
"test:ui": "vitest --ui",
|
| 12 |
+
"test:ci": "vitest run --coverage",
|
| 13 |
+
"lint": "biome lint",
|
| 14 |
+
"format": "biome format --write",
|
| 15 |
+
"check": "biome check --write",
|
| 16 |
+
"build:icons": "node scripts/build-icons.js",
|
| 17 |
+
"build:js": "vite build",
|
| 18 |
+
"build:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --minify",
|
| 19 |
+
"watch:css": "tailwindcss -i ./src/aspara/dashboard/static/css/input.css -o ./src/aspara/dashboard/static/dist/css/styles.css --watch",
|
| 20 |
+
"build": "pnpm run build:icons && pnpm run build:js && pnpm run build:css"
|
| 21 |
+
},
|
| 22 |
+
"keywords": [],
|
| 23 |
+
"author": "",
|
| 24 |
+
"license": "Apache-2.0",
|
| 25 |
+
"packageManager": "pnpm@10.6.3",
|
| 26 |
+
"dependencies": {
|
| 27 |
+
"@jcubic/tagger": "^0.6.2",
|
| 28 |
+
"@msgpack/msgpack": "^3.1.3"
|
| 29 |
+
},
|
| 30 |
+
"devDependencies": {
|
| 31 |
+
"@biomejs/biome": "^1.9.4",
|
| 32 |
+
"@playwright/test": "^1.58.2",
|
| 33 |
+
"@swc/core": "^1.15.17",
|
| 34 |
+
"@tailwindcss/cli": "^4.2.1",
|
| 35 |
+
"@testing-library/dom": "^10.4.1",
|
| 36 |
+
"@vitest/coverage-v8": "4.0.18",
|
| 37 |
+
"@vitest/ui": "^4.0.18",
|
| 38 |
+
"canvas": "npm:@napi-rs/canvas@^0.1.95",
|
| 39 |
+
"happy-dom": "^20.7.0",
|
| 40 |
+
"heroicons": "^2.2.0",
|
| 41 |
+
"jsdom": "^26.1.0",
|
| 42 |
+
"tailwindcss": "^4.2.1",
|
| 43 |
+
"vite": "^7.3.1",
|
| 44 |
+
"vitest": "^4.0.18"
|
| 45 |
+
}
|
| 46 |
+
}
|
playwright.config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-check
|
| 2 |
+
import { defineConfig, devices } from '@playwright/test';
|
| 3 |
+
|
| 4 |
+
const BASE_PORT = 6113;
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* @see https://playwright.dev/docs/test-configuration
|
| 8 |
+
*/
|
| 9 |
+
export default defineConfig({
|
| 10 |
+
// E2Eテストのみを対象にする(Vitestとの競合を避ける)
|
| 11 |
+
testDir: './tests/e2e',
|
| 12 |
+
|
| 13 |
+
// 並列実行の worker 数
|
| 14 |
+
// CI では 2 workers、ローカルでは制限なし
|
| 15 |
+
workers: process.env.CI ? 2 : undefined,
|
| 16 |
+
|
| 17 |
+
// テストの実行タイムアウト
|
| 18 |
+
timeout: 30 * 1000,
|
| 19 |
+
|
| 20 |
+
// テスト実行の期待値
|
| 21 |
+
expect: {
|
| 22 |
+
// 要素が表示されるまでの最大待機時間
|
| 23 |
+
timeout: 5000,
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
// 失敗したテストのスクリーンショットを撮る
|
| 27 |
+
use: {
|
| 28 |
+
// ベースURL
|
| 29 |
+
baseURL: `http://localhost:${BASE_PORT}`,
|
| 30 |
+
|
| 31 |
+
// スクリーンショットを撮る
|
| 32 |
+
screenshot: 'only-on-failure',
|
| 33 |
+
|
| 34 |
+
// トレースを記録する
|
| 35 |
+
trace: 'on-first-retry',
|
| 36 |
+
|
| 37 |
+
// ダウンロードを許可
|
| 38 |
+
acceptDownloads: true,
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
// テスト実行のレポート形式
|
| 42 |
+
// 'list' はコンソール出力のみ、HTMLレポートは生成しない
|
| 43 |
+
reporter: process.env.CI ? 'github' : 'list',
|
| 44 |
+
|
| 45 |
+
// テスト前にサーバーを自動起動
|
| 46 |
+
webServer: {
|
| 47 |
+
command: `uv run aspara dashboard --port ${BASE_PORT}`,
|
| 48 |
+
port: BASE_PORT,
|
| 49 |
+
reuseExistingServer: !process.env.CI,
|
| 50 |
+
timeout: 60 * 1000,
|
| 51 |
+
},
|
| 52 |
+
|
| 53 |
+
// プロジェクト設定
|
| 54 |
+
projects: [
|
| 55 |
+
{
|
| 56 |
+
name: 'chromium',
|
| 57 |
+
use: { ...devices['Desktop Chrome'] },
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
name: 'firefox',
|
| 61 |
+
use: { ...devices['Desktop Firefox'] },
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
name: 'webkit',
|
| 65 |
+
use: { ...devices['Desktop Safari'] },
|
| 66 |
+
},
|
| 67 |
+
],
|
| 68 |
+
});
|
pnpm-lock.yaml
ADDED
|
@@ -0,0 +1,2831 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
lockfileVersion: '9.0'
|
| 2 |
+
|
| 3 |
+
settings:
|
| 4 |
+
autoInstallPeers: true
|
| 5 |
+
excludeLinksFromLockfile: false
|
| 6 |
+
|
| 7 |
+
importers:
|
| 8 |
+
|
| 9 |
+
.:
|
| 10 |
+
dependencies:
|
| 11 |
+
'@jcubic/tagger':
|
| 12 |
+
specifier: ^0.6.2
|
| 13 |
+
version: 0.6.2
|
| 14 |
+
'@msgpack/msgpack':
|
| 15 |
+
specifier: ^3.1.3
|
| 16 |
+
version: 3.1.3
|
| 17 |
+
devDependencies:
|
| 18 |
+
'@biomejs/biome':
|
| 19 |
+
specifier: ^1.9.4
|
| 20 |
+
version: 1.9.4
|
| 21 |
+
'@playwright/test':
|
| 22 |
+
specifier: ^1.58.2
|
| 23 |
+
version: 1.58.2
|
| 24 |
+
'@swc/core':
|
| 25 |
+
specifier: ^1.15.17
|
| 26 |
+
version: 1.15.17
|
| 27 |
+
'@tailwindcss/cli':
|
| 28 |
+
specifier: ^4.2.1
|
| 29 |
+
version: 4.2.1
|
| 30 |
+
'@testing-library/dom':
|
| 31 |
+
specifier: ^10.4.1
|
| 32 |
+
version: 10.4.1
|
| 33 |
+
'@vitest/coverage-v8':
|
| 34 |
+
specifier: 4.0.18
|
| 35 |
+
version: 4.0.18(vitest@4.0.18)
|
| 36 |
+
'@vitest/ui':
|
| 37 |
+
specifier: ^4.0.18
|
| 38 |
+
version: 4.0.18(vitest@4.0.18)
|
| 39 |
+
canvas:
|
| 40 |
+
specifier: npm:@napi-rs/canvas@^0.1.95
|
| 41 |
+
version: '@napi-rs/canvas@0.1.95'
|
| 42 |
+
happy-dom:
|
| 43 |
+
specifier: ^20.7.0
|
| 44 |
+
version: 20.7.0
|
| 45 |
+
heroicons:
|
| 46 |
+
specifier: ^2.2.0
|
| 47 |
+
version: 2.2.0
|
| 48 |
+
jsdom:
|
| 49 |
+
specifier: ^26.1.0
|
| 50 |
+
version: 26.1.0(@napi-rs/canvas@0.1.95)
|
| 51 |
+
tailwindcss:
|
| 52 |
+
specifier: ^4.2.1
|
| 53 |
+
version: 4.2.1
|
| 54 |
+
vite:
|
| 55 |
+
specifier: ^7.3.1
|
| 56 |
+
version: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
|
| 57 |
+
vitest:
|
| 58 |
+
specifier: ^4.0.18
|
| 59 |
+
version: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
|
| 60 |
+
|
| 61 |
+
packages:
|
| 62 |
+
|
| 63 |
+
'@asamuzakjp/css-color@3.2.0':
|
| 64 |
+
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
| 65 |
+
|
| 66 |
+
'@babel/code-frame@7.29.0':
|
| 67 |
+
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
| 68 |
+
engines: {node: '>=6.9.0'}
|
| 69 |
+
|
| 70 |
+
'@babel/helper-string-parser@7.27.1':
|
| 71 |
+
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
| 72 |
+
engines: {node: '>=6.9.0'}
|
| 73 |
+
|
| 74 |
+
'@babel/helper-validator-identifier@7.28.5':
|
| 75 |
+
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
| 76 |
+
engines: {node: '>=6.9.0'}
|
| 77 |
+
|
| 78 |
+
'@babel/parser@7.29.0':
|
| 79 |
+
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
| 80 |
+
engines: {node: '>=6.0.0'}
|
| 81 |
+
hasBin: true
|
| 82 |
+
|
| 83 |
+
'@babel/runtime@7.28.6':
|
| 84 |
+
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
| 85 |
+
engines: {node: '>=6.9.0'}
|
| 86 |
+
|
| 87 |
+
'@babel/types@7.29.0':
|
| 88 |
+
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
| 89 |
+
engines: {node: '>=6.9.0'}
|
| 90 |
+
|
| 91 |
+
'@bcoe/v8-coverage@1.0.2':
|
| 92 |
+
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
| 93 |
+
engines: {node: '>=18'}
|
| 94 |
+
|
| 95 |
+
'@biomejs/biome@1.9.4':
|
| 96 |
+
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
|
| 97 |
+
engines: {node: '>=14.21.3'}
|
| 98 |
+
hasBin: true
|
| 99 |
+
|
| 100 |
+
'@biomejs/cli-darwin-arm64@1.9.4':
|
| 101 |
+
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
|
| 102 |
+
engines: {node: '>=14.21.3'}
|
| 103 |
+
cpu: [arm64]
|
| 104 |
+
os: [darwin]
|
| 105 |
+
|
| 106 |
+
'@biomejs/cli-darwin-x64@1.9.4':
|
| 107 |
+
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
|
| 108 |
+
engines: {node: '>=14.21.3'}
|
| 109 |
+
cpu: [x64]
|
| 110 |
+
os: [darwin]
|
| 111 |
+
|
| 112 |
+
'@biomejs/cli-linux-arm64-musl@1.9.4':
|
| 113 |
+
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
|
| 114 |
+
engines: {node: '>=14.21.3'}
|
| 115 |
+
cpu: [arm64]
|
| 116 |
+
os: [linux]
|
| 117 |
+
|
| 118 |
+
'@biomejs/cli-linux-arm64@1.9.4':
|
| 119 |
+
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
|
| 120 |
+
engines: {node: '>=14.21.3'}
|
| 121 |
+
cpu: [arm64]
|
| 122 |
+
os: [linux]
|
| 123 |
+
|
| 124 |
+
'@biomejs/cli-linux-x64-musl@1.9.4':
|
| 125 |
+
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
|
| 126 |
+
engines: {node: '>=14.21.3'}
|
| 127 |
+
cpu: [x64]
|
| 128 |
+
os: [linux]
|
| 129 |
+
|
| 130 |
+
'@biomejs/cli-linux-x64@1.9.4':
|
| 131 |
+
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
|
| 132 |
+
engines: {node: '>=14.21.3'}
|
| 133 |
+
cpu: [x64]
|
| 134 |
+
os: [linux]
|
| 135 |
+
|
| 136 |
+
'@biomejs/cli-win32-arm64@1.9.4':
|
| 137 |
+
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
|
| 138 |
+
engines: {node: '>=14.21.3'}
|
| 139 |
+
cpu: [arm64]
|
| 140 |
+
os: [win32]
|
| 141 |
+
|
| 142 |
+
'@biomejs/cli-win32-x64@1.9.4':
|
| 143 |
+
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
|
| 144 |
+
engines: {node: '>=14.21.3'}
|
| 145 |
+
cpu: [x64]
|
| 146 |
+
os: [win32]
|
| 147 |
+
|
| 148 |
+
'@csstools/color-helpers@5.1.0':
|
| 149 |
+
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
| 150 |
+
engines: {node: '>=18'}
|
| 151 |
+
|
| 152 |
+
'@csstools/css-calc@2.1.4':
|
| 153 |
+
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
| 154 |
+
engines: {node: '>=18'}
|
| 155 |
+
peerDependencies:
|
| 156 |
+
'@csstools/css-parser-algorithms': ^3.0.5
|
| 157 |
+
'@csstools/css-tokenizer': ^3.0.4
|
| 158 |
+
|
| 159 |
+
'@csstools/css-color-parser@3.1.0':
|
| 160 |
+
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
|
| 161 |
+
engines: {node: '>=18'}
|
| 162 |
+
peerDependencies:
|
| 163 |
+
'@csstools/css-parser-algorithms': ^3.0.5
|
| 164 |
+
'@csstools/css-tokenizer': ^3.0.4
|
| 165 |
+
|
| 166 |
+
'@csstools/css-parser-algorithms@3.0.5':
|
| 167 |
+
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
| 168 |
+
engines: {node: '>=18'}
|
| 169 |
+
peerDependencies:
|
| 170 |
+
'@csstools/css-tokenizer': ^3.0.4
|
| 171 |
+
|
| 172 |
+
'@csstools/css-tokenizer@3.0.4':
|
| 173 |
+
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
| 174 |
+
engines: {node: '>=18'}
|
| 175 |
+
|
| 176 |
+
'@esbuild/aix-ppc64@0.25.12':
|
| 177 |
+
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
| 178 |
+
engines: {node: '>=18'}
|
| 179 |
+
cpu: [ppc64]
|
| 180 |
+
os: [aix]
|
| 181 |
+
|
| 182 |
+
'@esbuild/aix-ppc64@0.27.3':
|
| 183 |
+
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
| 184 |
+
engines: {node: '>=18'}
|
| 185 |
+
cpu: [ppc64]
|
| 186 |
+
os: [aix]
|
| 187 |
+
|
| 188 |
+
'@esbuild/android-arm64@0.25.12':
|
| 189 |
+
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
| 190 |
+
engines: {node: '>=18'}
|
| 191 |
+
cpu: [arm64]
|
| 192 |
+
os: [android]
|
| 193 |
+
|
| 194 |
+
'@esbuild/android-arm64@0.27.3':
|
| 195 |
+
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
| 196 |
+
engines: {node: '>=18'}
|
| 197 |
+
cpu: [arm64]
|
| 198 |
+
os: [android]
|
| 199 |
+
|
| 200 |
+
'@esbuild/android-arm@0.25.12':
|
| 201 |
+
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
| 202 |
+
engines: {node: '>=18'}
|
| 203 |
+
cpu: [arm]
|
| 204 |
+
os: [android]
|
| 205 |
+
|
| 206 |
+
'@esbuild/android-arm@0.27.3':
|
| 207 |
+
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
| 208 |
+
engines: {node: '>=18'}
|
| 209 |
+
cpu: [arm]
|
| 210 |
+
os: [android]
|
| 211 |
+
|
| 212 |
+
'@esbuild/android-x64@0.25.12':
|
| 213 |
+
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
| 214 |
+
engines: {node: '>=18'}
|
| 215 |
+
cpu: [x64]
|
| 216 |
+
os: [android]
|
| 217 |
+
|
| 218 |
+
'@esbuild/android-x64@0.27.3':
|
| 219 |
+
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
| 220 |
+
engines: {node: '>=18'}
|
| 221 |
+
cpu: [x64]
|
| 222 |
+
os: [android]
|
| 223 |
+
|
| 224 |
+
'@esbuild/darwin-arm64@0.25.12':
|
| 225 |
+
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
| 226 |
+
engines: {node: '>=18'}
|
| 227 |
+
cpu: [arm64]
|
| 228 |
+
os: [darwin]
|
| 229 |
+
|
| 230 |
+
'@esbuild/darwin-arm64@0.27.3':
|
| 231 |
+
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
| 232 |
+
engines: {node: '>=18'}
|
| 233 |
+
cpu: [arm64]
|
| 234 |
+
os: [darwin]
|
| 235 |
+
|
| 236 |
+
'@esbuild/darwin-x64@0.25.12':
|
| 237 |
+
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
| 238 |
+
engines: {node: '>=18'}
|
| 239 |
+
cpu: [x64]
|
| 240 |
+
os: [darwin]
|
| 241 |
+
|
| 242 |
+
'@esbuild/darwin-x64@0.27.3':
|
| 243 |
+
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
| 244 |
+
engines: {node: '>=18'}
|
| 245 |
+
cpu: [x64]
|
| 246 |
+
os: [darwin]
|
| 247 |
+
|
| 248 |
+
'@esbuild/freebsd-arm64@0.25.12':
|
| 249 |
+
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
| 250 |
+
engines: {node: '>=18'}
|
| 251 |
+
cpu: [arm64]
|
| 252 |
+
os: [freebsd]
|
| 253 |
+
|
| 254 |
+
'@esbuild/freebsd-arm64@0.27.3':
|
| 255 |
+
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
| 256 |
+
engines: {node: '>=18'}
|
| 257 |
+
cpu: [arm64]
|
| 258 |
+
os: [freebsd]
|
| 259 |
+
|
| 260 |
+
'@esbuild/freebsd-x64@0.25.12':
|
| 261 |
+
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
| 262 |
+
engines: {node: '>=18'}
|
| 263 |
+
cpu: [x64]
|
| 264 |
+
os: [freebsd]
|
| 265 |
+
|
| 266 |
+
'@esbuild/freebsd-x64@0.27.3':
|
| 267 |
+
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
| 268 |
+
engines: {node: '>=18'}
|
| 269 |
+
cpu: [x64]
|
| 270 |
+
os: [freebsd]
|
| 271 |
+
|
| 272 |
+
'@esbuild/linux-arm64@0.25.12':
|
| 273 |
+
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
| 274 |
+
engines: {node: '>=18'}
|
| 275 |
+
cpu: [arm64]
|
| 276 |
+
os: [linux]
|
| 277 |
+
|
| 278 |
+
'@esbuild/linux-arm64@0.27.3':
|
| 279 |
+
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
| 280 |
+
engines: {node: '>=18'}
|
| 281 |
+
cpu: [arm64]
|
| 282 |
+
os: [linux]
|
| 283 |
+
|
| 284 |
+
'@esbuild/linux-arm@0.25.12':
|
| 285 |
+
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
| 286 |
+
engines: {node: '>=18'}
|
| 287 |
+
cpu: [arm]
|
| 288 |
+
os: [linux]
|
| 289 |
+
|
| 290 |
+
'@esbuild/linux-arm@0.27.3':
|
| 291 |
+
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
| 292 |
+
engines: {node: '>=18'}
|
| 293 |
+
cpu: [arm]
|
| 294 |
+
os: [linux]
|
| 295 |
+
|
| 296 |
+
'@esbuild/linux-ia32@0.25.12':
|
| 297 |
+
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
| 298 |
+
engines: {node: '>=18'}
|
| 299 |
+
cpu: [ia32]
|
| 300 |
+
os: [linux]
|
| 301 |
+
|
| 302 |
+
'@esbuild/linux-ia32@0.27.3':
|
| 303 |
+
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
| 304 |
+
engines: {node: '>=18'}
|
| 305 |
+
cpu: [ia32]
|
| 306 |
+
os: [linux]
|
| 307 |
+
|
| 308 |
+
'@esbuild/linux-loong64@0.25.12':
|
| 309 |
+
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
| 310 |
+
engines: {node: '>=18'}
|
| 311 |
+
cpu: [loong64]
|
| 312 |
+
os: [linux]
|
| 313 |
+
|
| 314 |
+
'@esbuild/linux-loong64@0.27.3':
|
| 315 |
+
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
| 316 |
+
engines: {node: '>=18'}
|
| 317 |
+
cpu: [loong64]
|
| 318 |
+
os: [linux]
|
| 319 |
+
|
| 320 |
+
'@esbuild/linux-mips64el@0.25.12':
|
| 321 |
+
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
| 322 |
+
engines: {node: '>=18'}
|
| 323 |
+
cpu: [mips64el]
|
| 324 |
+
os: [linux]
|
| 325 |
+
|
| 326 |
+
'@esbuild/linux-mips64el@0.27.3':
|
| 327 |
+
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
| 328 |
+
engines: {node: '>=18'}
|
| 329 |
+
cpu: [mips64el]
|
| 330 |
+
os: [linux]
|
| 331 |
+
|
| 332 |
+
'@esbuild/linux-ppc64@0.25.12':
|
| 333 |
+
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
| 334 |
+
engines: {node: '>=18'}
|
| 335 |
+
cpu: [ppc64]
|
| 336 |
+
os: [linux]
|
| 337 |
+
|
| 338 |
+
'@esbuild/linux-ppc64@0.27.3':
|
| 339 |
+
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
| 340 |
+
engines: {node: '>=18'}
|
| 341 |
+
cpu: [ppc64]
|
| 342 |
+
os: [linux]
|
| 343 |
+
|
| 344 |
+
'@esbuild/linux-riscv64@0.25.12':
|
| 345 |
+
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
| 346 |
+
engines: {node: '>=18'}
|
| 347 |
+
cpu: [riscv64]
|
| 348 |
+
os: [linux]
|
| 349 |
+
|
| 350 |
+
'@esbuild/linux-riscv64@0.27.3':
|
| 351 |
+
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
| 352 |
+
engines: {node: '>=18'}
|
| 353 |
+
cpu: [riscv64]
|
| 354 |
+
os: [linux]
|
| 355 |
+
|
| 356 |
+
'@esbuild/linux-s390x@0.25.12':
|
| 357 |
+
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
| 358 |
+
engines: {node: '>=18'}
|
| 359 |
+
cpu: [s390x]
|
| 360 |
+
os: [linux]
|
| 361 |
+
|
| 362 |
+
'@esbuild/linux-s390x@0.27.3':
|
| 363 |
+
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
| 364 |
+
engines: {node: '>=18'}
|
| 365 |
+
cpu: [s390x]
|
| 366 |
+
os: [linux]
|
| 367 |
+
|
| 368 |
+
'@esbuild/linux-x64@0.25.12':
|
| 369 |
+
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
| 370 |
+
engines: {node: '>=18'}
|
| 371 |
+
cpu: [x64]
|
| 372 |
+
os: [linux]
|
| 373 |
+
|
| 374 |
+
'@esbuild/linux-x64@0.27.3':
|
| 375 |
+
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
| 376 |
+
engines: {node: '>=18'}
|
| 377 |
+
cpu: [x64]
|
| 378 |
+
os: [linux]
|
| 379 |
+
|
| 380 |
+
'@esbuild/netbsd-arm64@0.25.12':
|
| 381 |
+
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
| 382 |
+
engines: {node: '>=18'}
|
| 383 |
+
cpu: [arm64]
|
| 384 |
+
os: [netbsd]
|
| 385 |
+
|
| 386 |
+
'@esbuild/netbsd-arm64@0.27.3':
|
| 387 |
+
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
| 388 |
+
engines: {node: '>=18'}
|
| 389 |
+
cpu: [arm64]
|
| 390 |
+
os: [netbsd]
|
| 391 |
+
|
| 392 |
+
'@esbuild/netbsd-x64@0.25.12':
|
| 393 |
+
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
| 394 |
+
engines: {node: '>=18'}
|
| 395 |
+
cpu: [x64]
|
| 396 |
+
os: [netbsd]
|
| 397 |
+
|
| 398 |
+
'@esbuild/netbsd-x64@0.27.3':
|
| 399 |
+
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
| 400 |
+
engines: {node: '>=18'}
|
| 401 |
+
cpu: [x64]
|
| 402 |
+
os: [netbsd]
|
| 403 |
+
|
| 404 |
+
'@esbuild/openbsd-arm64@0.25.12':
|
| 405 |
+
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
| 406 |
+
engines: {node: '>=18'}
|
| 407 |
+
cpu: [arm64]
|
| 408 |
+
os: [openbsd]
|
| 409 |
+
|
| 410 |
+
'@esbuild/openbsd-arm64@0.27.3':
|
| 411 |
+
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
| 412 |
+
engines: {node: '>=18'}
|
| 413 |
+
cpu: [arm64]
|
| 414 |
+
os: [openbsd]
|
| 415 |
+
|
| 416 |
+
'@esbuild/openbsd-x64@0.25.12':
|
| 417 |
+
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
| 418 |
+
engines: {node: '>=18'}
|
| 419 |
+
cpu: [x64]
|
| 420 |
+
os: [openbsd]
|
| 421 |
+
|
| 422 |
+
'@esbuild/openbsd-x64@0.27.3':
|
| 423 |
+
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
| 424 |
+
engines: {node: '>=18'}
|
| 425 |
+
cpu: [x64]
|
| 426 |
+
os: [openbsd]
|
| 427 |
+
|
| 428 |
+
'@esbuild/openharmony-arm64@0.25.12':
|
| 429 |
+
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
| 430 |
+
engines: {node: '>=18'}
|
| 431 |
+
cpu: [arm64]
|
| 432 |
+
os: [openharmony]
|
| 433 |
+
|
| 434 |
+
'@esbuild/openharmony-arm64@0.27.3':
|
| 435 |
+
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
| 436 |
+
engines: {node: '>=18'}
|
| 437 |
+
cpu: [arm64]
|
| 438 |
+
os: [openharmony]
|
| 439 |
+
|
| 440 |
+
'@esbuild/sunos-x64@0.25.12':
|
| 441 |
+
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
| 442 |
+
engines: {node: '>=18'}
|
| 443 |
+
cpu: [x64]
|
| 444 |
+
os: [sunos]
|
| 445 |
+
|
| 446 |
+
'@esbuild/sunos-x64@0.27.3':
|
| 447 |
+
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
| 448 |
+
engines: {node: '>=18'}
|
| 449 |
+
cpu: [x64]
|
| 450 |
+
os: [sunos]
|
| 451 |
+
|
| 452 |
+
'@esbuild/win32-arm64@0.25.12':
|
| 453 |
+
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
| 454 |
+
engines: {node: '>=18'}
|
| 455 |
+
cpu: [arm64]
|
| 456 |
+
os: [win32]
|
| 457 |
+
|
| 458 |
+
'@esbuild/win32-arm64@0.27.3':
|
| 459 |
+
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
| 460 |
+
engines: {node: '>=18'}
|
| 461 |
+
cpu: [arm64]
|
| 462 |
+
os: [win32]
|
| 463 |
+
|
| 464 |
+
'@esbuild/win32-ia32@0.25.12':
|
| 465 |
+
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
| 466 |
+
engines: {node: '>=18'}
|
| 467 |
+
cpu: [ia32]
|
| 468 |
+
os: [win32]
|
| 469 |
+
|
| 470 |
+
'@esbuild/win32-ia32@0.27.3':
|
| 471 |
+
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
| 472 |
+
engines: {node: '>=18'}
|
| 473 |
+
cpu: [ia32]
|
| 474 |
+
os: [win32]
|
| 475 |
+
|
| 476 |
+
'@esbuild/win32-x64@0.25.12':
|
| 477 |
+
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
| 478 |
+
engines: {node: '>=18'}
|
| 479 |
+
cpu: [x64]
|
| 480 |
+
os: [win32]
|
| 481 |
+
|
| 482 |
+
'@esbuild/win32-x64@0.27.3':
|
| 483 |
+
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
| 484 |
+
engines: {node: '>=18'}
|
| 485 |
+
cpu: [x64]
|
| 486 |
+
os: [win32]
|
| 487 |
+
|
| 488 |
+
'@jcubic/tagger@0.6.2':
|
| 489 |
+
resolution: {integrity: sha512-Pcs/cx8+GXRUuAyxDLKGE+NutXVOaqixTMZhde40R8gMg+paLzdfO3LmmXZ1IYmkm8Nb3a2RyG2N8ZLxcIR3fg==}
|
| 490 |
+
|
| 491 |
+
'@jridgewell/gen-mapping@0.3.13':
|
| 492 |
+
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
| 493 |
+
|
| 494 |
+
'@jridgewell/remapping@2.3.5':
|
| 495 |
+
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
| 496 |
+
|
| 497 |
+
'@jridgewell/resolve-uri@3.1.2':
|
| 498 |
+
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
| 499 |
+
engines: {node: '>=6.0.0'}
|
| 500 |
+
|
| 501 |
+
'@jridgewell/sourcemap-codec@1.5.5':
|
| 502 |
+
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
| 503 |
+
|
| 504 |
+
'@jridgewell/trace-mapping@0.3.31':
|
| 505 |
+
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
| 506 |
+
|
| 507 |
+
'@msgpack/msgpack@3.1.3':
|
| 508 |
+
resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
|
| 509 |
+
engines: {node: '>= 18'}
|
| 510 |
+
|
| 511 |
+
'@napi-rs/canvas-android-arm64@0.1.95':
|
| 512 |
+
resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
|
| 513 |
+
engines: {node: '>= 10'}
|
| 514 |
+
cpu: [arm64]
|
| 515 |
+
os: [android]
|
| 516 |
+
|
| 517 |
+
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
| 518 |
+
resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
|
| 519 |
+
engines: {node: '>= 10'}
|
| 520 |
+
cpu: [arm64]
|
| 521 |
+
os: [darwin]
|
| 522 |
+
|
| 523 |
+
'@napi-rs/canvas-darwin-x64@0.1.95':
|
| 524 |
+
resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
|
| 525 |
+
engines: {node: '>= 10'}
|
| 526 |
+
cpu: [x64]
|
| 527 |
+
os: [darwin]
|
| 528 |
+
|
| 529 |
+
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
| 530 |
+
resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
|
| 531 |
+
engines: {node: '>= 10'}
|
| 532 |
+
cpu: [arm]
|
| 533 |
+
os: [linux]
|
| 534 |
+
|
| 535 |
+
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
| 536 |
+
resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
|
| 537 |
+
engines: {node: '>= 10'}
|
| 538 |
+
cpu: [arm64]
|
| 539 |
+
os: [linux]
|
| 540 |
+
|
| 541 |
+
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
| 542 |
+
resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
|
| 543 |
+
engines: {node: '>= 10'}
|
| 544 |
+
cpu: [arm64]
|
| 545 |
+
os: [linux]
|
| 546 |
+
|
| 547 |
+
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
| 548 |
+
resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
|
| 549 |
+
engines: {node: '>= 10'}
|
| 550 |
+
cpu: [riscv64]
|
| 551 |
+
os: [linux]
|
| 552 |
+
|
| 553 |
+
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
| 554 |
+
resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
|
| 555 |
+
engines: {node: '>= 10'}
|
| 556 |
+
cpu: [x64]
|
| 557 |
+
os: [linux]
|
| 558 |
+
|
| 559 |
+
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
| 560 |
+
resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
|
| 561 |
+
engines: {node: '>= 10'}
|
| 562 |
+
cpu: [x64]
|
| 563 |
+
os: [linux]
|
| 564 |
+
|
| 565 |
+
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
| 566 |
+
resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
|
| 567 |
+
engines: {node: '>= 10'}
|
| 568 |
+
cpu: [arm64]
|
| 569 |
+
os: [win32]
|
| 570 |
+
|
| 571 |
+
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
| 572 |
+
resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
|
| 573 |
+
engines: {node: '>= 10'}
|
| 574 |
+
cpu: [x64]
|
| 575 |
+
os: [win32]
|
| 576 |
+
|
| 577 |
+
'@napi-rs/canvas@0.1.95':
|
| 578 |
+
resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
|
| 579 |
+
engines: {node: '>= 10'}
|
| 580 |
+
|
| 581 |
+
'@parcel/watcher-android-arm64@2.5.6':
|
| 582 |
+
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
| 583 |
+
engines: {node: '>= 10.0.0'}
|
| 584 |
+
cpu: [arm64]
|
| 585 |
+
os: [android]
|
| 586 |
+
|
| 587 |
+
'@parcel/watcher-darwin-arm64@2.5.6':
|
| 588 |
+
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
|
| 589 |
+
engines: {node: '>= 10.0.0'}
|
| 590 |
+
cpu: [arm64]
|
| 591 |
+
os: [darwin]
|
| 592 |
+
|
| 593 |
+
'@parcel/watcher-darwin-x64@2.5.6':
|
| 594 |
+
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
|
| 595 |
+
engines: {node: '>= 10.0.0'}
|
| 596 |
+
cpu: [x64]
|
| 597 |
+
os: [darwin]
|
| 598 |
+
|
| 599 |
+
'@parcel/watcher-freebsd-x64@2.5.6':
|
| 600 |
+
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
|
| 601 |
+
engines: {node: '>= 10.0.0'}
|
| 602 |
+
cpu: [x64]
|
| 603 |
+
os: [freebsd]
|
| 604 |
+
|
| 605 |
+
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
| 606 |
+
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
|
| 607 |
+
engines: {node: '>= 10.0.0'}
|
| 608 |
+
cpu: [arm]
|
| 609 |
+
os: [linux]
|
| 610 |
+
|
| 611 |
+
'@parcel/watcher-linux-arm-musl@2.5.6':
|
| 612 |
+
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
|
| 613 |
+
engines: {node: '>= 10.0.0'}
|
| 614 |
+
cpu: [arm]
|
| 615 |
+
os: [linux]
|
| 616 |
+
|
| 617 |
+
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
| 618 |
+
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
|
| 619 |
+
engines: {node: '>= 10.0.0'}
|
| 620 |
+
cpu: [arm64]
|
| 621 |
+
os: [linux]
|
| 622 |
+
|
| 623 |
+
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
| 624 |
+
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
|
| 625 |
+
engines: {node: '>= 10.0.0'}
|
| 626 |
+
cpu: [arm64]
|
| 627 |
+
os: [linux]
|
| 628 |
+
|
| 629 |
+
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
| 630 |
+
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
|
| 631 |
+
engines: {node: '>= 10.0.0'}
|
| 632 |
+
cpu: [x64]
|
| 633 |
+
os: [linux]
|
| 634 |
+
|
| 635 |
+
'@parcel/watcher-linux-x64-musl@2.5.6':
|
| 636 |
+
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
|
| 637 |
+
engines: {node: '>= 10.0.0'}
|
| 638 |
+
cpu: [x64]
|
| 639 |
+
os: [linux]
|
| 640 |
+
|
| 641 |
+
'@parcel/watcher-win32-arm64@2.5.6':
|
| 642 |
+
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
|
| 643 |
+
engines: {node: '>= 10.0.0'}
|
| 644 |
+
cpu: [arm64]
|
| 645 |
+
os: [win32]
|
| 646 |
+
|
| 647 |
+
'@parcel/watcher-win32-ia32@2.5.6':
|
| 648 |
+
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
|
| 649 |
+
engines: {node: '>= 10.0.0'}
|
| 650 |
+
cpu: [ia32]
|
| 651 |
+
os: [win32]
|
| 652 |
+
|
| 653 |
+
'@parcel/watcher-win32-x64@2.5.6':
|
| 654 |
+
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
|
| 655 |
+
engines: {node: '>= 10.0.0'}
|
| 656 |
+
cpu: [x64]
|
| 657 |
+
os: [win32]
|
| 658 |
+
|
| 659 |
+
'@parcel/watcher@2.5.6':
|
| 660 |
+
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
| 661 |
+
engines: {node: '>= 10.0.0'}
|
| 662 |
+
|
| 663 |
+
'@playwright/test@1.58.2':
|
| 664 |
+
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
| 665 |
+
engines: {node: '>=18'}
|
| 666 |
+
hasBin: true
|
| 667 |
+
|
| 668 |
+
'@polka/url@1.0.0-next.29':
|
| 669 |
+
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
| 670 |
+
|
| 671 |
+
'@rollup/rollup-android-arm-eabi@4.59.0':
|
| 672 |
+
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
|
| 673 |
+
cpu: [arm]
|
| 674 |
+
os: [android]
|
| 675 |
+
|
| 676 |
+
'@rollup/rollup-android-arm64@4.59.0':
|
| 677 |
+
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
|
| 678 |
+
cpu: [arm64]
|
| 679 |
+
os: [android]
|
| 680 |
+
|
| 681 |
+
'@rollup/rollup-darwin-arm64@4.59.0':
|
| 682 |
+
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
|
| 683 |
+
cpu: [arm64]
|
| 684 |
+
os: [darwin]
|
| 685 |
+
|
| 686 |
+
'@rollup/rollup-darwin-x64@4.59.0':
|
| 687 |
+
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
|
| 688 |
+
cpu: [x64]
|
| 689 |
+
os: [darwin]
|
| 690 |
+
|
| 691 |
+
'@rollup/rollup-freebsd-arm64@4.59.0':
|
| 692 |
+
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
|
| 693 |
+
cpu: [arm64]
|
| 694 |
+
os: [freebsd]
|
| 695 |
+
|
| 696 |
+
'@rollup/rollup-freebsd-x64@4.59.0':
|
| 697 |
+
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
|
| 698 |
+
cpu: [x64]
|
| 699 |
+
os: [freebsd]
|
| 700 |
+
|
| 701 |
+
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
| 702 |
+
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
| 703 |
+
cpu: [arm]
|
| 704 |
+
os: [linux]
|
| 705 |
+
|
| 706 |
+
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
| 707 |
+
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
| 708 |
+
cpu: [arm]
|
| 709 |
+
os: [linux]
|
| 710 |
+
|
| 711 |
+
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
| 712 |
+
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
| 713 |
+
cpu: [arm64]
|
| 714 |
+
os: [linux]
|
| 715 |
+
|
| 716 |
+
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
| 717 |
+
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
| 718 |
+
cpu: [arm64]
|
| 719 |
+
os: [linux]
|
| 720 |
+
|
| 721 |
+
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
| 722 |
+
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
| 723 |
+
cpu: [loong64]
|
| 724 |
+
os: [linux]
|
| 725 |
+
|
| 726 |
+
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
| 727 |
+
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
| 728 |
+
cpu: [loong64]
|
| 729 |
+
os: [linux]
|
| 730 |
+
|
| 731 |
+
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
| 732 |
+
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
| 733 |
+
cpu: [ppc64]
|
| 734 |
+
os: [linux]
|
| 735 |
+
|
| 736 |
+
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
| 737 |
+
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
| 738 |
+
cpu: [ppc64]
|
| 739 |
+
os: [linux]
|
| 740 |
+
|
| 741 |
+
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
| 742 |
+
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
| 743 |
+
cpu: [riscv64]
|
| 744 |
+
os: [linux]
|
| 745 |
+
|
| 746 |
+
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
| 747 |
+
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
| 748 |
+
cpu: [riscv64]
|
| 749 |
+
os: [linux]
|
| 750 |
+
|
| 751 |
+
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
| 752 |
+
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
| 753 |
+
cpu: [s390x]
|
| 754 |
+
os: [linux]
|
| 755 |
+
|
| 756 |
+
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
| 757 |
+
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
| 758 |
+
cpu: [x64]
|
| 759 |
+
os: [linux]
|
| 760 |
+
|
| 761 |
+
'@rollup/rollup-linux-x64-musl@4.59.0':
|
| 762 |
+
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
| 763 |
+
cpu: [x64]
|
| 764 |
+
os: [linux]
|
| 765 |
+
|
| 766 |
+
'@rollup/rollup-openbsd-x64@4.59.0':
|
| 767 |
+
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
| 768 |
+
cpu: [x64]
|
| 769 |
+
os: [openbsd]
|
| 770 |
+
|
| 771 |
+
'@rollup/rollup-openharmony-arm64@4.59.0':
|
| 772 |
+
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
|
| 773 |
+
cpu: [arm64]
|
| 774 |
+
os: [openharmony]
|
| 775 |
+
|
| 776 |
+
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
| 777 |
+
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
|
| 778 |
+
cpu: [arm64]
|
| 779 |
+
os: [win32]
|
| 780 |
+
|
| 781 |
+
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
| 782 |
+
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
|
| 783 |
+
cpu: [ia32]
|
| 784 |
+
os: [win32]
|
| 785 |
+
|
| 786 |
+
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
| 787 |
+
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
|
| 788 |
+
cpu: [x64]
|
| 789 |
+
os: [win32]
|
| 790 |
+
|
| 791 |
+
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
| 792 |
+
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
|
| 793 |
+
cpu: [x64]
|
| 794 |
+
os: [win32]
|
| 795 |
+
|
| 796 |
+
'@standard-schema/spec@1.1.0':
|
| 797 |
+
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
| 798 |
+
|
| 799 |
+
'@swc/core-darwin-arm64@1.15.17':
|
| 800 |
+
resolution: {integrity: sha512-eB9qdyt4E60323IS0rgV/rd79DJ+YWSyIKi+sT1dlIgR3ns4xlBiunREM3lVH0FKcUbhttiBvdVubT4QoOuZ+w==}
|
| 801 |
+
engines: {node: '>=10'}
|
| 802 |
+
cpu: [arm64]
|
| 803 |
+
os: [darwin]
|
| 804 |
+
|
| 805 |
+
'@swc/core-darwin-x64@1.15.17':
|
| 806 |
+
resolution: {integrity: sha512-k1TZARYs8947jJpSioqcPrusz+wEeABF4iiSdwcSyQh2rIUdIEk5FOyaqJASFPJ6dZfx7ZVOyjtDATVAegs2/Q==}
|
| 807 |
+
engines: {node: '>=10'}
|
| 808 |
+
cpu: [x64]
|
| 809 |
+
os: [darwin]
|
| 810 |
+
|
| 811 |
+
'@swc/core-linux-arm-gnueabihf@1.15.17':
|
| 812 |
+
resolution: {integrity: sha512-p6282NQZo5bzx0wphz1ETGjhcRB9CN+/XUAjQwApyoyX9iCloI5IT/RC3vjbflo42g8RPTxUTaItAO0hlLSesQ==}
|
| 813 |
+
engines: {node: '>=10'}
|
| 814 |
+
cpu: [arm]
|
| 815 |
+
os: [linux]
|
| 816 |
+
|
| 817 |
+
'@swc/core-linux-arm64-gnu@1.15.17':
|
| 818 |
+
resolution: {integrity: sha512-TGnDS4ejy8y9jqxXqZCyA+DvFc64nXUHS9rxdyeJ9B9uyIdtKVhBrA2xfghYRS/sSPSyHZ0yu89NxBICvONH+A==}
|
| 819 |
+
engines: {node: '>=10'}
|
| 820 |
+
cpu: [arm64]
|
| 821 |
+
os: [linux]
|
| 822 |
+
|
| 823 |
+
'@swc/core-linux-arm64-musl@1.15.17':
|
| 824 |
+
resolution: {integrity: sha512-D0/6Hj4CkgSTTahtlGxv9IDsLTuvQz30mkZEMDp8TqwYhCL8AomznkibwlQU8HtY4q/dqd1OGRPH+FmNb4BBEA==}
|
| 825 |
+
engines: {node: '>=10'}
|
| 826 |
+
cpu: [arm64]
|
| 827 |
+
os: [linux]
|
| 828 |
+
|
| 829 |
+
'@swc/core-linux-x64-gnu@1.15.17':
|
| 830 |
+
resolution: {integrity: sha512-1s2OFsg6DeRkWU7c+PIfIHZsFCbiZ34akXFHrg7KjpF8zIvpHZNoUUZimoWEwcB6GquXSkAO+1b5KpG5nusTeQ==}
|
| 831 |
+
engines: {node: '>=10'}
|
| 832 |
+
cpu: [x64]
|
| 833 |
+
os: [linux]
|
| 834 |
+
|
| 835 |
+
'@swc/core-linux-x64-musl@1.15.17':
|
| 836 |
+
resolution: {integrity: sha512-gtxGMGYtRWWmCcgx6xM2Yos43uiE/j8kZwkeL/LNGG9zM0tatd23NsfL9PnQJ45hY7QZ+dx2rM68e4ArgG4kJg==}
|
| 837 |
+
engines: {node: '>=10'}
|
| 838 |
+
cpu: [x64]
|
| 839 |
+
os: [linux]
|
| 840 |
+
|
| 841 |
+
'@swc/core-win32-arm64-msvc@1.15.17':
|
| 842 |
+
resolution: {integrity: sha512-gxi+/Miytez/O9vJ/QiheIivA3oWZjPp9nJu3VmAfLMWUzcZORMwgaI1ygtDTLjz7CzcwlGMJz/Ab66Y5DfNpg==}
|
| 843 |
+
engines: {node: '>=10'}
|
| 844 |
+
cpu: [arm64]
|
| 845 |
+
os: [win32]
|
| 846 |
+
|
| 847 |
+
'@swc/core-win32-ia32-msvc@1.15.17':
|
| 848 |
+
resolution: {integrity: sha512-KUsRqNbTp7SpNK0T9m4+i8GlngzNjwb69a3ttKA6XJ5r6Pewm+NSYji93pNkawXIivbWY2jhvceGMAyd+4hWaQ==}
|
| 849 |
+
engines: {node: '>=10'}
|
| 850 |
+
cpu: [ia32]
|
| 851 |
+
os: [win32]
|
| 852 |
+
|
| 853 |
+
'@swc/core-win32-x64-msvc@1.15.17':
|
| 854 |
+
resolution: {integrity: sha512-zqtEGE0/rTKvEC5sOtpANLHeWEPjsTD4/rwpUxo6ymztcLI/Z+L9Wi9xQvIGmLTUih1gvNZcAwROqdfRP3oAWQ==}
|
| 855 |
+
engines: {node: '>=10'}
|
| 856 |
+
cpu: [x64]
|
| 857 |
+
os: [win32]
|
| 858 |
+
|
| 859 |
+
'@swc/core@1.15.17':
|
| 860 |
+
resolution: {integrity: sha512-Mu3eOrYlkdQPl7yqotNckitTr6FZ0yd7mlWIBEzK+EGIyybgMENJHmbS2DeA7BMleJiBElP6ke+Nz93pkKmKJw==}
|
| 861 |
+
engines: {node: '>=10'}
|
| 862 |
+
peerDependencies:
|
| 863 |
+
'@swc/helpers': '>=0.5.17'
|
| 864 |
+
peerDependenciesMeta:
|
| 865 |
+
'@swc/helpers':
|
| 866 |
+
optional: true
|
| 867 |
+
|
| 868 |
+
'@swc/counter@0.1.3':
|
| 869 |
+
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
| 870 |
+
|
| 871 |
+
'@swc/types@0.1.25':
|
| 872 |
+
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
|
| 873 |
+
|
| 874 |
+
'@tailwindcss/cli@4.2.1':
|
| 875 |
+
resolution: {integrity: sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==}
|
| 876 |
+
hasBin: true
|
| 877 |
+
|
| 878 |
+
'@tailwindcss/node@4.2.1':
|
| 879 |
+
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
|
| 880 |
+
|
| 881 |
+
'@tailwindcss/oxide-android-arm64@4.2.1':
|
| 882 |
+
resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
|
| 883 |
+
engines: {node: '>= 20'}
|
| 884 |
+
cpu: [arm64]
|
| 885 |
+
os: [android]
|
| 886 |
+
|
| 887 |
+
'@tailwindcss/oxide-darwin-arm64@4.2.1':
|
| 888 |
+
resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
|
| 889 |
+
engines: {node: '>= 20'}
|
| 890 |
+
cpu: [arm64]
|
| 891 |
+
os: [darwin]
|
| 892 |
+
|
| 893 |
+
'@tailwindcss/oxide-darwin-x64@4.2.1':
|
| 894 |
+
resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
|
| 895 |
+
engines: {node: '>= 20'}
|
| 896 |
+
cpu: [x64]
|
| 897 |
+
os: [darwin]
|
| 898 |
+
|
| 899 |
+
'@tailwindcss/oxide-freebsd-x64@4.2.1':
|
| 900 |
+
resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
|
| 901 |
+
engines: {node: '>= 20'}
|
| 902 |
+
cpu: [x64]
|
| 903 |
+
os: [freebsd]
|
| 904 |
+
|
| 905 |
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
|
| 906 |
+
resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
|
| 907 |
+
engines: {node: '>= 20'}
|
| 908 |
+
cpu: [arm]
|
| 909 |
+
os: [linux]
|
| 910 |
+
|
| 911 |
+
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
|
| 912 |
+
resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
|
| 913 |
+
engines: {node: '>= 20'}
|
| 914 |
+
cpu: [arm64]
|
| 915 |
+
os: [linux]
|
| 916 |
+
|
| 917 |
+
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
| 918 |
+
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
|
| 919 |
+
engines: {node: '>= 20'}
|
| 920 |
+
cpu: [arm64]
|
| 921 |
+
os: [linux]
|
| 922 |
+
|
| 923 |
+
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
| 924 |
+
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
|
| 925 |
+
engines: {node: '>= 20'}
|
| 926 |
+
cpu: [x64]
|
| 927 |
+
os: [linux]
|
| 928 |
+
|
| 929 |
+
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
| 930 |
+
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
|
| 931 |
+
engines: {node: '>= 20'}
|
| 932 |
+
cpu: [x64]
|
| 933 |
+
os: [linux]
|
| 934 |
+
|
| 935 |
+
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
| 936 |
+
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
|
| 937 |
+
engines: {node: '>=14.0.0'}
|
| 938 |
+
cpu: [wasm32]
|
| 939 |
+
bundledDependencies:
|
| 940 |
+
- '@napi-rs/wasm-runtime'
|
| 941 |
+
- '@emnapi/core'
|
| 942 |
+
- '@emnapi/runtime'
|
| 943 |
+
- '@tybys/wasm-util'
|
| 944 |
+
- '@emnapi/wasi-threads'
|
| 945 |
+
- tslib
|
| 946 |
+
|
| 947 |
+
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
|
| 948 |
+
resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
|
| 949 |
+
engines: {node: '>= 20'}
|
| 950 |
+
cpu: [arm64]
|
| 951 |
+
os: [win32]
|
| 952 |
+
|
| 953 |
+
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
|
| 954 |
+
resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
|
| 955 |
+
engines: {node: '>= 20'}
|
| 956 |
+
cpu: [x64]
|
| 957 |
+
os: [win32]
|
| 958 |
+
|
| 959 |
+
'@tailwindcss/oxide@4.2.1':
|
| 960 |
+
resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
|
| 961 |
+
engines: {node: '>= 20'}
|
| 962 |
+
|
| 963 |
+
'@testing-library/dom@10.4.1':
|
| 964 |
+
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
| 965 |
+
engines: {node: '>=18'}
|
| 966 |
+
|
| 967 |
+
'@types/aria-query@5.0.4':
|
| 968 |
+
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
| 969 |
+
|
| 970 |
+
'@types/chai@5.2.3':
|
| 971 |
+
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
| 972 |
+
|
| 973 |
+
'@types/deep-eql@4.0.2':
|
| 974 |
+
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
| 975 |
+
|
| 976 |
+
'@types/estree@1.0.8':
|
| 977 |
+
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
| 978 |
+
|
| 979 |
+
'@types/node@25.3.2':
|
| 980 |
+
resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==}
|
| 981 |
+
|
| 982 |
+
'@types/whatwg-mimetype@3.0.2':
|
| 983 |
+
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
| 984 |
+
|
| 985 |
+
'@types/ws@8.18.1':
|
| 986 |
+
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
| 987 |
+
|
| 988 |
+
'@vitest/coverage-v8@4.0.18':
|
| 989 |
+
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
| 990 |
+
peerDependencies:
|
| 991 |
+
'@vitest/browser': 4.0.18
|
| 992 |
+
vitest: 4.0.18
|
| 993 |
+
peerDependenciesMeta:
|
| 994 |
+
'@vitest/browser':
|
| 995 |
+
optional: true
|
| 996 |
+
|
| 997 |
+
'@vitest/expect@4.0.18':
|
| 998 |
+
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
| 999 |
+
|
| 1000 |
+
'@vitest/mocker@4.0.18':
|
| 1001 |
+
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
|
| 1002 |
+
peerDependencies:
|
| 1003 |
+
msw: ^2.4.9
|
| 1004 |
+
vite: ^6.0.0 || ^7.0.0-0
|
| 1005 |
+
peerDependenciesMeta:
|
| 1006 |
+
msw:
|
| 1007 |
+
optional: true
|
| 1008 |
+
vite:
|
| 1009 |
+
optional: true
|
| 1010 |
+
|
| 1011 |
+
'@vitest/pretty-format@4.0.18':
|
| 1012 |
+
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
| 1013 |
+
|
| 1014 |
+
'@vitest/runner@4.0.18':
|
| 1015 |
+
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
| 1016 |
+
|
| 1017 |
+
'@vitest/snapshot@4.0.18':
|
| 1018 |
+
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
| 1019 |
+
|
| 1020 |
+
'@vitest/spy@4.0.18':
|
| 1021 |
+
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
| 1022 |
+
|
| 1023 |
+
'@vitest/ui@4.0.18':
|
| 1024 |
+
resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==}
|
| 1025 |
+
peerDependencies:
|
| 1026 |
+
vitest: 4.0.18
|
| 1027 |
+
|
| 1028 |
+
'@vitest/utils@4.0.18':
|
| 1029 |
+
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
| 1030 |
+
|
| 1031 |
+
agent-base@7.1.4:
|
| 1032 |
+
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
| 1033 |
+
engines: {node: '>= 14'}
|
| 1034 |
+
|
| 1035 |
+
ansi-regex@5.0.1:
|
| 1036 |
+
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
| 1037 |
+
engines: {node: '>=8'}
|
| 1038 |
+
|
| 1039 |
+
ansi-styles@5.2.0:
|
| 1040 |
+
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
| 1041 |
+
engines: {node: '>=10'}
|
| 1042 |
+
|
| 1043 |
+
aria-query@5.3.0:
|
| 1044 |
+
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
| 1045 |
+
|
| 1046 |
+
assertion-error@2.0.1:
|
| 1047 |
+
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
| 1048 |
+
engines: {node: '>=12'}
|
| 1049 |
+
|
| 1050 |
+
ast-v8-to-istanbul@0.3.12:
|
| 1051 |
+
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
| 1052 |
+
|
| 1053 |
+
chai@6.2.2:
|
| 1054 |
+
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
| 1055 |
+
engines: {node: '>=18'}
|
| 1056 |
+
|
| 1057 |
+
cssstyle@4.6.0:
|
| 1058 |
+
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
| 1059 |
+
engines: {node: '>=18'}
|
| 1060 |
+
|
| 1061 |
+
data-urls@5.0.0:
|
| 1062 |
+
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
| 1063 |
+
engines: {node: '>=18'}
|
| 1064 |
+
|
| 1065 |
+
debug@4.4.3:
|
| 1066 |
+
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
| 1067 |
+
engines: {node: '>=6.0'}
|
| 1068 |
+
peerDependencies:
|
| 1069 |
+
supports-color: '*'
|
| 1070 |
+
peerDependenciesMeta:
|
| 1071 |
+
supports-color:
|
| 1072 |
+
optional: true
|
| 1073 |
+
|
| 1074 |
+
decimal.js@10.6.0:
|
| 1075 |
+
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
| 1076 |
+
|
| 1077 |
+
dequal@2.0.3:
|
| 1078 |
+
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
| 1079 |
+
engines: {node: '>=6'}
|
| 1080 |
+
|
| 1081 |
+
detect-libc@2.1.2:
|
| 1082 |
+
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
| 1083 |
+
engines: {node: '>=8'}
|
| 1084 |
+
|
| 1085 |
+
dom-accessibility-api@0.5.16:
|
| 1086 |
+
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
| 1087 |
+
|
| 1088 |
+
enhanced-resolve@5.20.0:
|
| 1089 |
+
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
| 1090 |
+
engines: {node: '>=10.13.0'}
|
| 1091 |
+
|
| 1092 |
+
entities@6.0.1:
|
| 1093 |
+
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
| 1094 |
+
engines: {node: '>=0.12'}
|
| 1095 |
+
|
| 1096 |
+
entities@7.0.1:
|
| 1097 |
+
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
| 1098 |
+
engines: {node: '>=0.12'}
|
| 1099 |
+
|
| 1100 |
+
es-module-lexer@1.7.0:
|
| 1101 |
+
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
| 1102 |
+
|
| 1103 |
+
esbuild@0.25.12:
|
| 1104 |
+
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
| 1105 |
+
engines: {node: '>=18'}
|
| 1106 |
+
hasBin: true
|
| 1107 |
+
|
| 1108 |
+
esbuild@0.27.3:
|
| 1109 |
+
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
| 1110 |
+
engines: {node: '>=18'}
|
| 1111 |
+
hasBin: true
|
| 1112 |
+
|
| 1113 |
+
estree-walker@3.0.3:
|
| 1114 |
+
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
| 1115 |
+
|
| 1116 |
+
expect-type@1.3.0:
|
| 1117 |
+
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
| 1118 |
+
engines: {node: '>=12.0.0'}
|
| 1119 |
+
|
| 1120 |
+
fdir@6.5.0:
|
| 1121 |
+
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
| 1122 |
+
engines: {node: '>=12.0.0'}
|
| 1123 |
+
peerDependencies:
|
| 1124 |
+
picomatch: ^3 || ^4
|
| 1125 |
+
peerDependenciesMeta:
|
| 1126 |
+
picomatch:
|
| 1127 |
+
optional: true
|
| 1128 |
+
|
| 1129 |
+
fflate@0.8.2:
|
| 1130 |
+
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
| 1131 |
+
|
| 1132 |
+
flatted@3.3.3:
|
| 1133 |
+
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
| 1134 |
+
|
| 1135 |
+
fsevents@2.3.2:
|
| 1136 |
+
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
| 1137 |
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
| 1138 |
+
os: [darwin]
|
| 1139 |
+
|
| 1140 |
+
fsevents@2.3.3:
|
| 1141 |
+
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
| 1142 |
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
| 1143 |
+
os: [darwin]
|
| 1144 |
+
|
| 1145 |
+
graceful-fs@4.2.11:
|
| 1146 |
+
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
| 1147 |
+
|
| 1148 |
+
happy-dom@20.7.0:
|
| 1149 |
+
resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==}
|
| 1150 |
+
engines: {node: '>=20.0.0'}
|
| 1151 |
+
|
| 1152 |
+
has-flag@4.0.0:
|
| 1153 |
+
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
| 1154 |
+
engines: {node: '>=8'}
|
| 1155 |
+
|
| 1156 |
+
heroicons@2.2.0:
|
| 1157 |
+
resolution: {integrity: sha512-yOwvztmNiBWqR946t+JdgZmyzEmnRMC2nxvHFC90bF1SUttwB6yJKYeme1JeEcBfobdOs827nCyiWBS2z/brog==}
|
| 1158 |
+
|
| 1159 |
+
html-encoding-sniffer@4.0.0:
|
| 1160 |
+
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
| 1161 |
+
engines: {node: '>=18'}
|
| 1162 |
+
|
| 1163 |
+
html-escaper@2.0.2:
|
| 1164 |
+
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
| 1165 |
+
|
| 1166 |
+
http-proxy-agent@7.0.2:
|
| 1167 |
+
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
| 1168 |
+
engines: {node: '>= 14'}
|
| 1169 |
+
|
| 1170 |
+
https-proxy-agent@7.0.6:
|
| 1171 |
+
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
| 1172 |
+
engines: {node: '>= 14'}
|
| 1173 |
+
|
| 1174 |
+
iconv-lite@0.6.3:
|
| 1175 |
+
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
| 1176 |
+
engines: {node: '>=0.10.0'}
|
| 1177 |
+
|
| 1178 |
+
is-extglob@2.1.1:
|
| 1179 |
+
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
| 1180 |
+
engines: {node: '>=0.10.0'}
|
| 1181 |
+
|
| 1182 |
+
is-glob@4.0.3:
|
| 1183 |
+
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
| 1184 |
+
engines: {node: '>=0.10.0'}
|
| 1185 |
+
|
| 1186 |
+
is-potential-custom-element-name@1.0.1:
|
| 1187 |
+
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
| 1188 |
+
|
| 1189 |
+
istanbul-lib-coverage@3.2.2:
|
| 1190 |
+
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
| 1191 |
+
engines: {node: '>=8'}
|
| 1192 |
+
|
| 1193 |
+
istanbul-lib-report@3.0.1:
|
| 1194 |
+
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
| 1195 |
+
engines: {node: '>=10'}
|
| 1196 |
+
|
| 1197 |
+
istanbul-reports@3.2.0:
|
| 1198 |
+
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
| 1199 |
+
engines: {node: '>=8'}
|
| 1200 |
+
|
| 1201 |
+
jiti@2.6.1:
|
| 1202 |
+
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
| 1203 |
+
hasBin: true
|
| 1204 |
+
|
| 1205 |
+
js-tokens@10.0.0:
|
| 1206 |
+
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
| 1207 |
+
|
| 1208 |
+
js-tokens@4.0.0:
|
| 1209 |
+
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
| 1210 |
+
|
| 1211 |
+
jsdom@26.1.0:
|
| 1212 |
+
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
| 1213 |
+
engines: {node: '>=18'}
|
| 1214 |
+
peerDependencies:
|
| 1215 |
+
canvas: ^3.0.0
|
| 1216 |
+
peerDependenciesMeta:
|
| 1217 |
+
canvas:
|
| 1218 |
+
optional: true
|
| 1219 |
+
|
| 1220 |
+
lightningcss-android-arm64@1.31.1:
|
| 1221 |
+
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
| 1222 |
+
engines: {node: '>= 12.0.0'}
|
| 1223 |
+
cpu: [arm64]
|
| 1224 |
+
os: [android]
|
| 1225 |
+
|
| 1226 |
+
lightningcss-darwin-arm64@1.31.1:
|
| 1227 |
+
resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
|
| 1228 |
+
engines: {node: '>= 12.0.0'}
|
| 1229 |
+
cpu: [arm64]
|
| 1230 |
+
os: [darwin]
|
| 1231 |
+
|
| 1232 |
+
lightningcss-darwin-x64@1.31.1:
|
| 1233 |
+
resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
|
| 1234 |
+
engines: {node: '>= 12.0.0'}
|
| 1235 |
+
cpu: [x64]
|
| 1236 |
+
os: [darwin]
|
| 1237 |
+
|
| 1238 |
+
lightningcss-freebsd-x64@1.31.1:
|
| 1239 |
+
resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
|
| 1240 |
+
engines: {node: '>= 12.0.0'}
|
| 1241 |
+
cpu: [x64]
|
| 1242 |
+
os: [freebsd]
|
| 1243 |
+
|
| 1244 |
+
lightningcss-linux-arm-gnueabihf@1.31.1:
|
| 1245 |
+
resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
|
| 1246 |
+
engines: {node: '>= 12.0.0'}
|
| 1247 |
+
cpu: [arm]
|
| 1248 |
+
os: [linux]
|
| 1249 |
+
|
| 1250 |
+
lightningcss-linux-arm64-gnu@1.31.1:
|
| 1251 |
+
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
|
| 1252 |
+
engines: {node: '>= 12.0.0'}
|
| 1253 |
+
cpu: [arm64]
|
| 1254 |
+
os: [linux]
|
| 1255 |
+
|
| 1256 |
+
lightningcss-linux-arm64-musl@1.31.1:
|
| 1257 |
+
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
| 1258 |
+
engines: {node: '>= 12.0.0'}
|
| 1259 |
+
cpu: [arm64]
|
| 1260 |
+
os: [linux]
|
| 1261 |
+
|
| 1262 |
+
lightningcss-linux-x64-gnu@1.31.1:
|
| 1263 |
+
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
| 1264 |
+
engines: {node: '>= 12.0.0'}
|
| 1265 |
+
cpu: [x64]
|
| 1266 |
+
os: [linux]
|
| 1267 |
+
|
| 1268 |
+
lightningcss-linux-x64-musl@1.31.1:
|
| 1269 |
+
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
| 1270 |
+
engines: {node: '>= 12.0.0'}
|
| 1271 |
+
cpu: [x64]
|
| 1272 |
+
os: [linux]
|
| 1273 |
+
|
| 1274 |
+
lightningcss-win32-arm64-msvc@1.31.1:
|
| 1275 |
+
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
| 1276 |
+
engines: {node: '>= 12.0.0'}
|
| 1277 |
+
cpu: [arm64]
|
| 1278 |
+
os: [win32]
|
| 1279 |
+
|
| 1280 |
+
lightningcss-win32-x64-msvc@1.31.1:
|
| 1281 |
+
resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
|
| 1282 |
+
engines: {node: '>= 12.0.0'}
|
| 1283 |
+
cpu: [x64]
|
| 1284 |
+
os: [win32]
|
| 1285 |
+
|
| 1286 |
+
lightningcss@1.31.1:
|
| 1287 |
+
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
|
| 1288 |
+
engines: {node: '>= 12.0.0'}
|
| 1289 |
+
|
| 1290 |
+
lru-cache@10.4.3:
|
| 1291 |
+
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
| 1292 |
+
|
| 1293 |
+
lz-string@1.5.0:
|
| 1294 |
+
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
| 1295 |
+
hasBin: true
|
| 1296 |
+
|
| 1297 |
+
magic-string@0.30.21:
|
| 1298 |
+
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
| 1299 |
+
|
| 1300 |
+
magicast@0.5.2:
|
| 1301 |
+
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
| 1302 |
+
|
| 1303 |
+
make-dir@4.0.0:
|
| 1304 |
+
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
| 1305 |
+
engines: {node: '>=10'}
|
| 1306 |
+
|
| 1307 |
+
mri@1.2.0:
|
| 1308 |
+
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
| 1309 |
+
engines: {node: '>=4'}
|
| 1310 |
+
|
| 1311 |
+
mrmime@2.0.1:
|
| 1312 |
+
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
| 1313 |
+
engines: {node: '>=10'}
|
| 1314 |
+
|
| 1315 |
+
ms@2.1.3:
|
| 1316 |
+
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
| 1317 |
+
|
| 1318 |
+
nanoid@3.3.11:
|
| 1319 |
+
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
| 1320 |
+
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
| 1321 |
+
hasBin: true
|
| 1322 |
+
|
| 1323 |
+
node-addon-api@7.1.1:
|
| 1324 |
+
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
| 1325 |
+
|
| 1326 |
+
nwsapi@2.2.23:
|
| 1327 |
+
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
| 1328 |
+
|
| 1329 |
+
obug@2.1.1:
|
| 1330 |
+
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
| 1331 |
+
|
| 1332 |
+
parse5@7.3.0:
|
| 1333 |
+
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
| 1334 |
+
|
| 1335 |
+
pathe@2.0.3:
|
| 1336 |
+
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
| 1337 |
+
|
| 1338 |
+
picocolors@1.1.1:
|
| 1339 |
+
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
| 1340 |
+
|
| 1341 |
+
picomatch@4.0.3:
|
| 1342 |
+
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
| 1343 |
+
engines: {node: '>=12'}
|
| 1344 |
+
|
| 1345 |
+
playwright-core@1.58.2:
|
| 1346 |
+
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
| 1347 |
+
engines: {node: '>=18'}
|
| 1348 |
+
hasBin: true
|
| 1349 |
+
|
| 1350 |
+
playwright@1.58.2:
|
| 1351 |
+
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
| 1352 |
+
engines: {node: '>=18'}
|
| 1353 |
+
hasBin: true
|
| 1354 |
+
|
| 1355 |
+
postcss@8.5.6:
|
| 1356 |
+
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
| 1357 |
+
engines: {node: ^10 || ^12 || >=14}
|
| 1358 |
+
|
| 1359 |
+
pretty-format@27.5.1:
|
| 1360 |
+
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
| 1361 |
+
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
| 1362 |
+
|
| 1363 |
+
punycode@2.3.1:
|
| 1364 |
+
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
| 1365 |
+
engines: {node: '>=6'}
|
| 1366 |
+
|
| 1367 |
+
react-is@17.0.2:
|
| 1368 |
+
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
| 1369 |
+
|
| 1370 |
+
rollup@4.59.0:
|
| 1371 |
+
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
| 1372 |
+
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
| 1373 |
+
hasBin: true
|
| 1374 |
+
|
| 1375 |
+
rrweb-cssom@0.8.0:
|
| 1376 |
+
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
| 1377 |
+
|
| 1378 |
+
safer-buffer@2.1.2:
|
| 1379 |
+
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
| 1380 |
+
|
| 1381 |
+
saxes@6.0.0:
|
| 1382 |
+
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
| 1383 |
+
engines: {node: '>=v12.22.7'}
|
| 1384 |
+
|
| 1385 |
+
semver@7.7.4:
|
| 1386 |
+
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
| 1387 |
+
engines: {node: '>=10'}
|
| 1388 |
+
hasBin: true
|
| 1389 |
+
|
| 1390 |
+
siginfo@2.0.0:
|
| 1391 |
+
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
| 1392 |
+
|
| 1393 |
+
sirv@3.0.2:
|
| 1394 |
+
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
| 1395 |
+
engines: {node: '>=18'}
|
| 1396 |
+
|
| 1397 |
+
source-map-js@1.2.1:
|
| 1398 |
+
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
| 1399 |
+
engines: {node: '>=0.10.0'}
|
| 1400 |
+
|
| 1401 |
+
stackback@0.0.2:
|
| 1402 |
+
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
| 1403 |
+
|
| 1404 |
+
std-env@3.10.0:
|
| 1405 |
+
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
| 1406 |
+
|
| 1407 |
+
supports-color@7.2.0:
|
| 1408 |
+
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
| 1409 |
+
engines: {node: '>=8'}
|
| 1410 |
+
|
| 1411 |
+
symbol-tree@3.2.4:
|
| 1412 |
+
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
| 1413 |
+
|
| 1414 |
+
tailwindcss@4.2.1:
|
| 1415 |
+
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
|
| 1416 |
+
|
| 1417 |
+
tapable@2.3.0:
|
| 1418 |
+
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
| 1419 |
+
engines: {node: '>=6'}
|
| 1420 |
+
|
| 1421 |
+
tinybench@2.9.0:
|
| 1422 |
+
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
| 1423 |
+
|
| 1424 |
+
tinyexec@1.0.2:
|
| 1425 |
+
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
| 1426 |
+
engines: {node: '>=18'}
|
| 1427 |
+
|
| 1428 |
+
tinyglobby@0.2.15:
|
| 1429 |
+
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
| 1430 |
+
engines: {node: '>=12.0.0'}
|
| 1431 |
+
|
| 1432 |
+
tinyrainbow@3.0.3:
|
| 1433 |
+
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
| 1434 |
+
engines: {node: '>=14.0.0'}
|
| 1435 |
+
|
| 1436 |
+
tldts-core@6.1.86:
|
| 1437 |
+
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
|
| 1438 |
+
|
| 1439 |
+
tldts@6.1.86:
|
| 1440 |
+
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
| 1441 |
+
hasBin: true
|
| 1442 |
+
|
| 1443 |
+
totalist@3.0.1:
|
| 1444 |
+
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
| 1445 |
+
engines: {node: '>=6'}
|
| 1446 |
+
|
| 1447 |
+
tough-cookie@5.1.2:
|
| 1448 |
+
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
| 1449 |
+
engines: {node: '>=16'}
|
| 1450 |
+
|
| 1451 |
+
tr46@5.1.1:
|
| 1452 |
+
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
|
| 1453 |
+
engines: {node: '>=18'}
|
| 1454 |
+
|
| 1455 |
+
undici-types@7.18.2:
|
| 1456 |
+
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
| 1457 |
+
|
| 1458 |
+
vite@6.4.1:
|
| 1459 |
+
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
|
| 1460 |
+
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
| 1461 |
+
hasBin: true
|
| 1462 |
+
peerDependencies:
|
| 1463 |
+
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
| 1464 |
+
jiti: '>=1.21.0'
|
| 1465 |
+
less: '*'
|
| 1466 |
+
lightningcss: ^1.21.0
|
| 1467 |
+
sass: '*'
|
| 1468 |
+
sass-embedded: '*'
|
| 1469 |
+
stylus: '*'
|
| 1470 |
+
sugarss: '*'
|
| 1471 |
+
terser: ^5.16.0
|
| 1472 |
+
tsx: ^4.8.1
|
| 1473 |
+
yaml: ^2.4.2
|
| 1474 |
+
peerDependenciesMeta:
|
| 1475 |
+
'@types/node':
|
| 1476 |
+
optional: true
|
| 1477 |
+
jiti:
|
| 1478 |
+
optional: true
|
| 1479 |
+
less:
|
| 1480 |
+
optional: true
|
| 1481 |
+
lightningcss:
|
| 1482 |
+
optional: true
|
| 1483 |
+
sass:
|
| 1484 |
+
optional: true
|
| 1485 |
+
sass-embedded:
|
| 1486 |
+
optional: true
|
| 1487 |
+
stylus:
|
| 1488 |
+
optional: true
|
| 1489 |
+
sugarss:
|
| 1490 |
+
optional: true
|
| 1491 |
+
terser:
|
| 1492 |
+
optional: true
|
| 1493 |
+
tsx:
|
| 1494 |
+
optional: true
|
| 1495 |
+
yaml:
|
| 1496 |
+
optional: true
|
| 1497 |
+
|
| 1498 |
+
vite@7.3.1:
|
| 1499 |
+
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
| 1500 |
+
engines: {node: ^20.19.0 || >=22.12.0}
|
| 1501 |
+
hasBin: true
|
| 1502 |
+
peerDependencies:
|
| 1503 |
+
'@types/node': ^20.19.0 || >=22.12.0
|
| 1504 |
+
jiti: '>=1.21.0'
|
| 1505 |
+
less: ^4.0.0
|
| 1506 |
+
lightningcss: ^1.21.0
|
| 1507 |
+
sass: ^1.70.0
|
| 1508 |
+
sass-embedded: ^1.70.0
|
| 1509 |
+
stylus: '>=0.54.8'
|
| 1510 |
+
sugarss: ^5.0.0
|
| 1511 |
+
terser: ^5.16.0
|
| 1512 |
+
tsx: ^4.8.1
|
| 1513 |
+
yaml: ^2.4.2
|
| 1514 |
+
peerDependenciesMeta:
|
| 1515 |
+
'@types/node':
|
| 1516 |
+
optional: true
|
| 1517 |
+
jiti:
|
| 1518 |
+
optional: true
|
| 1519 |
+
less:
|
| 1520 |
+
optional: true
|
| 1521 |
+
lightningcss:
|
| 1522 |
+
optional: true
|
| 1523 |
+
sass:
|
| 1524 |
+
optional: true
|
| 1525 |
+
sass-embedded:
|
| 1526 |
+
optional: true
|
| 1527 |
+
stylus:
|
| 1528 |
+
optional: true
|
| 1529 |
+
sugarss:
|
| 1530 |
+
optional: true
|
| 1531 |
+
terser:
|
| 1532 |
+
optional: true
|
| 1533 |
+
tsx:
|
| 1534 |
+
optional: true
|
| 1535 |
+
yaml:
|
| 1536 |
+
optional: true
|
| 1537 |
+
|
| 1538 |
+
vitest@4.0.18:
|
| 1539 |
+
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
| 1540 |
+
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
| 1541 |
+
hasBin: true
|
| 1542 |
+
peerDependencies:
|
| 1543 |
+
'@edge-runtime/vm': '*'
|
| 1544 |
+
'@opentelemetry/api': ^1.9.0
|
| 1545 |
+
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
| 1546 |
+
'@vitest/browser-playwright': 4.0.18
|
| 1547 |
+
'@vitest/browser-preview': 4.0.18
|
| 1548 |
+
'@vitest/browser-webdriverio': 4.0.18
|
| 1549 |
+
'@vitest/ui': 4.0.18
|
| 1550 |
+
happy-dom: '*'
|
| 1551 |
+
jsdom: '*'
|
| 1552 |
+
peerDependenciesMeta:
|
| 1553 |
+
'@edge-runtime/vm':
|
| 1554 |
+
optional: true
|
| 1555 |
+
'@opentelemetry/api':
|
| 1556 |
+
optional: true
|
| 1557 |
+
'@types/node':
|
| 1558 |
+
optional: true
|
| 1559 |
+
'@vitest/browser-playwright':
|
| 1560 |
+
optional: true
|
| 1561 |
+
'@vitest/browser-preview':
|
| 1562 |
+
optional: true
|
| 1563 |
+
'@vitest/browser-webdriverio':
|
| 1564 |
+
optional: true
|
| 1565 |
+
'@vitest/ui':
|
| 1566 |
+
optional: true
|
| 1567 |
+
happy-dom:
|
| 1568 |
+
optional: true
|
| 1569 |
+
jsdom:
|
| 1570 |
+
optional: true
|
| 1571 |
+
|
| 1572 |
+
w3c-xmlserializer@5.0.0:
|
| 1573 |
+
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
| 1574 |
+
engines: {node: '>=18'}
|
| 1575 |
+
|
| 1576 |
+
webidl-conversions@7.0.0:
|
| 1577 |
+
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
| 1578 |
+
engines: {node: '>=12'}
|
| 1579 |
+
|
| 1580 |
+
whatwg-encoding@3.1.1:
|
| 1581 |
+
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
| 1582 |
+
engines: {node: '>=18'}
|
| 1583 |
+
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
| 1584 |
+
|
| 1585 |
+
whatwg-mimetype@3.0.0:
|
| 1586 |
+
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
| 1587 |
+
engines: {node: '>=12'}
|
| 1588 |
+
|
| 1589 |
+
whatwg-mimetype@4.0.0:
|
| 1590 |
+
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
| 1591 |
+
engines: {node: '>=18'}
|
| 1592 |
+
|
| 1593 |
+
whatwg-url@14.2.0:
|
| 1594 |
+
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
|
| 1595 |
+
engines: {node: '>=18'}
|
| 1596 |
+
|
| 1597 |
+
why-is-node-running@2.3.0:
|
| 1598 |
+
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
| 1599 |
+
engines: {node: '>=8'}
|
| 1600 |
+
hasBin: true
|
| 1601 |
+
|
| 1602 |
+
ws@8.19.0:
|
| 1603 |
+
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
|
| 1604 |
+
engines: {node: '>=10.0.0'}
|
| 1605 |
+
peerDependencies:
|
| 1606 |
+
bufferutil: ^4.0.1
|
| 1607 |
+
utf-8-validate: '>=5.0.2'
|
| 1608 |
+
peerDependenciesMeta:
|
| 1609 |
+
bufferutil:
|
| 1610 |
+
optional: true
|
| 1611 |
+
utf-8-validate:
|
| 1612 |
+
optional: true
|
| 1613 |
+
|
| 1614 |
+
xml-name-validator@5.0.0:
|
| 1615 |
+
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
| 1616 |
+
engines: {node: '>=18'}
|
| 1617 |
+
|
| 1618 |
+
xmlchars@2.2.0:
|
| 1619 |
+
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
| 1620 |
+
|
| 1621 |
+
snapshots:
|
| 1622 |
+
|
| 1623 |
+
'@asamuzakjp/css-color@3.2.0':
|
| 1624 |
+
dependencies:
|
| 1625 |
+
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
| 1626 |
+
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
| 1627 |
+
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
| 1628 |
+
'@csstools/css-tokenizer': 3.0.4
|
| 1629 |
+
lru-cache: 10.4.3
|
| 1630 |
+
|
| 1631 |
+
'@babel/code-frame@7.29.0':
|
| 1632 |
+
dependencies:
|
| 1633 |
+
'@babel/helper-validator-identifier': 7.28.5
|
| 1634 |
+
js-tokens: 4.0.0
|
| 1635 |
+
picocolors: 1.1.1
|
| 1636 |
+
|
| 1637 |
+
'@babel/helper-string-parser@7.27.1': {}
|
| 1638 |
+
|
| 1639 |
+
'@babel/helper-validator-identifier@7.28.5': {}
|
| 1640 |
+
|
| 1641 |
+
'@babel/parser@7.29.0':
|
| 1642 |
+
dependencies:
|
| 1643 |
+
'@babel/types': 7.29.0
|
| 1644 |
+
|
| 1645 |
+
'@babel/runtime@7.28.6': {}
|
| 1646 |
+
|
| 1647 |
+
'@babel/types@7.29.0':
|
| 1648 |
+
dependencies:
|
| 1649 |
+
'@babel/helper-string-parser': 7.27.1
|
| 1650 |
+
'@babel/helper-validator-identifier': 7.28.5
|
| 1651 |
+
|
| 1652 |
+
'@bcoe/v8-coverage@1.0.2': {}
|
| 1653 |
+
|
| 1654 |
+
'@biomejs/biome@1.9.4':
|
| 1655 |
+
optionalDependencies:
|
| 1656 |
+
'@biomejs/cli-darwin-arm64': 1.9.4
|
| 1657 |
+
'@biomejs/cli-darwin-x64': 1.9.4
|
| 1658 |
+
'@biomejs/cli-linux-arm64': 1.9.4
|
| 1659 |
+
'@biomejs/cli-linux-arm64-musl': 1.9.4
|
| 1660 |
+
'@biomejs/cli-linux-x64': 1.9.4
|
| 1661 |
+
'@biomejs/cli-linux-x64-musl': 1.9.4
|
| 1662 |
+
'@biomejs/cli-win32-arm64': 1.9.4
|
| 1663 |
+
'@biomejs/cli-win32-x64': 1.9.4
|
| 1664 |
+
|
| 1665 |
+
'@biomejs/cli-darwin-arm64@1.9.4':
|
| 1666 |
+
optional: true
|
| 1667 |
+
|
| 1668 |
+
'@biomejs/cli-darwin-x64@1.9.4':
|
| 1669 |
+
optional: true
|
| 1670 |
+
|
| 1671 |
+
'@biomejs/cli-linux-arm64-musl@1.9.4':
|
| 1672 |
+
optional: true
|
| 1673 |
+
|
| 1674 |
+
'@biomejs/cli-linux-arm64@1.9.4':
|
| 1675 |
+
optional: true
|
| 1676 |
+
|
| 1677 |
+
'@biomejs/cli-linux-x64-musl@1.9.4':
|
| 1678 |
+
optional: true
|
| 1679 |
+
|
| 1680 |
+
'@biomejs/cli-linux-x64@1.9.4':
|
| 1681 |
+
optional: true
|
| 1682 |
+
|
| 1683 |
+
'@biomejs/cli-win32-arm64@1.9.4':
|
| 1684 |
+
optional: true
|
| 1685 |
+
|
| 1686 |
+
'@biomejs/cli-win32-x64@1.9.4':
|
| 1687 |
+
optional: true
|
| 1688 |
+
|
| 1689 |
+
'@csstools/color-helpers@5.1.0': {}
|
| 1690 |
+
|
| 1691 |
+
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
| 1692 |
+
dependencies:
|
| 1693 |
+
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
| 1694 |
+
'@csstools/css-tokenizer': 3.0.4
|
| 1695 |
+
|
| 1696 |
+
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
| 1697 |
+
dependencies:
|
| 1698 |
+
'@csstools/color-helpers': 5.1.0
|
| 1699 |
+
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
| 1700 |
+
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
| 1701 |
+
'@csstools/css-tokenizer': 3.0.4
|
| 1702 |
+
|
| 1703 |
+
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
| 1704 |
+
dependencies:
|
| 1705 |
+
'@csstools/css-tokenizer': 3.0.4
|
| 1706 |
+
|
| 1707 |
+
'@csstools/css-tokenizer@3.0.4': {}
|
| 1708 |
+
|
| 1709 |
+
'@esbuild/aix-ppc64@0.25.12':
|
| 1710 |
+
optional: true
|
| 1711 |
+
|
| 1712 |
+
'@esbuild/aix-ppc64@0.27.3':
|
| 1713 |
+
optional: true
|
| 1714 |
+
|
| 1715 |
+
'@esbuild/android-arm64@0.25.12':
|
| 1716 |
+
optional: true
|
| 1717 |
+
|
| 1718 |
+
'@esbuild/android-arm64@0.27.3':
|
| 1719 |
+
optional: true
|
| 1720 |
+
|
| 1721 |
+
'@esbuild/android-arm@0.25.12':
|
| 1722 |
+
optional: true
|
| 1723 |
+
|
| 1724 |
+
'@esbuild/android-arm@0.27.3':
|
| 1725 |
+
optional: true
|
| 1726 |
+
|
| 1727 |
+
'@esbuild/android-x64@0.25.12':
|
| 1728 |
+
optional: true
|
| 1729 |
+
|
| 1730 |
+
'@esbuild/android-x64@0.27.3':
|
| 1731 |
+
optional: true
|
| 1732 |
+
|
| 1733 |
+
'@esbuild/darwin-arm64@0.25.12':
|
| 1734 |
+
optional: true
|
| 1735 |
+
|
| 1736 |
+
'@esbuild/darwin-arm64@0.27.3':
|
| 1737 |
+
optional: true
|
| 1738 |
+
|
| 1739 |
+
'@esbuild/darwin-x64@0.25.12':
|
| 1740 |
+
optional: true
|
| 1741 |
+
|
| 1742 |
+
'@esbuild/darwin-x64@0.27.3':
|
| 1743 |
+
optional: true
|
| 1744 |
+
|
| 1745 |
+
'@esbuild/freebsd-arm64@0.25.12':
|
| 1746 |
+
optional: true
|
| 1747 |
+
|
| 1748 |
+
'@esbuild/freebsd-arm64@0.27.3':
|
| 1749 |
+
optional: true
|
| 1750 |
+
|
| 1751 |
+
'@esbuild/freebsd-x64@0.25.12':
|
| 1752 |
+
optional: true
|
| 1753 |
+
|
| 1754 |
+
'@esbuild/freebsd-x64@0.27.3':
|
| 1755 |
+
optional: true
|
| 1756 |
+
|
| 1757 |
+
'@esbuild/linux-arm64@0.25.12':
|
| 1758 |
+
optional: true
|
| 1759 |
+
|
| 1760 |
+
'@esbuild/linux-arm64@0.27.3':
|
| 1761 |
+
optional: true
|
| 1762 |
+
|
| 1763 |
+
'@esbuild/linux-arm@0.25.12':
|
| 1764 |
+
optional: true
|
| 1765 |
+
|
| 1766 |
+
'@esbuild/linux-arm@0.27.3':
|
| 1767 |
+
optional: true
|
| 1768 |
+
|
| 1769 |
+
'@esbuild/linux-ia32@0.25.12':
|
| 1770 |
+
optional: true
|
| 1771 |
+
|
| 1772 |
+
'@esbuild/linux-ia32@0.27.3':
|
| 1773 |
+
optional: true
|
| 1774 |
+
|
| 1775 |
+
'@esbuild/linux-loong64@0.25.12':
|
| 1776 |
+
optional: true
|
| 1777 |
+
|
| 1778 |
+
'@esbuild/linux-loong64@0.27.3':
|
| 1779 |
+
optional: true
|
| 1780 |
+
|
| 1781 |
+
'@esbuild/linux-mips64el@0.25.12':
|
| 1782 |
+
optional: true
|
| 1783 |
+
|
| 1784 |
+
'@esbuild/linux-mips64el@0.27.3':
|
| 1785 |
+
optional: true
|
| 1786 |
+
|
| 1787 |
+
'@esbuild/linux-ppc64@0.25.12':
|
| 1788 |
+
optional: true
|
| 1789 |
+
|
| 1790 |
+
'@esbuild/linux-ppc64@0.27.3':
|
| 1791 |
+
optional: true
|
| 1792 |
+
|
| 1793 |
+
'@esbuild/linux-riscv64@0.25.12':
|
| 1794 |
+
optional: true
|
| 1795 |
+
|
| 1796 |
+
'@esbuild/linux-riscv64@0.27.3':
|
| 1797 |
+
optional: true
|
| 1798 |
+
|
| 1799 |
+
'@esbuild/linux-s390x@0.25.12':
|
| 1800 |
+
optional: true
|
| 1801 |
+
|
| 1802 |
+
'@esbuild/linux-s390x@0.27.3':
|
| 1803 |
+
optional: true
|
| 1804 |
+
|
| 1805 |
+
'@esbuild/linux-x64@0.25.12':
|
| 1806 |
+
optional: true
|
| 1807 |
+
|
| 1808 |
+
'@esbuild/linux-x64@0.27.3':
|
| 1809 |
+
optional: true
|
| 1810 |
+
|
| 1811 |
+
'@esbuild/netbsd-arm64@0.25.12':
|
| 1812 |
+
optional: true
|
| 1813 |
+
|
| 1814 |
+
'@esbuild/netbsd-arm64@0.27.3':
|
| 1815 |
+
optional: true
|
| 1816 |
+
|
| 1817 |
+
'@esbuild/netbsd-x64@0.25.12':
|
| 1818 |
+
optional: true
|
| 1819 |
+
|
| 1820 |
+
'@esbuild/netbsd-x64@0.27.3':
|
| 1821 |
+
optional: true
|
| 1822 |
+
|
| 1823 |
+
'@esbuild/openbsd-arm64@0.25.12':
|
| 1824 |
+
optional: true
|
| 1825 |
+
|
| 1826 |
+
'@esbuild/openbsd-arm64@0.27.3':
|
| 1827 |
+
optional: true
|
| 1828 |
+
|
| 1829 |
+
'@esbuild/openbsd-x64@0.25.12':
|
| 1830 |
+
optional: true
|
| 1831 |
+
|
| 1832 |
+
'@esbuild/openbsd-x64@0.27.3':
|
| 1833 |
+
optional: true
|
| 1834 |
+
|
| 1835 |
+
'@esbuild/openharmony-arm64@0.25.12':
|
| 1836 |
+
optional: true
|
| 1837 |
+
|
| 1838 |
+
'@esbuild/openharmony-arm64@0.27.3':
|
| 1839 |
+
optional: true
|
| 1840 |
+
|
| 1841 |
+
'@esbuild/sunos-x64@0.25.12':
|
| 1842 |
+
optional: true
|
| 1843 |
+
|
| 1844 |
+
'@esbuild/sunos-x64@0.27.3':
|
| 1845 |
+
optional: true
|
| 1846 |
+
|
| 1847 |
+
'@esbuild/win32-arm64@0.25.12':
|
| 1848 |
+
optional: true
|
| 1849 |
+
|
| 1850 |
+
'@esbuild/win32-arm64@0.27.3':
|
| 1851 |
+
optional: true
|
| 1852 |
+
|
| 1853 |
+
'@esbuild/win32-ia32@0.25.12':
|
| 1854 |
+
optional: true
|
| 1855 |
+
|
| 1856 |
+
'@esbuild/win32-ia32@0.27.3':
|
| 1857 |
+
optional: true
|
| 1858 |
+
|
| 1859 |
+
'@esbuild/win32-x64@0.25.12':
|
| 1860 |
+
optional: true
|
| 1861 |
+
|
| 1862 |
+
'@esbuild/win32-x64@0.27.3':
|
| 1863 |
+
optional: true
|
| 1864 |
+
|
| 1865 |
+
'@jcubic/tagger@0.6.2': {}
|
| 1866 |
+
|
| 1867 |
+
'@jridgewell/gen-mapping@0.3.13':
|
| 1868 |
+
dependencies:
|
| 1869 |
+
'@jridgewell/sourcemap-codec': 1.5.5
|
| 1870 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 1871 |
+
|
| 1872 |
+
'@jridgewell/remapping@2.3.5':
|
| 1873 |
+
dependencies:
|
| 1874 |
+
'@jridgewell/gen-mapping': 0.3.13
|
| 1875 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 1876 |
+
|
| 1877 |
+
'@jridgewell/resolve-uri@3.1.2': {}
|
| 1878 |
+
|
| 1879 |
+
'@jridgewell/sourcemap-codec@1.5.5': {}
|
| 1880 |
+
|
| 1881 |
+
'@jridgewell/trace-mapping@0.3.31':
|
| 1882 |
+
dependencies:
|
| 1883 |
+
'@jridgewell/resolve-uri': 3.1.2
|
| 1884 |
+
'@jridgewell/sourcemap-codec': 1.5.5
|
| 1885 |
+
|
| 1886 |
+
'@msgpack/msgpack@3.1.3': {}
|
| 1887 |
+
|
| 1888 |
+
'@napi-rs/canvas-android-arm64@0.1.95':
|
| 1889 |
+
optional: true
|
| 1890 |
+
|
| 1891 |
+
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
| 1892 |
+
optional: true
|
| 1893 |
+
|
| 1894 |
+
'@napi-rs/canvas-darwin-x64@0.1.95':
|
| 1895 |
+
optional: true
|
| 1896 |
+
|
| 1897 |
+
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
| 1898 |
+
optional: true
|
| 1899 |
+
|
| 1900 |
+
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
| 1901 |
+
optional: true
|
| 1902 |
+
|
| 1903 |
+
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
| 1904 |
+
optional: true
|
| 1905 |
+
|
| 1906 |
+
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
| 1907 |
+
optional: true
|
| 1908 |
+
|
| 1909 |
+
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
| 1910 |
+
optional: true
|
| 1911 |
+
|
| 1912 |
+
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
| 1913 |
+
optional: true
|
| 1914 |
+
|
| 1915 |
+
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
| 1916 |
+
optional: true
|
| 1917 |
+
|
| 1918 |
+
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
| 1919 |
+
optional: true
|
| 1920 |
+
|
| 1921 |
+
'@napi-rs/canvas@0.1.95':
|
| 1922 |
+
optionalDependencies:
|
| 1923 |
+
'@napi-rs/canvas-android-arm64': 0.1.95
|
| 1924 |
+
'@napi-rs/canvas-darwin-arm64': 0.1.95
|
| 1925 |
+
'@napi-rs/canvas-darwin-x64': 0.1.95
|
| 1926 |
+
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
|
| 1927 |
+
'@napi-rs/canvas-linux-arm64-gnu': 0.1.95
|
| 1928 |
+
'@napi-rs/canvas-linux-arm64-musl': 0.1.95
|
| 1929 |
+
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
|
| 1930 |
+
'@napi-rs/canvas-linux-x64-gnu': 0.1.95
|
| 1931 |
+
'@napi-rs/canvas-linux-x64-musl': 0.1.95
|
| 1932 |
+
'@napi-rs/canvas-win32-arm64-msvc': 0.1.95
|
| 1933 |
+
'@napi-rs/canvas-win32-x64-msvc': 0.1.95
|
| 1934 |
+
|
| 1935 |
+
'@parcel/watcher-android-arm64@2.5.6':
|
| 1936 |
+
optional: true
|
| 1937 |
+
|
| 1938 |
+
'@parcel/watcher-darwin-arm64@2.5.6':
|
| 1939 |
+
optional: true
|
| 1940 |
+
|
| 1941 |
+
'@parcel/watcher-darwin-x64@2.5.6':
|
| 1942 |
+
optional: true
|
| 1943 |
+
|
| 1944 |
+
'@parcel/watcher-freebsd-x64@2.5.6':
|
| 1945 |
+
optional: true
|
| 1946 |
+
|
| 1947 |
+
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
| 1948 |
+
optional: true
|
| 1949 |
+
|
| 1950 |
+
'@parcel/watcher-linux-arm-musl@2.5.6':
|
| 1951 |
+
optional: true
|
| 1952 |
+
|
| 1953 |
+
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
| 1954 |
+
optional: true
|
| 1955 |
+
|
| 1956 |
+
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
| 1957 |
+
optional: true
|
| 1958 |
+
|
| 1959 |
+
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
| 1960 |
+
optional: true
|
| 1961 |
+
|
| 1962 |
+
'@parcel/watcher-linux-x64-musl@2.5.6':
|
| 1963 |
+
optional: true
|
| 1964 |
+
|
| 1965 |
+
'@parcel/watcher-win32-arm64@2.5.6':
|
| 1966 |
+
optional: true
|
| 1967 |
+
|
| 1968 |
+
'@parcel/watcher-win32-ia32@2.5.6':
|
| 1969 |
+
optional: true
|
| 1970 |
+
|
| 1971 |
+
'@parcel/watcher-win32-x64@2.5.6':
|
| 1972 |
+
optional: true
|
| 1973 |
+
|
| 1974 |
+
'@parcel/watcher@2.5.6':
|
| 1975 |
+
dependencies:
|
| 1976 |
+
detect-libc: 2.1.2
|
| 1977 |
+
is-glob: 4.0.3
|
| 1978 |
+
node-addon-api: 7.1.1
|
| 1979 |
+
picomatch: 4.0.3
|
| 1980 |
+
optionalDependencies:
|
| 1981 |
+
'@parcel/watcher-android-arm64': 2.5.6
|
| 1982 |
+
'@parcel/watcher-darwin-arm64': 2.5.6
|
| 1983 |
+
'@parcel/watcher-darwin-x64': 2.5.6
|
| 1984 |
+
'@parcel/watcher-freebsd-x64': 2.5.6
|
| 1985 |
+
'@parcel/watcher-linux-arm-glibc': 2.5.6
|
| 1986 |
+
'@parcel/watcher-linux-arm-musl': 2.5.6
|
| 1987 |
+
'@parcel/watcher-linux-arm64-glibc': 2.5.6
|
| 1988 |
+
'@parcel/watcher-linux-arm64-musl': 2.5.6
|
| 1989 |
+
'@parcel/watcher-linux-x64-glibc': 2.5.6
|
| 1990 |
+
'@parcel/watcher-linux-x64-musl': 2.5.6
|
| 1991 |
+
'@parcel/watcher-win32-arm64': 2.5.6
|
| 1992 |
+
'@parcel/watcher-win32-ia32': 2.5.6
|
| 1993 |
+
'@parcel/watcher-win32-x64': 2.5.6
|
| 1994 |
+
|
| 1995 |
+
'@playwright/test@1.58.2':
|
| 1996 |
+
dependencies:
|
| 1997 |
+
playwright: 1.58.2
|
| 1998 |
+
|
| 1999 |
+
'@polka/url@1.0.0-next.29': {}
|
| 2000 |
+
|
| 2001 |
+
'@rollup/rollup-android-arm-eabi@4.59.0':
|
| 2002 |
+
optional: true
|
| 2003 |
+
|
| 2004 |
+
'@rollup/rollup-android-arm64@4.59.0':
|
| 2005 |
+
optional: true
|
| 2006 |
+
|
| 2007 |
+
'@rollup/rollup-darwin-arm64@4.59.0':
|
| 2008 |
+
optional: true
|
| 2009 |
+
|
| 2010 |
+
'@rollup/rollup-darwin-x64@4.59.0':
|
| 2011 |
+
optional: true
|
| 2012 |
+
|
| 2013 |
+
'@rollup/rollup-freebsd-arm64@4.59.0':
|
| 2014 |
+
optional: true
|
| 2015 |
+
|
| 2016 |
+
'@rollup/rollup-freebsd-x64@4.59.0':
|
| 2017 |
+
optional: true
|
| 2018 |
+
|
| 2019 |
+
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
|
| 2020 |
+
optional: true
|
| 2021 |
+
|
| 2022 |
+
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
| 2023 |
+
optional: true
|
| 2024 |
+
|
| 2025 |
+
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
| 2026 |
+
optional: true
|
| 2027 |
+
|
| 2028 |
+
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
| 2029 |
+
optional: true
|
| 2030 |
+
|
| 2031 |
+
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
| 2032 |
+
optional: true
|
| 2033 |
+
|
| 2034 |
+
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
| 2035 |
+
optional: true
|
| 2036 |
+
|
| 2037 |
+
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
| 2038 |
+
optional: true
|
| 2039 |
+
|
| 2040 |
+
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
| 2041 |
+
optional: true
|
| 2042 |
+
|
| 2043 |
+
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
| 2044 |
+
optional: true
|
| 2045 |
+
|
| 2046 |
+
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
| 2047 |
+
optional: true
|
| 2048 |
+
|
| 2049 |
+
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
| 2050 |
+
optional: true
|
| 2051 |
+
|
| 2052 |
+
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
| 2053 |
+
optional: true
|
| 2054 |
+
|
| 2055 |
+
'@rollup/rollup-linux-x64-musl@4.59.0':
|
| 2056 |
+
optional: true
|
| 2057 |
+
|
| 2058 |
+
'@rollup/rollup-openbsd-x64@4.59.0':
|
| 2059 |
+
optional: true
|
| 2060 |
+
|
| 2061 |
+
'@rollup/rollup-openharmony-arm64@4.59.0':
|
| 2062 |
+
optional: true
|
| 2063 |
+
|
| 2064 |
+
'@rollup/rollup-win32-arm64-msvc@4.59.0':
|
| 2065 |
+
optional: true
|
| 2066 |
+
|
| 2067 |
+
'@rollup/rollup-win32-ia32-msvc@4.59.0':
|
| 2068 |
+
optional: true
|
| 2069 |
+
|
| 2070 |
+
'@rollup/rollup-win32-x64-gnu@4.59.0':
|
| 2071 |
+
optional: true
|
| 2072 |
+
|
| 2073 |
+
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
| 2074 |
+
optional: true
|
| 2075 |
+
|
| 2076 |
+
'@standard-schema/spec@1.1.0': {}
|
| 2077 |
+
|
| 2078 |
+
'@swc/core-darwin-arm64@1.15.17':
|
| 2079 |
+
optional: true
|
| 2080 |
+
|
| 2081 |
+
'@swc/core-darwin-x64@1.15.17':
|
| 2082 |
+
optional: true
|
| 2083 |
+
|
| 2084 |
+
'@swc/core-linux-arm-gnueabihf@1.15.17':
|
| 2085 |
+
optional: true
|
| 2086 |
+
|
| 2087 |
+
'@swc/core-linux-arm64-gnu@1.15.17':
|
| 2088 |
+
optional: true
|
| 2089 |
+
|
| 2090 |
+
'@swc/core-linux-arm64-musl@1.15.17':
|
| 2091 |
+
optional: true
|
| 2092 |
+
|
| 2093 |
+
'@swc/core-linux-x64-gnu@1.15.17':
|
| 2094 |
+
optional: true
|
| 2095 |
+
|
| 2096 |
+
'@swc/core-linux-x64-musl@1.15.17':
|
| 2097 |
+
optional: true
|
| 2098 |
+
|
| 2099 |
+
'@swc/core-win32-arm64-msvc@1.15.17':
|
| 2100 |
+
optional: true
|
| 2101 |
+
|
| 2102 |
+
'@swc/core-win32-ia32-msvc@1.15.17':
|
| 2103 |
+
optional: true
|
| 2104 |
+
|
| 2105 |
+
'@swc/core-win32-x64-msvc@1.15.17':
|
| 2106 |
+
optional: true
|
| 2107 |
+
|
| 2108 |
+
'@swc/core@1.15.17':
|
| 2109 |
+
dependencies:
|
| 2110 |
+
'@swc/counter': 0.1.3
|
| 2111 |
+
'@swc/types': 0.1.25
|
| 2112 |
+
optionalDependencies:
|
| 2113 |
+
'@swc/core-darwin-arm64': 1.15.17
|
| 2114 |
+
'@swc/core-darwin-x64': 1.15.17
|
| 2115 |
+
'@swc/core-linux-arm-gnueabihf': 1.15.17
|
| 2116 |
+
'@swc/core-linux-arm64-gnu': 1.15.17
|
| 2117 |
+
'@swc/core-linux-arm64-musl': 1.15.17
|
| 2118 |
+
'@swc/core-linux-x64-gnu': 1.15.17
|
| 2119 |
+
'@swc/core-linux-x64-musl': 1.15.17
|
| 2120 |
+
'@swc/core-win32-arm64-msvc': 1.15.17
|
| 2121 |
+
'@swc/core-win32-ia32-msvc': 1.15.17
|
| 2122 |
+
'@swc/core-win32-x64-msvc': 1.15.17
|
| 2123 |
+
|
| 2124 |
+
'@swc/counter@0.1.3': {}
|
| 2125 |
+
|
| 2126 |
+
'@swc/types@0.1.25':
|
| 2127 |
+
dependencies:
|
| 2128 |
+
'@swc/counter': 0.1.3
|
| 2129 |
+
|
| 2130 |
+
'@tailwindcss/cli@4.2.1':
|
| 2131 |
+
dependencies:
|
| 2132 |
+
'@parcel/watcher': 2.5.6
|
| 2133 |
+
'@tailwindcss/node': 4.2.1
|
| 2134 |
+
'@tailwindcss/oxide': 4.2.1
|
| 2135 |
+
enhanced-resolve: 5.20.0
|
| 2136 |
+
mri: 1.2.0
|
| 2137 |
+
picocolors: 1.1.1
|
| 2138 |
+
tailwindcss: 4.2.1
|
| 2139 |
+
|
| 2140 |
+
'@tailwindcss/node@4.2.1':
|
| 2141 |
+
dependencies:
|
| 2142 |
+
'@jridgewell/remapping': 2.3.5
|
| 2143 |
+
enhanced-resolve: 5.20.0
|
| 2144 |
+
jiti: 2.6.1
|
| 2145 |
+
lightningcss: 1.31.1
|
| 2146 |
+
magic-string: 0.30.21
|
| 2147 |
+
source-map-js: 1.2.1
|
| 2148 |
+
tailwindcss: 4.2.1
|
| 2149 |
+
|
| 2150 |
+
'@tailwindcss/oxide-android-arm64@4.2.1':
|
| 2151 |
+
optional: true
|
| 2152 |
+
|
| 2153 |
+
'@tailwindcss/oxide-darwin-arm64@4.2.1':
|
| 2154 |
+
optional: true
|
| 2155 |
+
|
| 2156 |
+
'@tailwindcss/oxide-darwin-x64@4.2.1':
|
| 2157 |
+
optional: true
|
| 2158 |
+
|
| 2159 |
+
'@tailwindcss/oxide-freebsd-x64@4.2.1':
|
| 2160 |
+
optional: true
|
| 2161 |
+
|
| 2162 |
+
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
|
| 2163 |
+
optional: true
|
| 2164 |
+
|
| 2165 |
+
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
|
| 2166 |
+
optional: true
|
| 2167 |
+
|
| 2168 |
+
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
|
| 2169 |
+
optional: true
|
| 2170 |
+
|
| 2171 |
+
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
|
| 2172 |
+
optional: true
|
| 2173 |
+
|
| 2174 |
+
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
|
| 2175 |
+
optional: true
|
| 2176 |
+
|
| 2177 |
+
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
|
| 2178 |
+
optional: true
|
| 2179 |
+
|
| 2180 |
+
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
|
| 2181 |
+
optional: true
|
| 2182 |
+
|
| 2183 |
+
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
|
| 2184 |
+
optional: true
|
| 2185 |
+
|
| 2186 |
+
'@tailwindcss/oxide@4.2.1':
|
| 2187 |
+
optionalDependencies:
|
| 2188 |
+
'@tailwindcss/oxide-android-arm64': 4.2.1
|
| 2189 |
+
'@tailwindcss/oxide-darwin-arm64': 4.2.1
|
| 2190 |
+
'@tailwindcss/oxide-darwin-x64': 4.2.1
|
| 2191 |
+
'@tailwindcss/oxide-freebsd-x64': 4.2.1
|
| 2192 |
+
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
|
| 2193 |
+
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
|
| 2194 |
+
'@tailwindcss/oxide-linux-arm64-musl': 4.2.1
|
| 2195 |
+
'@tailwindcss/oxide-linux-x64-gnu': 4.2.1
|
| 2196 |
+
'@tailwindcss/oxide-linux-x64-musl': 4.2.1
|
| 2197 |
+
'@tailwindcss/oxide-wasm32-wasi': 4.2.1
|
| 2198 |
+
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
|
| 2199 |
+
'@tailwindcss/oxide-win32-x64-msvc': 4.2.1
|
| 2200 |
+
|
| 2201 |
+
'@testing-library/dom@10.4.1':
|
| 2202 |
+
dependencies:
|
| 2203 |
+
'@babel/code-frame': 7.29.0
|
| 2204 |
+
'@babel/runtime': 7.28.6
|
| 2205 |
+
'@types/aria-query': 5.0.4
|
| 2206 |
+
aria-query: 5.3.0
|
| 2207 |
+
dom-accessibility-api: 0.5.16
|
| 2208 |
+
lz-string: 1.5.0
|
| 2209 |
+
picocolors: 1.1.1
|
| 2210 |
+
pretty-format: 27.5.1
|
| 2211 |
+
|
| 2212 |
+
'@types/aria-query@5.0.4': {}
|
| 2213 |
+
|
| 2214 |
+
'@types/chai@5.2.3':
|
| 2215 |
+
dependencies:
|
| 2216 |
+
'@types/deep-eql': 4.0.2
|
| 2217 |
+
assertion-error: 2.0.1
|
| 2218 |
+
|
| 2219 |
+
'@types/deep-eql@4.0.2': {}
|
| 2220 |
+
|
| 2221 |
+
'@types/estree@1.0.8': {}
|
| 2222 |
+
|
| 2223 |
+
'@types/node@25.3.2':
|
| 2224 |
+
dependencies:
|
| 2225 |
+
undici-types: 7.18.2
|
| 2226 |
+
|
| 2227 |
+
'@types/whatwg-mimetype@3.0.2': {}
|
| 2228 |
+
|
| 2229 |
+
'@types/ws@8.18.1':
|
| 2230 |
+
dependencies:
|
| 2231 |
+
'@types/node': 25.3.2
|
| 2232 |
+
|
| 2233 |
+
'@vitest/coverage-v8@4.0.18(vitest@4.0.18)':
|
| 2234 |
+
dependencies:
|
| 2235 |
+
'@bcoe/v8-coverage': 1.0.2
|
| 2236 |
+
'@vitest/utils': 4.0.18
|
| 2237 |
+
ast-v8-to-istanbul: 0.3.12
|
| 2238 |
+
istanbul-lib-coverage: 3.2.2
|
| 2239 |
+
istanbul-lib-report: 3.0.1
|
| 2240 |
+
istanbul-reports: 3.2.0
|
| 2241 |
+
magicast: 0.5.2
|
| 2242 |
+
obug: 2.1.1
|
| 2243 |
+
std-env: 3.10.0
|
| 2244 |
+
tinyrainbow: 3.0.3
|
| 2245 |
+
vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
|
| 2246 |
+
|
| 2247 |
+
'@vitest/expect@4.0.18':
|
| 2248 |
+
dependencies:
|
| 2249 |
+
'@standard-schema/spec': 1.1.0
|
| 2250 |
+
'@types/chai': 5.2.3
|
| 2251 |
+
'@vitest/spy': 4.0.18
|
| 2252 |
+
'@vitest/utils': 4.0.18
|
| 2253 |
+
chai: 6.2.2
|
| 2254 |
+
tinyrainbow: 3.0.3
|
| 2255 |
+
|
| 2256 |
+
'@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1))':
|
| 2257 |
+
dependencies:
|
| 2258 |
+
'@vitest/spy': 4.0.18
|
| 2259 |
+
estree-walker: 3.0.3
|
| 2260 |
+
magic-string: 0.30.21
|
| 2261 |
+
optionalDependencies:
|
| 2262 |
+
vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
|
| 2263 |
+
|
| 2264 |
+
'@vitest/pretty-format@4.0.18':
|
| 2265 |
+
dependencies:
|
| 2266 |
+
tinyrainbow: 3.0.3
|
| 2267 |
+
|
| 2268 |
+
'@vitest/runner@4.0.18':
|
| 2269 |
+
dependencies:
|
| 2270 |
+
'@vitest/utils': 4.0.18
|
| 2271 |
+
pathe: 2.0.3
|
| 2272 |
+
|
| 2273 |
+
'@vitest/snapshot@4.0.18':
|
| 2274 |
+
dependencies:
|
| 2275 |
+
'@vitest/pretty-format': 4.0.18
|
| 2276 |
+
magic-string: 0.30.21
|
| 2277 |
+
pathe: 2.0.3
|
| 2278 |
+
|
| 2279 |
+
'@vitest/spy@4.0.18': {}
|
| 2280 |
+
|
| 2281 |
+
'@vitest/ui@4.0.18(vitest@4.0.18)':
|
| 2282 |
+
dependencies:
|
| 2283 |
+
'@vitest/utils': 4.0.18
|
| 2284 |
+
fflate: 0.8.2
|
| 2285 |
+
flatted: 3.3.3
|
| 2286 |
+
pathe: 2.0.3
|
| 2287 |
+
sirv: 3.0.2
|
| 2288 |
+
tinyglobby: 0.2.15
|
| 2289 |
+
tinyrainbow: 3.0.3
|
| 2290 |
+
vitest: 4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1)
|
| 2291 |
+
|
| 2292 |
+
'@vitest/utils@4.0.18':
|
| 2293 |
+
dependencies:
|
| 2294 |
+
'@vitest/pretty-format': 4.0.18
|
| 2295 |
+
tinyrainbow: 3.0.3
|
| 2296 |
+
|
| 2297 |
+
agent-base@7.1.4: {}
|
| 2298 |
+
|
| 2299 |
+
ansi-regex@5.0.1: {}
|
| 2300 |
+
|
| 2301 |
+
ansi-styles@5.2.0: {}
|
| 2302 |
+
|
| 2303 |
+
aria-query@5.3.0:
|
| 2304 |
+
dependencies:
|
| 2305 |
+
dequal: 2.0.3
|
| 2306 |
+
|
| 2307 |
+
assertion-error@2.0.1: {}
|
| 2308 |
+
|
| 2309 |
+
ast-v8-to-istanbul@0.3.12:
|
| 2310 |
+
dependencies:
|
| 2311 |
+
'@jridgewell/trace-mapping': 0.3.31
|
| 2312 |
+
estree-walker: 3.0.3
|
| 2313 |
+
js-tokens: 10.0.0
|
| 2314 |
+
|
| 2315 |
+
chai@6.2.2: {}
|
| 2316 |
+
|
| 2317 |
+
cssstyle@4.6.0:
|
| 2318 |
+
dependencies:
|
| 2319 |
+
'@asamuzakjp/css-color': 3.2.0
|
| 2320 |
+
rrweb-cssom: 0.8.0
|
| 2321 |
+
|
| 2322 |
+
data-urls@5.0.0:
|
| 2323 |
+
dependencies:
|
| 2324 |
+
whatwg-mimetype: 4.0.0
|
| 2325 |
+
whatwg-url: 14.2.0
|
| 2326 |
+
|
| 2327 |
+
debug@4.4.3:
|
| 2328 |
+
dependencies:
|
| 2329 |
+
ms: 2.1.3
|
| 2330 |
+
|
| 2331 |
+
decimal.js@10.6.0: {}
|
| 2332 |
+
|
| 2333 |
+
dequal@2.0.3: {}
|
| 2334 |
+
|
| 2335 |
+
detect-libc@2.1.2: {}
|
| 2336 |
+
|
| 2337 |
+
dom-accessibility-api@0.5.16: {}
|
| 2338 |
+
|
| 2339 |
+
enhanced-resolve@5.20.0:
|
| 2340 |
+
dependencies:
|
| 2341 |
+
graceful-fs: 4.2.11
|
| 2342 |
+
tapable: 2.3.0
|
| 2343 |
+
|
| 2344 |
+
entities@6.0.1: {}
|
| 2345 |
+
|
| 2346 |
+
entities@7.0.1: {}
|
| 2347 |
+
|
| 2348 |
+
es-module-lexer@1.7.0: {}
|
| 2349 |
+
|
| 2350 |
+
esbuild@0.25.12:
|
| 2351 |
+
optionalDependencies:
|
| 2352 |
+
'@esbuild/aix-ppc64': 0.25.12
|
| 2353 |
+
'@esbuild/android-arm': 0.25.12
|
| 2354 |
+
'@esbuild/android-arm64': 0.25.12
|
| 2355 |
+
'@esbuild/android-x64': 0.25.12
|
| 2356 |
+
'@esbuild/darwin-arm64': 0.25.12
|
| 2357 |
+
'@esbuild/darwin-x64': 0.25.12
|
| 2358 |
+
'@esbuild/freebsd-arm64': 0.25.12
|
| 2359 |
+
'@esbuild/freebsd-x64': 0.25.12
|
| 2360 |
+
'@esbuild/linux-arm': 0.25.12
|
| 2361 |
+
'@esbuild/linux-arm64': 0.25.12
|
| 2362 |
+
'@esbuild/linux-ia32': 0.25.12
|
| 2363 |
+
'@esbuild/linux-loong64': 0.25.12
|
| 2364 |
+
'@esbuild/linux-mips64el': 0.25.12
|
| 2365 |
+
'@esbuild/linux-ppc64': 0.25.12
|
| 2366 |
+
'@esbuild/linux-riscv64': 0.25.12
|
| 2367 |
+
'@esbuild/linux-s390x': 0.25.12
|
| 2368 |
+
'@esbuild/linux-x64': 0.25.12
|
| 2369 |
+
'@esbuild/netbsd-arm64': 0.25.12
|
| 2370 |
+
'@esbuild/netbsd-x64': 0.25.12
|
| 2371 |
+
'@esbuild/openbsd-arm64': 0.25.12
|
| 2372 |
+
'@esbuild/openbsd-x64': 0.25.12
|
| 2373 |
+
'@esbuild/openharmony-arm64': 0.25.12
|
| 2374 |
+
'@esbuild/sunos-x64': 0.25.12
|
| 2375 |
+
'@esbuild/win32-arm64': 0.25.12
|
| 2376 |
+
'@esbuild/win32-ia32': 0.25.12
|
| 2377 |
+
'@esbuild/win32-x64': 0.25.12
|
| 2378 |
+
|
| 2379 |
+
esbuild@0.27.3:
|
| 2380 |
+
optionalDependencies:
|
| 2381 |
+
'@esbuild/aix-ppc64': 0.27.3
|
| 2382 |
+
'@esbuild/android-arm': 0.27.3
|
| 2383 |
+
'@esbuild/android-arm64': 0.27.3
|
| 2384 |
+
'@esbuild/android-x64': 0.27.3
|
| 2385 |
+
'@esbuild/darwin-arm64': 0.27.3
|
| 2386 |
+
'@esbuild/darwin-x64': 0.27.3
|
| 2387 |
+
'@esbuild/freebsd-arm64': 0.27.3
|
| 2388 |
+
'@esbuild/freebsd-x64': 0.27.3
|
| 2389 |
+
'@esbuild/linux-arm': 0.27.3
|
| 2390 |
+
'@esbuild/linux-arm64': 0.27.3
|
| 2391 |
+
'@esbuild/linux-ia32': 0.27.3
|
| 2392 |
+
'@esbuild/linux-loong64': 0.27.3
|
| 2393 |
+
'@esbuild/linux-mips64el': 0.27.3
|
| 2394 |
+
'@esbuild/linux-ppc64': 0.27.3
|
| 2395 |
+
'@esbuild/linux-riscv64': 0.27.3
|
| 2396 |
+
'@esbuild/linux-s390x': 0.27.3
|
| 2397 |
+
'@esbuild/linux-x64': 0.27.3
|
| 2398 |
+
'@esbuild/netbsd-arm64': 0.27.3
|
| 2399 |
+
'@esbuild/netbsd-x64': 0.27.3
|
| 2400 |
+
'@esbuild/openbsd-arm64': 0.27.3
|
| 2401 |
+
'@esbuild/openbsd-x64': 0.27.3
|
| 2402 |
+
'@esbuild/openharmony-arm64': 0.27.3
|
| 2403 |
+
'@esbuild/sunos-x64': 0.27.3
|
| 2404 |
+
'@esbuild/win32-arm64': 0.27.3
|
| 2405 |
+
'@esbuild/win32-ia32': 0.27.3
|
| 2406 |
+
'@esbuild/win32-x64': 0.27.3
|
| 2407 |
+
|
| 2408 |
+
estree-walker@3.0.3:
|
| 2409 |
+
dependencies:
|
| 2410 |
+
'@types/estree': 1.0.8
|
| 2411 |
+
|
| 2412 |
+
expect-type@1.3.0: {}
|
| 2413 |
+
|
| 2414 |
+
fdir@6.5.0(picomatch@4.0.3):
|
| 2415 |
+
optionalDependencies:
|
| 2416 |
+
picomatch: 4.0.3
|
| 2417 |
+
|
| 2418 |
+
fflate@0.8.2: {}
|
| 2419 |
+
|
| 2420 |
+
flatted@3.3.3: {}
|
| 2421 |
+
|
| 2422 |
+
fsevents@2.3.2:
|
| 2423 |
+
optional: true
|
| 2424 |
+
|
| 2425 |
+
fsevents@2.3.3:
|
| 2426 |
+
optional: true
|
| 2427 |
+
|
| 2428 |
+
graceful-fs@4.2.11: {}
|
| 2429 |
+
|
| 2430 |
+
happy-dom@20.7.0:
|
| 2431 |
+
dependencies:
|
| 2432 |
+
'@types/node': 25.3.2
|
| 2433 |
+
'@types/whatwg-mimetype': 3.0.2
|
| 2434 |
+
'@types/ws': 8.18.1
|
| 2435 |
+
entities: 7.0.1
|
| 2436 |
+
whatwg-mimetype: 3.0.0
|
| 2437 |
+
ws: 8.19.0
|
| 2438 |
+
transitivePeerDependencies:
|
| 2439 |
+
- bufferutil
|
| 2440 |
+
- utf-8-validate
|
| 2441 |
+
|
| 2442 |
+
has-flag@4.0.0: {}
|
| 2443 |
+
|
| 2444 |
+
heroicons@2.2.0: {}
|
| 2445 |
+
|
| 2446 |
+
html-encoding-sniffer@4.0.0:
|
| 2447 |
+
dependencies:
|
| 2448 |
+
whatwg-encoding: 3.1.1
|
| 2449 |
+
|
| 2450 |
+
html-escaper@2.0.2: {}
|
| 2451 |
+
|
| 2452 |
+
http-proxy-agent@7.0.2:
|
| 2453 |
+
dependencies:
|
| 2454 |
+
agent-base: 7.1.4
|
| 2455 |
+
debug: 4.4.3
|
| 2456 |
+
transitivePeerDependencies:
|
| 2457 |
+
- supports-color
|
| 2458 |
+
|
| 2459 |
+
https-proxy-agent@7.0.6:
|
| 2460 |
+
dependencies:
|
| 2461 |
+
agent-base: 7.1.4
|
| 2462 |
+
debug: 4.4.3
|
| 2463 |
+
transitivePeerDependencies:
|
| 2464 |
+
- supports-color
|
| 2465 |
+
|
| 2466 |
+
iconv-lite@0.6.3:
|
| 2467 |
+
dependencies:
|
| 2468 |
+
safer-buffer: 2.1.2
|
| 2469 |
+
|
| 2470 |
+
is-extglob@2.1.1: {}
|
| 2471 |
+
|
| 2472 |
+
is-glob@4.0.3:
|
| 2473 |
+
dependencies:
|
| 2474 |
+
is-extglob: 2.1.1
|
| 2475 |
+
|
| 2476 |
+
is-potential-custom-element-name@1.0.1: {}
|
| 2477 |
+
|
| 2478 |
+
istanbul-lib-coverage@3.2.2: {}
|
| 2479 |
+
|
| 2480 |
+
istanbul-lib-report@3.0.1:
|
| 2481 |
+
dependencies:
|
| 2482 |
+
istanbul-lib-coverage: 3.2.2
|
| 2483 |
+
make-dir: 4.0.0
|
| 2484 |
+
supports-color: 7.2.0
|
| 2485 |
+
|
| 2486 |
+
istanbul-reports@3.2.0:
|
| 2487 |
+
dependencies:
|
| 2488 |
+
html-escaper: 2.0.2
|
| 2489 |
+
istanbul-lib-report: 3.0.1
|
| 2490 |
+
|
| 2491 |
+
jiti@2.6.1: {}
|
| 2492 |
+
|
| 2493 |
+
js-tokens@10.0.0: {}
|
| 2494 |
+
|
| 2495 |
+
js-tokens@4.0.0: {}
|
| 2496 |
+
|
| 2497 |
+
jsdom@26.1.0(@napi-rs/canvas@0.1.95):
|
| 2498 |
+
dependencies:
|
| 2499 |
+
cssstyle: 4.6.0
|
| 2500 |
+
data-urls: 5.0.0
|
| 2501 |
+
decimal.js: 10.6.0
|
| 2502 |
+
html-encoding-sniffer: 4.0.0
|
| 2503 |
+
http-proxy-agent: 7.0.2
|
| 2504 |
+
https-proxy-agent: 7.0.6
|
| 2505 |
+
is-potential-custom-element-name: 1.0.1
|
| 2506 |
+
nwsapi: 2.2.23
|
| 2507 |
+
parse5: 7.3.0
|
| 2508 |
+
rrweb-cssom: 0.8.0
|
| 2509 |
+
saxes: 6.0.0
|
| 2510 |
+
symbol-tree: 3.2.4
|
| 2511 |
+
tough-cookie: 5.1.2
|
| 2512 |
+
w3c-xmlserializer: 5.0.0
|
| 2513 |
+
webidl-conversions: 7.0.0
|
| 2514 |
+
whatwg-encoding: 3.1.1
|
| 2515 |
+
whatwg-mimetype: 4.0.0
|
| 2516 |
+
whatwg-url: 14.2.0
|
| 2517 |
+
ws: 8.19.0
|
| 2518 |
+
xml-name-validator: 5.0.0
|
| 2519 |
+
optionalDependencies:
|
| 2520 |
+
canvas: '@napi-rs/canvas@0.1.95'
|
| 2521 |
+
transitivePeerDependencies:
|
| 2522 |
+
- bufferutil
|
| 2523 |
+
- supports-color
|
| 2524 |
+
- utf-8-validate
|
| 2525 |
+
|
| 2526 |
+
lightningcss-android-arm64@1.31.1:
|
| 2527 |
+
optional: true
|
| 2528 |
+
|
| 2529 |
+
lightningcss-darwin-arm64@1.31.1:
|
| 2530 |
+
optional: true
|
| 2531 |
+
|
| 2532 |
+
lightningcss-darwin-x64@1.31.1:
|
| 2533 |
+
optional: true
|
| 2534 |
+
|
| 2535 |
+
lightningcss-freebsd-x64@1.31.1:
|
| 2536 |
+
optional: true
|
| 2537 |
+
|
| 2538 |
+
lightningcss-linux-arm-gnueabihf@1.31.1:
|
| 2539 |
+
optional: true
|
| 2540 |
+
|
| 2541 |
+
lightningcss-linux-arm64-gnu@1.31.1:
|
| 2542 |
+
optional: true
|
| 2543 |
+
|
| 2544 |
+
lightningcss-linux-arm64-musl@1.31.1:
|
| 2545 |
+
optional: true
|
| 2546 |
+
|
| 2547 |
+
lightningcss-linux-x64-gnu@1.31.1:
|
| 2548 |
+
optional: true
|
| 2549 |
+
|
| 2550 |
+
lightningcss-linux-x64-musl@1.31.1:
|
| 2551 |
+
optional: true
|
| 2552 |
+
|
| 2553 |
+
lightningcss-win32-arm64-msvc@1.31.1:
|
| 2554 |
+
optional: true
|
| 2555 |
+
|
| 2556 |
+
lightningcss-win32-x64-msvc@1.31.1:
|
| 2557 |
+
optional: true
|
| 2558 |
+
|
| 2559 |
+
lightningcss@1.31.1:
|
| 2560 |
+
dependencies:
|
| 2561 |
+
detect-libc: 2.1.2
|
| 2562 |
+
optionalDependencies:
|
| 2563 |
+
lightningcss-android-arm64: 1.31.1
|
| 2564 |
+
lightningcss-darwin-arm64: 1.31.1
|
| 2565 |
+
lightningcss-darwin-x64: 1.31.1
|
| 2566 |
+
lightningcss-freebsd-x64: 1.31.1
|
| 2567 |
+
lightningcss-linux-arm-gnueabihf: 1.31.1
|
| 2568 |
+
lightningcss-linux-arm64-gnu: 1.31.1
|
| 2569 |
+
lightningcss-linux-arm64-musl: 1.31.1
|
| 2570 |
+
lightningcss-linux-x64-gnu: 1.31.1
|
| 2571 |
+
lightningcss-linux-x64-musl: 1.31.1
|
| 2572 |
+
lightningcss-win32-arm64-msvc: 1.31.1
|
| 2573 |
+
lightningcss-win32-x64-msvc: 1.31.1
|
| 2574 |
+
|
| 2575 |
+
lru-cache@10.4.3: {}
|
| 2576 |
+
|
| 2577 |
+
lz-string@1.5.0: {}
|
| 2578 |
+
|
| 2579 |
+
magic-string@0.30.21:
|
| 2580 |
+
dependencies:
|
| 2581 |
+
'@jridgewell/sourcemap-codec': 1.5.5
|
| 2582 |
+
|
| 2583 |
+
magicast@0.5.2:
|
| 2584 |
+
dependencies:
|
| 2585 |
+
'@babel/parser': 7.29.0
|
| 2586 |
+
'@babel/types': 7.29.0
|
| 2587 |
+
source-map-js: 1.2.1
|
| 2588 |
+
|
| 2589 |
+
make-dir@4.0.0:
|
| 2590 |
+
dependencies:
|
| 2591 |
+
semver: 7.7.4
|
| 2592 |
+
|
| 2593 |
+
mri@1.2.0: {}
|
| 2594 |
+
|
| 2595 |
+
mrmime@2.0.1: {}
|
| 2596 |
+
|
| 2597 |
+
ms@2.1.3: {}
|
| 2598 |
+
|
| 2599 |
+
nanoid@3.3.11: {}
|
| 2600 |
+
|
| 2601 |
+
node-addon-api@7.1.1: {}
|
| 2602 |
+
|
| 2603 |
+
nwsapi@2.2.23: {}
|
| 2604 |
+
|
| 2605 |
+
obug@2.1.1: {}
|
| 2606 |
+
|
| 2607 |
+
parse5@7.3.0:
|
| 2608 |
+
dependencies:
|
| 2609 |
+
entities: 6.0.1
|
| 2610 |
+
|
| 2611 |
+
pathe@2.0.3: {}
|
| 2612 |
+
|
| 2613 |
+
picocolors@1.1.1: {}
|
| 2614 |
+
|
| 2615 |
+
picomatch@4.0.3: {}
|
| 2616 |
+
|
| 2617 |
+
playwright-core@1.58.2: {}
|
| 2618 |
+
|
| 2619 |
+
playwright@1.58.2:
|
| 2620 |
+
dependencies:
|
| 2621 |
+
playwright-core: 1.58.2
|
| 2622 |
+
optionalDependencies:
|
| 2623 |
+
fsevents: 2.3.2
|
| 2624 |
+
|
| 2625 |
+
postcss@8.5.6:
|
| 2626 |
+
dependencies:
|
| 2627 |
+
nanoid: 3.3.11
|
| 2628 |
+
picocolors: 1.1.1
|
| 2629 |
+
source-map-js: 1.2.1
|
| 2630 |
+
|
| 2631 |
+
pretty-format@27.5.1:
|
| 2632 |
+
dependencies:
|
| 2633 |
+
ansi-regex: 5.0.1
|
| 2634 |
+
ansi-styles: 5.2.0
|
| 2635 |
+
react-is: 17.0.2
|
| 2636 |
+
|
| 2637 |
+
punycode@2.3.1: {}
|
| 2638 |
+
|
| 2639 |
+
react-is@17.0.2: {}
|
| 2640 |
+
|
| 2641 |
+
rollup@4.59.0:
|
| 2642 |
+
dependencies:
|
| 2643 |
+
'@types/estree': 1.0.8
|
| 2644 |
+
optionalDependencies:
|
| 2645 |
+
'@rollup/rollup-android-arm-eabi': 4.59.0
|
| 2646 |
+
'@rollup/rollup-android-arm64': 4.59.0
|
| 2647 |
+
'@rollup/rollup-darwin-arm64': 4.59.0
|
| 2648 |
+
'@rollup/rollup-darwin-x64': 4.59.0
|
| 2649 |
+
'@rollup/rollup-freebsd-arm64': 4.59.0
|
| 2650 |
+
'@rollup/rollup-freebsd-x64': 4.59.0
|
| 2651 |
+
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
|
| 2652 |
+
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
|
| 2653 |
+
'@rollup/rollup-linux-arm64-gnu': 4.59.0
|
| 2654 |
+
'@rollup/rollup-linux-arm64-musl': 4.59.0
|
| 2655 |
+
'@rollup/rollup-linux-loong64-gnu': 4.59.0
|
| 2656 |
+
'@rollup/rollup-linux-loong64-musl': 4.59.0
|
| 2657 |
+
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
|
| 2658 |
+
'@rollup/rollup-linux-ppc64-musl': 4.59.0
|
| 2659 |
+
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
|
| 2660 |
+
'@rollup/rollup-linux-riscv64-musl': 4.59.0
|
| 2661 |
+
'@rollup/rollup-linux-s390x-gnu': 4.59.0
|
| 2662 |
+
'@rollup/rollup-linux-x64-gnu': 4.59.0
|
| 2663 |
+
'@rollup/rollup-linux-x64-musl': 4.59.0
|
| 2664 |
+
'@rollup/rollup-openbsd-x64': 4.59.0
|
| 2665 |
+
'@rollup/rollup-openharmony-arm64': 4.59.0
|
| 2666 |
+
'@rollup/rollup-win32-arm64-msvc': 4.59.0
|
| 2667 |
+
'@rollup/rollup-win32-ia32-msvc': 4.59.0
|
| 2668 |
+
'@rollup/rollup-win32-x64-gnu': 4.59.0
|
| 2669 |
+
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
| 2670 |
+
fsevents: 2.3.3
|
| 2671 |
+
|
| 2672 |
+
rrweb-cssom@0.8.0: {}
|
| 2673 |
+
|
| 2674 |
+
safer-buffer@2.1.2: {}
|
| 2675 |
+
|
| 2676 |
+
saxes@6.0.0:
|
| 2677 |
+
dependencies:
|
| 2678 |
+
xmlchars: 2.2.0
|
| 2679 |
+
|
| 2680 |
+
semver@7.7.4: {}
|
| 2681 |
+
|
| 2682 |
+
siginfo@2.0.0: {}
|
| 2683 |
+
|
| 2684 |
+
sirv@3.0.2:
|
| 2685 |
+
dependencies:
|
| 2686 |
+
'@polka/url': 1.0.0-next.29
|
| 2687 |
+
mrmime: 2.0.1
|
| 2688 |
+
totalist: 3.0.1
|
| 2689 |
+
|
| 2690 |
+
source-map-js@1.2.1: {}
|
| 2691 |
+
|
| 2692 |
+
stackback@0.0.2: {}
|
| 2693 |
+
|
| 2694 |
+
std-env@3.10.0: {}
|
| 2695 |
+
|
| 2696 |
+
supports-color@7.2.0:
|
| 2697 |
+
dependencies:
|
| 2698 |
+
has-flag: 4.0.0
|
| 2699 |
+
|
| 2700 |
+
symbol-tree@3.2.4: {}
|
| 2701 |
+
|
| 2702 |
+
tailwindcss@4.2.1: {}
|
| 2703 |
+
|
| 2704 |
+
tapable@2.3.0: {}
|
| 2705 |
+
|
| 2706 |
+
tinybench@2.9.0: {}
|
| 2707 |
+
|
| 2708 |
+
tinyexec@1.0.2: {}
|
| 2709 |
+
|
| 2710 |
+
tinyglobby@0.2.15:
|
| 2711 |
+
dependencies:
|
| 2712 |
+
fdir: 6.5.0(picomatch@4.0.3)
|
| 2713 |
+
picomatch: 4.0.3
|
| 2714 |
+
|
| 2715 |
+
tinyrainbow@3.0.3: {}
|
| 2716 |
+
|
| 2717 |
+
tldts-core@6.1.86: {}
|
| 2718 |
+
|
| 2719 |
+
tldts@6.1.86:
|
| 2720 |
+
dependencies:
|
| 2721 |
+
tldts-core: 6.1.86
|
| 2722 |
+
|
| 2723 |
+
totalist@3.0.1: {}
|
| 2724 |
+
|
| 2725 |
+
tough-cookie@5.1.2:
|
| 2726 |
+
dependencies:
|
| 2727 |
+
tldts: 6.1.86
|
| 2728 |
+
|
| 2729 |
+
tr46@5.1.1:
|
| 2730 |
+
dependencies:
|
| 2731 |
+
punycode: 2.3.1
|
| 2732 |
+
|
| 2733 |
+
undici-types@7.18.2: {}
|
| 2734 |
+
|
| 2735 |
+
vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1):
|
| 2736 |
+
dependencies:
|
| 2737 |
+
esbuild: 0.25.12
|
| 2738 |
+
fdir: 6.5.0(picomatch@4.0.3)
|
| 2739 |
+
picomatch: 4.0.3
|
| 2740 |
+
postcss: 8.5.6
|
| 2741 |
+
rollup: 4.59.0
|
| 2742 |
+
tinyglobby: 0.2.15
|
| 2743 |
+
optionalDependencies:
|
| 2744 |
+
'@types/node': 25.3.2
|
| 2745 |
+
fsevents: 2.3.3
|
| 2746 |
+
jiti: 2.6.1
|
| 2747 |
+
lightningcss: 1.31.1
|
| 2748 |
+
|
| 2749 |
+
vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1):
|
| 2750 |
+
dependencies:
|
| 2751 |
+
esbuild: 0.27.3
|
| 2752 |
+
fdir: 6.5.0(picomatch@4.0.3)
|
| 2753 |
+
picomatch: 4.0.3
|
| 2754 |
+
postcss: 8.5.6
|
| 2755 |
+
rollup: 4.59.0
|
| 2756 |
+
tinyglobby: 0.2.15
|
| 2757 |
+
optionalDependencies:
|
| 2758 |
+
'@types/node': 25.3.2
|
| 2759 |
+
fsevents: 2.3.3
|
| 2760 |
+
jiti: 2.6.1
|
| 2761 |
+
lightningcss: 1.31.1
|
| 2762 |
+
|
| 2763 |
+
vitest@4.0.18(@types/node@25.3.2)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jiti@2.6.1)(jsdom@26.1.0(@napi-rs/canvas@0.1.95))(lightningcss@1.31.1):
|
| 2764 |
+
dependencies:
|
| 2765 |
+
'@vitest/expect': 4.0.18
|
| 2766 |
+
'@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1))
|
| 2767 |
+
'@vitest/pretty-format': 4.0.18
|
| 2768 |
+
'@vitest/runner': 4.0.18
|
| 2769 |
+
'@vitest/snapshot': 4.0.18
|
| 2770 |
+
'@vitest/spy': 4.0.18
|
| 2771 |
+
'@vitest/utils': 4.0.18
|
| 2772 |
+
es-module-lexer: 1.7.0
|
| 2773 |
+
expect-type: 1.3.0
|
| 2774 |
+
magic-string: 0.30.21
|
| 2775 |
+
obug: 2.1.1
|
| 2776 |
+
pathe: 2.0.3
|
| 2777 |
+
picomatch: 4.0.3
|
| 2778 |
+
std-env: 3.10.0
|
| 2779 |
+
tinybench: 2.9.0
|
| 2780 |
+
tinyexec: 1.0.2
|
| 2781 |
+
tinyglobby: 0.2.15
|
| 2782 |
+
tinyrainbow: 3.0.3
|
| 2783 |
+
vite: 6.4.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)
|
| 2784 |
+
why-is-node-running: 2.3.0
|
| 2785 |
+
optionalDependencies:
|
| 2786 |
+
'@types/node': 25.3.2
|
| 2787 |
+
'@vitest/ui': 4.0.18(vitest@4.0.18)
|
| 2788 |
+
happy-dom: 20.7.0
|
| 2789 |
+
jsdom: 26.1.0(@napi-rs/canvas@0.1.95)
|
| 2790 |
+
transitivePeerDependencies:
|
| 2791 |
+
- jiti
|
| 2792 |
+
- less
|
| 2793 |
+
- lightningcss
|
| 2794 |
+
- msw
|
| 2795 |
+
- sass
|
| 2796 |
+
- sass-embedded
|
| 2797 |
+
- stylus
|
| 2798 |
+
- sugarss
|
| 2799 |
+
- terser
|
| 2800 |
+
- tsx
|
| 2801 |
+
- yaml
|
| 2802 |
+
|
| 2803 |
+
w3c-xmlserializer@5.0.0:
|
| 2804 |
+
dependencies:
|
| 2805 |
+
xml-name-validator: 5.0.0
|
| 2806 |
+
|
| 2807 |
+
webidl-conversions@7.0.0: {}
|
| 2808 |
+
|
| 2809 |
+
whatwg-encoding@3.1.1:
|
| 2810 |
+
dependencies:
|
| 2811 |
+
iconv-lite: 0.6.3
|
| 2812 |
+
|
| 2813 |
+
whatwg-mimetype@3.0.0: {}
|
| 2814 |
+
|
| 2815 |
+
whatwg-mimetype@4.0.0: {}
|
| 2816 |
+
|
| 2817 |
+
whatwg-url@14.2.0:
|
| 2818 |
+
dependencies:
|
| 2819 |
+
tr46: 5.1.1
|
| 2820 |
+
webidl-conversions: 7.0.0
|
| 2821 |
+
|
| 2822 |
+
why-is-node-running@2.3.0:
|
| 2823 |
+
dependencies:
|
| 2824 |
+
siginfo: 2.0.0
|
| 2825 |
+
stackback: 0.0.2
|
| 2826 |
+
|
| 2827 |
+
ws@8.19.0: {}
|
| 2828 |
+
|
| 2829 |
+
xml-name-validator@5.0.0: {}
|
| 2830 |
+
|
| 2831 |
+
xmlchars@2.2.0: {}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["uv_build>=0.9.26"]
|
| 3 |
+
build-backend = "uv_build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "aspara"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Blazingly fast metrics tracker for machine learning experiments"
|
| 9 |
+
authors = [
|
| 10 |
+
{name = "TOKUNAGA Hiroyuki"}
|
| 11 |
+
]
|
| 12 |
+
license = "Apache-2.0"
|
| 13 |
+
readme = "README.md"
|
| 14 |
+
requires-python = ">=3.10"
|
| 15 |
+
dependencies = [
|
| 16 |
+
"polars>=1.37.1",
|
| 17 |
+
"pydantic>=2.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[project.optional-dependencies]
|
| 21 |
+
tracker = [
|
| 22 |
+
"uvicorn>=0.27.0",
|
| 23 |
+
"fastapi>=0.115.12",
|
| 24 |
+
"python-multipart>=0.0.22",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
dashboard = [
|
| 28 |
+
"uvicorn>=0.27.0",
|
| 29 |
+
"sse-starlette>=1.8.0",
|
| 30 |
+
"aiofiles>=23.2.0",
|
| 31 |
+
"watchfiles>=1.1.1",
|
| 32 |
+
"pystache>=0.6.8",
|
| 33 |
+
"fastapi>=0.115.12",
|
| 34 |
+
"lttb>=0.3.2",
|
| 35 |
+
"numpy>=1.24.0",
|
| 36 |
+
"msgpack>=1.0.0",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
remote = [
|
| 40 |
+
"requests>=2.31.0",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
tui = [
|
| 44 |
+
"textual>=0.47.0",
|
| 45 |
+
"textual-plotext>=0.2.0",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
all = [
|
| 49 |
+
"aspara[tracker]",
|
| 50 |
+
"aspara[dashboard]",
|
| 51 |
+
"aspara[remote]",
|
| 52 |
+
"aspara[tui]",
|
| 53 |
+
"aspara[docs]",
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
docs = [
|
| 57 |
+
"mkdocs>=1.6.0",
|
| 58 |
+
"mkdocs-material>=9.6.0",
|
| 59 |
+
"mkdocstrings[python]>=0.24.0",
|
| 60 |
+
"mkdocs-autorefs>=0.5.0",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
[project.scripts]
|
| 64 |
+
aspara = "aspara.cli:main"
|
| 65 |
+
|
| 66 |
+
[dependency-groups]
|
| 67 |
+
dev = [
|
| 68 |
+
"aspara[all]",
|
| 69 |
+
"pytest>=8.3.4",
|
| 70 |
+
"pytest-asyncio>=1.0.0",
|
| 71 |
+
"pytest-cov>=7.0.0",
|
| 72 |
+
"mkdocs>=1.6.0",
|
| 73 |
+
"mkdocs-material>=9.6.0",
|
| 74 |
+
"mkdocstrings[python]>=0.24.0",
|
| 75 |
+
"mkdocs-autorefs>=0.5.0",
|
| 76 |
+
"ruff>=0.14.10",
|
| 77 |
+
"playwright>=1.52.0",
|
| 78 |
+
"pyrefly>=0.46.1",
|
| 79 |
+
"py-spy>=0.4.1",
|
| 80 |
+
"types-requests>=2.31.0",
|
| 81 |
+
"ty>=0.0.12",
|
| 82 |
+
"bandit>=1.9.3",
|
| 83 |
+
"httpx>=0.28.1",
|
| 84 |
+
"mypy>=1.19.1",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
[tool.ruff]
|
| 88 |
+
line-length = 160
|
| 89 |
+
indent-width = 4
|
| 90 |
+
target-version = "py310"
|
| 91 |
+
extend-exclude = [".venv", "build", "dist"]
|
| 92 |
+
src = ["src"]
|
| 93 |
+
|
| 94 |
+
[tool.ruff.lint]
|
| 95 |
+
select = ["E", "F", "W", "B", "I"]
|
| 96 |
+
extend-select = [
|
| 97 |
+
"C4", # flake8-comprehensions
|
| 98 |
+
"SIM", # flake8-simplify
|
| 99 |
+
"ERA", # eradicate
|
| 100 |
+
"UP", # pyupgrade
|
| 101 |
+
]
|
| 102 |
+
extend-ignore = ["SIM108"]
|
| 103 |
+
|
| 104 |
+
[tool.pyrefly]
|
| 105 |
+
project-includes = [
|
| 106 |
+
"src/**/*.py*",
|
| 107 |
+
"tests/**/*.py*",
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
[tool.ruff.format]
|
| 112 |
+
quote-style = "double"
|
| 113 |
+
indent-style = "space"
|
| 114 |
+
preview = true
|
| 115 |
+
line-ending = "auto"
|
| 116 |
+
docstring-code-format = true
|
| 117 |
+
|
| 118 |
+
[tool.ruff.lint.isort]
|
| 119 |
+
known-first-party = ["aspara"]
|
| 120 |
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
|
| 121 |
+
|
| 122 |
+
[tool.mypy]
|
| 123 |
+
ignore_missing_imports = true
|
scripts/build-icons.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Build script to generate SVG symbol sprites from heroicons.
|
| 5 |
+
*
|
| 6 |
+
* Reads icons.config.json and generates _icons.mustache partial
|
| 7 |
+
* containing SVG symbols that can be referenced via <use href="#id">.
|
| 8 |
+
*
|
| 9 |
+
* Usage: node scripts/build-icons.js
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { readFileSync, writeFileSync } from 'node:fs';
|
| 13 |
+
import { dirname, join } from 'node:path';
|
| 14 |
+
import { fileURLToPath } from 'node:url';
|
| 15 |
+
|
| 16 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 17 |
+
const ROOT_DIR = join(__dirname, '..');
|
| 18 |
+
|
| 19 |
+
const CONFIG_PATH = join(ROOT_DIR, 'icons.config.json');
|
| 20 |
+
const OUTPUT_PATH = join(ROOT_DIR, 'src/aspara/dashboard/templates/_icons.mustache');
|
| 21 |
+
const HEROICONS_PATH = join(ROOT_DIR, 'node_modules/heroicons/24');
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Parse SVG file and extract attributes and inner content.
|
| 25 |
+
* @param {string} svgContent - Raw SVG file content
|
| 26 |
+
* @returns {{ attrs: Object, innerContent: string }}
|
| 27 |
+
*/
|
| 28 |
+
function parseSvg(svgContent) {
|
| 29 |
+
// Extract attributes from the opening <svg> tag
|
| 30 |
+
const svgMatch = svgContent.match(/<svg([^>]*)>([\s\S]*)<\/svg>/);
|
| 31 |
+
if (!svgMatch) {
|
| 32 |
+
throw new Error('Invalid SVG format');
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const attrsString = svgMatch[1];
|
| 36 |
+
const innerContent = svgMatch[2].trim();
|
| 37 |
+
|
| 38 |
+
// Parse attributes
|
| 39 |
+
const attrs = {};
|
| 40 |
+
const attrRegex = /(\S+)=["']([^"']*)["']/g;
|
| 41 |
+
for (const match of attrsString.matchAll(attrRegex)) {
|
| 42 |
+
attrs[match[1]] = match[2];
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return { attrs, innerContent };
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Convert SVG to symbol element.
|
| 50 |
+
* @param {string} svgContent - Raw SVG file content
|
| 51 |
+
* @param {string} id - Symbol ID
|
| 52 |
+
* @returns {string} Symbol element string
|
| 53 |
+
*/
|
| 54 |
+
function svgToSymbol(svgContent, id) {
|
| 55 |
+
const { attrs, innerContent } = parseSvg(svgContent);
|
| 56 |
+
|
| 57 |
+
// Build symbol attributes (keep viewBox, fill, stroke, stroke-width)
|
| 58 |
+
const symbolAttrs = [`id="${id}"`];
|
| 59 |
+
|
| 60 |
+
if (attrs.viewBox) {
|
| 61 |
+
symbolAttrs.push(`viewBox="${attrs.viewBox}"`);
|
| 62 |
+
}
|
| 63 |
+
if (attrs.fill) {
|
| 64 |
+
symbolAttrs.push(`fill="${attrs.fill}"`);
|
| 65 |
+
}
|
| 66 |
+
if (attrs.stroke) {
|
| 67 |
+
symbolAttrs.push(`stroke="${attrs.stroke}"`);
|
| 68 |
+
}
|
| 69 |
+
if (attrs['stroke-width']) {
|
| 70 |
+
symbolAttrs.push(`stroke-width="${attrs['stroke-width']}"`);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return ` <symbol ${symbolAttrs.join(' ')}>\n ${innerContent}\n </symbol>`;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Main build function.
|
| 78 |
+
*/
|
| 79 |
+
function build() {
|
| 80 |
+
console.log('Building icon sprites...');
|
| 81 |
+
|
| 82 |
+
// Read config
|
| 83 |
+
const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
| 84 |
+
console.log(`Found ${config.icons.length} icons in config`);
|
| 85 |
+
|
| 86 |
+
const symbols = [];
|
| 87 |
+
|
| 88 |
+
for (const icon of config.icons) {
|
| 89 |
+
const svgPath = join(HEROICONS_PATH, icon.style, `${icon.name}.svg`);
|
| 90 |
+
console.log(` Processing: ${icon.name} (${icon.style}) -> #${icon.id}`);
|
| 91 |
+
|
| 92 |
+
try {
|
| 93 |
+
const svgContent = readFileSync(svgPath, 'utf-8');
|
| 94 |
+
const symbol = svgToSymbol(svgContent, icon.id);
|
| 95 |
+
symbols.push(symbol);
|
| 96 |
+
} catch (err) {
|
| 97 |
+
console.error(` Error reading ${svgPath}: ${err.message}`);
|
| 98 |
+
process.exit(1);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Generate output
|
| 103 |
+
const output = `{{!
|
| 104 |
+
Auto-generated icon sprites from heroicons.
|
| 105 |
+
Do not edit manually - run "pnpm build:icons" to regenerate.
|
| 106 |
+
|
| 107 |
+
Source: icons.config.json
|
| 108 |
+
}}
|
| 109 |
+
<svg style="display: none" aria-hidden="true">
|
| 110 |
+
${symbols.join('\n')}
|
| 111 |
+
</svg>
|
| 112 |
+
`;
|
| 113 |
+
|
| 114 |
+
writeFileSync(OUTPUT_PATH, output);
|
| 115 |
+
console.log(`\nGenerated: ${OUTPUT_PATH}`);
|
| 116 |
+
console.log('Done!');
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
build();
|
space_README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Aspara Demo
|
| 3 |
+
emoji: 🌱
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Aspara Demo
|
| 12 |
+
|
| 13 |
+
Aspara — a blazingly fast metrics tracker for machine learning experiments.
|
| 14 |
+
|
| 15 |
+
This Space runs a demo dashboard with pre-generated sample data.
|
| 16 |
+
Browse projects, compare runs, and explore metrics to see what Aspara can do.
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- LTTB-based metric downsampling for responsive charts
|
| 21 |
+
- Run comparison with overlay charts
|
| 22 |
+
- Tag and note editing
|
| 23 |
+
- Real-time updates via SSE
|
| 24 |
+
|
| 25 |
+
## Links
|
| 26 |
+
|
| 27 |
+
- [GitHub Repository](https://github.com/prednext/aspara)
|
src/aspara/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara - Simple metrics tracking system for machine learning experiments.
|
| 3 |
+
|
| 4 |
+
This module provides a wandb-compatible API for experiment tracking.
|
| 5 |
+
|
| 6 |
+
Examples:
|
| 7 |
+
>>> import aspara
|
| 8 |
+
>>> run = aspara.init(project="my_project", config={"lr": 0.01})
|
| 9 |
+
>>> aspara.log({"loss": 0.5, "accuracy": 0.95})
|
| 10 |
+
>>> aspara.finish()
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from aspara.run import Config, Run, Summary, finish, init, log
|
| 14 |
+
from aspara.run import get_current_run as _get_current_run
|
| 15 |
+
|
| 16 |
+
__version__ = "0.1.0"
|
| 17 |
+
__all__ = [
|
| 18 |
+
"Run",
|
| 19 |
+
"Config",
|
| 20 |
+
"Summary",
|
| 21 |
+
"init",
|
| 22 |
+
"log",
|
| 23 |
+
"finish",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Convenience function for accessing current run's config
|
| 28 |
+
def config() -> Config | None:
|
| 29 |
+
"""Get the config of the current run."""
|
| 30 |
+
run = _get_current_run()
|
| 31 |
+
return run.config if run else None
|
src/aspara/catalog/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara Catalog module
|
| 3 |
+
|
| 4 |
+
Provides ProjectCatalog and RunCatalog for discovering and managing
|
| 5 |
+
projects and runs in the data directory.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .project_catalog import ProjectCatalog, ProjectInfo
|
| 9 |
+
from .run_catalog import RunCatalog, RunInfo
|
| 10 |
+
from .watcher import DataDirWatcher
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"ProjectCatalog",
|
| 14 |
+
"RunCatalog",
|
| 15 |
+
"ProjectInfo",
|
| 16 |
+
"RunInfo",
|
| 17 |
+
"DataDirWatcher",
|
| 18 |
+
]
|
src/aspara/catalog/project_catalog.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ProjectCatalog - Catalog for discovering and managing projects.
|
| 3 |
+
|
| 4 |
+
This module provides functionality for listing, getting, and deleting projects
|
| 5 |
+
in the data directory.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import shutil
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
|
| 16 |
+
from aspara.exceptions import ProjectNotFoundError
|
| 17 |
+
from aspara.storage import ProjectMetadataStorage
|
| 18 |
+
from aspara.utils.validators import validate_name, validate_safe_path
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ProjectInfo(BaseModel):
|
| 24 |
+
"""Project information."""
|
| 25 |
+
|
| 26 |
+
name: str
|
| 27 |
+
run_count: int
|
| 28 |
+
last_update: datetime
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ProjectCatalog:
|
| 32 |
+
"""Catalog for discovering and managing projects.
|
| 33 |
+
|
| 34 |
+
This class provides methods to list, get, and delete projects
|
| 35 |
+
in the data directory. It does not handle metrics data directly;
|
| 36 |
+
that responsibility belongs to MetricsStorage.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(self, data_dir: str | Path) -> None:
|
| 40 |
+
"""Initialize the project catalog.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
data_dir: Base directory for data storage
|
| 44 |
+
"""
|
| 45 |
+
self.data_dir = Path(data_dir)
|
| 46 |
+
|
| 47 |
+
def get_projects(self) -> list[ProjectInfo]:
|
| 48 |
+
"""List all projects in the data directory.
|
| 49 |
+
|
| 50 |
+
Uses os.scandir() for efficient directory iteration with cached stat info.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
List of ProjectInfo objects sorted by name
|
| 54 |
+
"""
|
| 55 |
+
import os
|
| 56 |
+
|
| 57 |
+
projects: list[ProjectInfo] = []
|
| 58 |
+
if not self.data_dir.exists():
|
| 59 |
+
return projects
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
# Use scandir for efficient iteration with cached stat info
|
| 63 |
+
with os.scandir(self.data_dir) as project_entries:
|
| 64 |
+
for project_entry in project_entries:
|
| 65 |
+
if not project_entry.is_dir():
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
# Collect run files with stat info in single pass
|
| 69 |
+
run_files_mtime: list[float] = []
|
| 70 |
+
with os.scandir(project_entry.path) as file_entries:
|
| 71 |
+
for file_entry in file_entries:
|
| 72 |
+
if (
|
| 73 |
+
file_entry.name.endswith(".jsonl")
|
| 74 |
+
and not file_entry.name.endswith(".wal.jsonl")
|
| 75 |
+
and not file_entry.name.endswith(".meta.jsonl")
|
| 76 |
+
):
|
| 77 |
+
# stat() result is cached by scandir
|
| 78 |
+
run_files_mtime.append(file_entry.stat().st_mtime)
|
| 79 |
+
|
| 80 |
+
run_count = len(run_files_mtime)
|
| 81 |
+
|
| 82 |
+
# Find last update time - use cached stat from scandir
|
| 83 |
+
if run_files_mtime:
|
| 84 |
+
last_update = datetime.fromtimestamp(max(run_files_mtime))
|
| 85 |
+
else:
|
| 86 |
+
last_update = datetime.fromtimestamp(project_entry.stat().st_mtime)
|
| 87 |
+
|
| 88 |
+
projects.append(
|
| 89 |
+
ProjectInfo(
|
| 90 |
+
name=project_entry.name,
|
| 91 |
+
run_count=run_count,
|
| 92 |
+
last_update=last_update,
|
| 93 |
+
)
|
| 94 |
+
)
|
| 95 |
+
except (OSError, PermissionError):
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
return sorted(projects, key=lambda p: p.name)
|
| 99 |
+
|
| 100 |
+
def get(self, name: str) -> ProjectInfo:
|
| 101 |
+
"""Get a specific project by name.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
name: Project name
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
ProjectInfo object
|
| 108 |
+
|
| 109 |
+
Raises:
|
| 110 |
+
ValueError: If project name is invalid
|
| 111 |
+
ProjectNotFoundError: If project does not exist
|
| 112 |
+
"""
|
| 113 |
+
validate_name(name, "project name")
|
| 114 |
+
|
| 115 |
+
project_dir = self.data_dir / name
|
| 116 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 117 |
+
|
| 118 |
+
if not project_dir.exists() or not project_dir.is_dir():
|
| 119 |
+
raise ProjectNotFoundError(f"Project '{name}' not found")
|
| 120 |
+
|
| 121 |
+
# Count runs
|
| 122 |
+
run_files = [f for f in project_dir.iterdir() if f.suffix in [".jsonl", ".db", ".wal"]]
|
| 123 |
+
run_count = len(run_files)
|
| 124 |
+
|
| 125 |
+
# Get last update time from run files
|
| 126 |
+
last_update = datetime.fromtimestamp(project_dir.stat().st_mtime)
|
| 127 |
+
if run_files:
|
| 128 |
+
last_update = max(datetime.fromtimestamp(f.stat().st_mtime) for f in run_files)
|
| 129 |
+
|
| 130 |
+
return ProjectInfo(
|
| 131 |
+
name=name,
|
| 132 |
+
run_count=run_count,
|
| 133 |
+
last_update=last_update,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
def exists(self, name: str) -> bool:
|
| 137 |
+
"""Check if a project exists.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
name: Project name
|
| 141 |
+
|
| 142 |
+
Returns:
|
| 143 |
+
True if project exists, False otherwise
|
| 144 |
+
"""
|
| 145 |
+
try:
|
| 146 |
+
validate_name(name, "project name")
|
| 147 |
+
project_dir = self.data_dir / name
|
| 148 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 149 |
+
return project_dir.exists() and project_dir.is_dir()
|
| 150 |
+
except ValueError:
|
| 151 |
+
return False
|
| 152 |
+
|
| 153 |
+
def delete(self, name: str) -> None:
|
| 154 |
+
"""Delete a project and all its runs.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
name: Project name to delete
|
| 158 |
+
|
| 159 |
+
Raises:
|
| 160 |
+
ValueError: If project name is empty or invalid
|
| 161 |
+
ProjectNotFoundError: If project does not exist
|
| 162 |
+
PermissionError: If deletion is not permitted
|
| 163 |
+
"""
|
| 164 |
+
if not name:
|
| 165 |
+
raise ValueError("Project name cannot be empty")
|
| 166 |
+
|
| 167 |
+
validate_name(name, "project name")
|
| 168 |
+
|
| 169 |
+
project_dir = self.data_dir / name
|
| 170 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 171 |
+
|
| 172 |
+
if not project_dir.exists():
|
| 173 |
+
raise ProjectNotFoundError(f"Project '{name}' does not exist")
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
shutil.rmtree(project_dir)
|
| 177 |
+
logger.info(f"Successfully deleted project: {name}")
|
| 178 |
+
except (PermissionError, OSError) as e:
|
| 179 |
+
logger.error(f"Error deleting project {name}: {type(e).__name__}")
|
| 180 |
+
raise
|
| 181 |
+
|
| 182 |
+
def get_metadata(self, name: str) -> dict[str, Any]:
|
| 183 |
+
"""Get project-level metadata.json for a project.
|
| 184 |
+
|
| 185 |
+
Returns a dictionary with notes, tags, created_at, updated_at fields.
|
| 186 |
+
"""
|
| 187 |
+
storage = ProjectMetadataStorage(self.data_dir, name)
|
| 188 |
+
return storage.get_metadata()
|
| 189 |
+
|
| 190 |
+
def update_metadata(self, name: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
| 191 |
+
"""Update project-level metadata.json for a project.
|
| 192 |
+
|
| 193 |
+
The metadata dict may contain partial fields (notes, tags).
|
| 194 |
+
Validation and timestamp handling is delegated to ProjectMetadataStorage.
|
| 195 |
+
"""
|
| 196 |
+
storage = ProjectMetadataStorage(self.data_dir, name)
|
| 197 |
+
return storage.update_metadata(metadata)
|
| 198 |
+
|
| 199 |
+
def delete_metadata(self, name: str) -> bool:
|
| 200 |
+
"""Delete project-level metadata.json for a project."""
|
| 201 |
+
storage = ProjectMetadataStorage(self.data_dir, name)
|
| 202 |
+
return storage.delete_metadata()
|
src/aspara/catalog/run_catalog.py
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RunCatalog - Catalog for discovering and managing runs within a project.
|
| 3 |
+
|
| 4 |
+
This module provides functionality for listing, getting, and deleting runs
|
| 5 |
+
in a project directory.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import contextlib
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import shutil
|
| 15 |
+
from collections.abc import AsyncGenerator, Mapping
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import polars as pl
|
| 21 |
+
from pydantic import BaseModel, Field
|
| 22 |
+
|
| 23 |
+
from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
|
| 24 |
+
from aspara.models import MetricRecord, RunStatus, StatusRecord
|
| 25 |
+
from aspara.storage import RunMetadataStorage
|
| 26 |
+
from aspara.utils.timestamp import parse_to_datetime
|
| 27 |
+
from aspara.utils.validators import validate_name, validate_safe_path
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
# Threshold in seconds to consider a run as potentially failed (1 hour)
|
| 32 |
+
STALE_RUN_THRESHOLD_SECONDS = 3600
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class RunInfo(BaseModel):
|
| 36 |
+
"""Run information."""
|
| 37 |
+
|
| 38 |
+
name: str
|
| 39 |
+
run_id: str | None = None
|
| 40 |
+
start_time: datetime | None = None
|
| 41 |
+
last_update: datetime | None = None
|
| 42 |
+
param_count: int
|
| 43 |
+
artifact_count: int = 0
|
| 44 |
+
tags: list[str] = []
|
| 45 |
+
is_corrupted: bool = False
|
| 46 |
+
error_message: str | None = None
|
| 47 |
+
is_finished: bool = False
|
| 48 |
+
exit_code: int | None = None
|
| 49 |
+
status: RunStatus = Field(default=RunStatus.WIP)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _detect_backend(data_dir: Path, project: str, run_name: str) -> str:
|
| 53 |
+
"""Detect which storage backend a run is using.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
data_dir: Base data directory
|
| 57 |
+
project: Project name
|
| 58 |
+
run_name: Run name
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
"polars" if the run uses Polars backend (WAL + Parquet), "jsonl" otherwise
|
| 62 |
+
"""
|
| 63 |
+
project_dir = data_dir / project
|
| 64 |
+
|
| 65 |
+
# Check for Polars backend indicators
|
| 66 |
+
wal_file = project_dir / f"{run_name}.wal.jsonl"
|
| 67 |
+
archive_dir = project_dir / f"{run_name}_archive"
|
| 68 |
+
|
| 69 |
+
# If WAL file or archive directory exists, it's a Polars backend
|
| 70 |
+
if wal_file.exists() or archive_dir.exists():
|
| 71 |
+
return "polars"
|
| 72 |
+
|
| 73 |
+
# Otherwise, it's JSONL backend
|
| 74 |
+
return "jsonl"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _open_metrics_storage(
|
| 78 |
+
base_dir: Path | str,
|
| 79 |
+
project: str,
|
| 80 |
+
run_name: str,
|
| 81 |
+
):
|
| 82 |
+
"""Open metrics storage for an existing run.
|
| 83 |
+
|
| 84 |
+
Detects the backend type from existing files and returns
|
| 85 |
+
the appropriate storage instance.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
base_dir: Base data directory
|
| 89 |
+
project: Project name
|
| 90 |
+
run_name: Run name
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
JsonlMetricsStorage or PolarsMetricsStorage instance
|
| 94 |
+
"""
|
| 95 |
+
from aspara.storage import JsonlMetricsStorage, PolarsMetricsStorage
|
| 96 |
+
|
| 97 |
+
backend = _detect_backend(Path(base_dir), project, run_name)
|
| 98 |
+
|
| 99 |
+
if backend == "polars":
|
| 100 |
+
return PolarsMetricsStorage(
|
| 101 |
+
base_dir=str(base_dir),
|
| 102 |
+
project_name=project,
|
| 103 |
+
run_name=run_name,
|
| 104 |
+
)
|
| 105 |
+
else:
|
| 106 |
+
return JsonlMetricsStorage(
|
| 107 |
+
base_dir=str(base_dir),
|
| 108 |
+
project_name=project,
|
| 109 |
+
run_name=run_name,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _read_metadata_file(metadata_file: Path) -> dict:
|
| 114 |
+
"""Read .meta.json file and return parsed data.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
metadata_file: Path to the .meta.json file
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Dictionary with metadata, or empty dict if file doesn't exist or is invalid
|
| 121 |
+
"""
|
| 122 |
+
if not metadata_file.exists():
|
| 123 |
+
return {}
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
with open(metadata_file) as f:
|
| 127 |
+
return json.load(f)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.warning(f"Error reading metadata file {metadata_file}: {e}")
|
| 130 |
+
return {}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _infer_stale_status(
|
| 134 |
+
status: RunStatus,
|
| 135 |
+
start_time: datetime | None,
|
| 136 |
+
is_finished: bool,
|
| 137 |
+
) -> RunStatus:
|
| 138 |
+
"""Infer MAYBE_FAILED status for old runs that were never finished.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
status: Current run status
|
| 142 |
+
start_time: When the run started
|
| 143 |
+
is_finished: Whether the run has finished
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
MAYBE_FAILED if run is stale, otherwise the original status
|
| 147 |
+
"""
|
| 148 |
+
if status != RunStatus.WIP or not start_time or is_finished:
|
| 149 |
+
return status
|
| 150 |
+
|
| 151 |
+
current_time = datetime.now(timezone.utc)
|
| 152 |
+
age_seconds = (current_time - start_time).total_seconds()
|
| 153 |
+
if age_seconds > STALE_RUN_THRESHOLD_SECONDS:
|
| 154 |
+
return RunStatus.MAYBE_FAILED
|
| 155 |
+
|
| 156 |
+
return status
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _extract_timestamp_range(
|
| 160 |
+
df: pl.DataFrame,
|
| 161 |
+
) -> tuple[datetime | None, datetime | None]:
|
| 162 |
+
"""Extract start_time and last_update from DataFrame.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
df: DataFrame with timestamp column
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
Tuple of (start_time, last_update)
|
| 169 |
+
"""
|
| 170 |
+
if len(df) == 0 or "timestamp" not in df.columns:
|
| 171 |
+
return (None, None)
|
| 172 |
+
|
| 173 |
+
timestamps = df.select("timestamp").to_series()
|
| 174 |
+
if len(timestamps) == 0:
|
| 175 |
+
return (None, None)
|
| 176 |
+
|
| 177 |
+
ts_min = timestamps.min()
|
| 178 |
+
ts_max = timestamps.max()
|
| 179 |
+
|
| 180 |
+
start_time = ts_min if isinstance(ts_min, datetime) else None
|
| 181 |
+
last_update = ts_max if isinstance(ts_max, datetime) else None
|
| 182 |
+
|
| 183 |
+
return (start_time, last_update)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _check_corruption(
|
| 187 |
+
df: pl.DataFrame,
|
| 188 |
+
metadata_file_exists: bool,
|
| 189 |
+
) -> tuple[bool, str | None]:
|
| 190 |
+
"""Check if metrics data is corrupted.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
df: DataFrame with metrics
|
| 194 |
+
metadata_file_exists: Whether metadata file exists
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
Tuple of (is_corrupted, error_message)
|
| 198 |
+
"""
|
| 199 |
+
if len(df) == 0 and not metadata_file_exists:
|
| 200 |
+
return (True, "Empty file! No data found!")
|
| 201 |
+
if len(df) > 0 and "timestamp" not in df.columns:
|
| 202 |
+
return (True, "No timestamps found! Corrupted Run!")
|
| 203 |
+
return (False, None)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _map_error_to_corruption(
|
| 207 |
+
error: Exception,
|
| 208 |
+
metadata_file_exists: bool,
|
| 209 |
+
) -> tuple[bool, str | None]:
|
| 210 |
+
"""Map storage read errors to corruption status.
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
error: The exception that occurred
|
| 214 |
+
metadata_file_exists: Whether metadata file exists
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
Tuple of (is_corrupted, error_message)
|
| 218 |
+
"""
|
| 219 |
+
error_str = str(error).lower()
|
| 220 |
+
|
| 221 |
+
if "empty" in error_str or "empty string" in error_str:
|
| 222 |
+
return (True, "Empty file! No data found!")
|
| 223 |
+
if "expectedobjectkey" in error_str.replace(" ", "") or "invalid json" in error_str:
|
| 224 |
+
return (True, f"Invalid file format! Error: {error!s}")
|
| 225 |
+
if "timestamp" in error_str:
|
| 226 |
+
return (True, f"No timestamps found! Error: {error!s}")
|
| 227 |
+
if "step" in error_str and not metadata_file_exists:
|
| 228 |
+
return (True, f"Failed to read metrics: {error!s}")
|
| 229 |
+
if not metadata_file_exists:
|
| 230 |
+
return (True, f"Failed to read metrics: {error!s}")
|
| 231 |
+
|
| 232 |
+
return (False, None)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class RunCatalog:
|
| 236 |
+
"""Catalog for discovering and managing runs within a project.
|
| 237 |
+
|
| 238 |
+
This class provides methods to list, get, delete, and watch runs.
|
| 239 |
+
It handles both JSONL and DuckDB storage formats.
|
| 240 |
+
"""
|
| 241 |
+
|
| 242 |
+
def __init__(self, data_dir: str | Path) -> None:
|
| 243 |
+
"""Initialize the run catalog.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
data_dir: Base directory for data storage
|
| 247 |
+
"""
|
| 248 |
+
self.data_dir = Path(data_dir)
|
| 249 |
+
|
| 250 |
+
def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
|
| 251 |
+
"""Parse file path to extract project, run name, and file type.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
file_path: Absolute path to a file (e.g., data/project/run.jsonl)
|
| 255 |
+
|
| 256 |
+
Returns:
|
| 257 |
+
(project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
|
| 258 |
+
None if path doesn't match expected pattern
|
| 259 |
+
"""
|
| 260 |
+
try:
|
| 261 |
+
relative = file_path.relative_to(self.data_dir)
|
| 262 |
+
except ValueError:
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
parts = relative.parts
|
| 266 |
+
if len(parts) != 2:
|
| 267 |
+
return None
|
| 268 |
+
|
| 269 |
+
project = parts[0]
|
| 270 |
+
filename = parts[1]
|
| 271 |
+
|
| 272 |
+
if filename.endswith(".wal.jsonl"):
|
| 273 |
+
return (project, filename[:-10], "wal")
|
| 274 |
+
elif filename.endswith(".meta.json"):
|
| 275 |
+
return (project, filename[:-10], "meta")
|
| 276 |
+
elif filename.endswith(".jsonl"):
|
| 277 |
+
return (project, filename[:-6], "metrics")
|
| 278 |
+
|
| 279 |
+
return None
|
| 280 |
+
|
| 281 |
+
def _read_run_info(self, project: str, run_name: str, run_file: Path) -> RunInfo:
|
| 282 |
+
"""Read run information from JSONL metrics file and metadata file.
|
| 283 |
+
|
| 284 |
+
Supports both JSONL and Polars backends.
|
| 285 |
+
Optimization: Avoids loading full DataFrame when metadata provides sufficient info.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
project: Project name
|
| 289 |
+
run_name: Run name
|
| 290 |
+
run_file: Path to the JSONL metrics file
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
RunInfo object with metadata from both files
|
| 294 |
+
"""
|
| 295 |
+
metadata_file = run_file.parent / f"{run_name}.meta.json"
|
| 296 |
+
|
| 297 |
+
# Read metadata
|
| 298 |
+
metadata = _read_metadata_file(metadata_file)
|
| 299 |
+
run_id = metadata.get("run_id")
|
| 300 |
+
tags = metadata.get("tags", [])
|
| 301 |
+
is_finished = metadata.get("is_finished", False)
|
| 302 |
+
exit_code = metadata.get("exit_code")
|
| 303 |
+
|
| 304 |
+
# Read params count
|
| 305 |
+
params = metadata.get("params", {})
|
| 306 |
+
params_count = len(params) if isinstance(params, dict) else 0
|
| 307 |
+
|
| 308 |
+
# Parse status
|
| 309 |
+
status_value = metadata.get("status", RunStatus.WIP.value)
|
| 310 |
+
try:
|
| 311 |
+
status = RunStatus(status_value)
|
| 312 |
+
except ValueError:
|
| 313 |
+
status = RunStatus.from_is_finished_and_exit_code(is_finished, exit_code)
|
| 314 |
+
|
| 315 |
+
# Parse start_time from metadata
|
| 316 |
+
start_time = None
|
| 317 |
+
start_time_value = metadata.get("start_time")
|
| 318 |
+
if start_time_value is not None:
|
| 319 |
+
with contextlib.suppress(ValueError):
|
| 320 |
+
start_time = parse_to_datetime(start_time_value)
|
| 321 |
+
|
| 322 |
+
# Infer stale status
|
| 323 |
+
status = _infer_stale_status(status, start_time, is_finished)
|
| 324 |
+
|
| 325 |
+
# Lightweight corruption check: file exists and is not empty
|
| 326 |
+
is_corrupted = False
|
| 327 |
+
error_message = None
|
| 328 |
+
last_update = None
|
| 329 |
+
|
| 330 |
+
# Use file modification time as last_update
|
| 331 |
+
if run_file.exists():
|
| 332 |
+
last_update = datetime.fromtimestamp(run_file.stat().st_mtime)
|
| 333 |
+
|
| 334 |
+
if not run_file.exists() and not metadata_file.exists():
|
| 335 |
+
is_corrupted = True
|
| 336 |
+
error_message = "Run file not found"
|
| 337 |
+
elif run_file.exists() and run_file.stat().st_size == 0 and not metadata_file.exists():
|
| 338 |
+
is_corrupted = True
|
| 339 |
+
error_message = "Empty file! No data found!"
|
| 340 |
+
|
| 341 |
+
return RunInfo(
|
| 342 |
+
name=run_name,
|
| 343 |
+
run_id=run_id,
|
| 344 |
+
start_time=start_time,
|
| 345 |
+
last_update=last_update,
|
| 346 |
+
param_count=params_count,
|
| 347 |
+
artifact_count=0,
|
| 348 |
+
tags=tags,
|
| 349 |
+
is_corrupted=is_corrupted,
|
| 350 |
+
error_message=error_message,
|
| 351 |
+
is_finished=is_finished,
|
| 352 |
+
exit_code=exit_code,
|
| 353 |
+
status=status,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
def get_runs(self, project: str) -> list[RunInfo]:
|
| 357 |
+
"""List all runs in a project.
|
| 358 |
+
|
| 359 |
+
Args:
|
| 360 |
+
project: Project name
|
| 361 |
+
|
| 362 |
+
Returns:
|
| 363 |
+
List of RunInfo objects sorted by name
|
| 364 |
+
|
| 365 |
+
Raises:
|
| 366 |
+
ValueError: If project name is invalid
|
| 367 |
+
ProjectNotFoundError: If project does not exist
|
| 368 |
+
"""
|
| 369 |
+
validate_name(project, "project name")
|
| 370 |
+
|
| 371 |
+
project_dir = self.data_dir / project
|
| 372 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 373 |
+
|
| 374 |
+
if not project_dir.exists():
|
| 375 |
+
raise ProjectNotFoundError(f"Project '{project}' not found")
|
| 376 |
+
|
| 377 |
+
runs = []
|
| 378 |
+
seen_run_names: set[str] = set()
|
| 379 |
+
|
| 380 |
+
# Process .jsonl files (including .wal.jsonl for Polars backend)
|
| 381 |
+
for run_file in list(project_dir.glob("*.jsonl")):
|
| 382 |
+
# Determine run name from file
|
| 383 |
+
if run_file.name.endswith(".wal.jsonl"):
|
| 384 |
+
# Skip WAL files - they're handled by metadata
|
| 385 |
+
continue
|
| 386 |
+
else:
|
| 387 |
+
run_name = run_file.stem
|
| 388 |
+
|
| 389 |
+
# Skip if we've already processed this run
|
| 390 |
+
if run_name in seen_run_names:
|
| 391 |
+
continue
|
| 392 |
+
seen_run_names.add(run_name)
|
| 393 |
+
|
| 394 |
+
# Handle plain JSONL files
|
| 395 |
+
run = self._read_run_info(project, run_name, run_file)
|
| 396 |
+
runs.append(run)
|
| 397 |
+
|
| 398 |
+
return sorted(runs, key=lambda r: r.name)
|
| 399 |
+
|
| 400 |
+
def get(self, project: str, run: str) -> RunInfo:
|
| 401 |
+
"""Get a specific run.
|
| 402 |
+
|
| 403 |
+
Args:
|
| 404 |
+
project: Project name
|
| 405 |
+
run: Run name
|
| 406 |
+
|
| 407 |
+
Returns:
|
| 408 |
+
RunInfo object
|
| 409 |
+
|
| 410 |
+
Raises:
|
| 411 |
+
ValueError: If project or run name is invalid
|
| 412 |
+
ProjectNotFoundError: If project does not exist
|
| 413 |
+
RunNotFoundError: If run does not exist
|
| 414 |
+
"""
|
| 415 |
+
validate_name(project, "project name")
|
| 416 |
+
validate_name(run, "run name")
|
| 417 |
+
|
| 418 |
+
project_dir = self.data_dir / project
|
| 419 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 420 |
+
|
| 421 |
+
if not project_dir.exists():
|
| 422 |
+
raise ProjectNotFoundError(f"Project '{project}' not found")
|
| 423 |
+
|
| 424 |
+
# Check for JSONL file
|
| 425 |
+
jsonl_file = project_dir / f"{run}.jsonl"
|
| 426 |
+
|
| 427 |
+
if jsonl_file.exists():
|
| 428 |
+
return self._read_run_info(project, run, jsonl_file)
|
| 429 |
+
else:
|
| 430 |
+
raise RunNotFoundError(f"Run '{run}' not found in project '{project}'")
|
| 431 |
+
|
| 432 |
+
def delete(self, project: str, run: str) -> None:
|
| 433 |
+
"""Delete a run and its artifacts.
|
| 434 |
+
|
| 435 |
+
Args:
|
| 436 |
+
project: Project name
|
| 437 |
+
run: Run name to delete
|
| 438 |
+
|
| 439 |
+
Raises:
|
| 440 |
+
ValueError: If project or run name is empty or invalid
|
| 441 |
+
ProjectNotFoundError: If project does not exist
|
| 442 |
+
RunNotFoundError: If run does not exist
|
| 443 |
+
PermissionError: If deletion is not permitted
|
| 444 |
+
"""
|
| 445 |
+
if not project:
|
| 446 |
+
raise ValueError("Project name cannot be empty")
|
| 447 |
+
if not run:
|
| 448 |
+
raise ValueError("Run name cannot be empty")
|
| 449 |
+
|
| 450 |
+
validate_name(project, "project name")
|
| 451 |
+
validate_name(run, "run name")
|
| 452 |
+
|
| 453 |
+
project_dir = self.data_dir / project
|
| 454 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 455 |
+
|
| 456 |
+
if not project_dir.exists():
|
| 457 |
+
raise ProjectNotFoundError(f"Project '{project}' does not exist")
|
| 458 |
+
|
| 459 |
+
# Check for any run files
|
| 460 |
+
wal_file = project_dir / f"{run}.wal.jsonl"
|
| 461 |
+
jsonl_file = project_dir / f"{run}.jsonl"
|
| 462 |
+
|
| 463 |
+
if not wal_file.exists() and not jsonl_file.exists():
|
| 464 |
+
raise RunNotFoundError(f"Run '{run}' does not exist in project '{project}'")
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
# Delete all run-related files
|
| 468 |
+
metadata_file = project_dir / f"{run}.meta.json"
|
| 469 |
+
for file_path in [wal_file, jsonl_file, metadata_file]:
|
| 470 |
+
if file_path.exists():
|
| 471 |
+
file_path.unlink()
|
| 472 |
+
logger.debug(f"Deleted file: {file_path}")
|
| 473 |
+
|
| 474 |
+
# Delete artifacts directory if it exists
|
| 475 |
+
artifacts_dir = project_dir / run / "artifacts"
|
| 476 |
+
run_dir = project_dir / run
|
| 477 |
+
|
| 478 |
+
if artifacts_dir.exists():
|
| 479 |
+
shutil.rmtree(artifacts_dir)
|
| 480 |
+
logger.debug(f"Deleted artifacts for {project}/{run}")
|
| 481 |
+
|
| 482 |
+
# Delete run directory if it exists and is empty
|
| 483 |
+
if run_dir.exists():
|
| 484 |
+
try:
|
| 485 |
+
run_dir.rmdir()
|
| 486 |
+
logger.debug(f"Deleted run directory for {project}/{run}")
|
| 487 |
+
except OSError:
|
| 488 |
+
pass
|
| 489 |
+
|
| 490 |
+
logger.info(f"Successfully deleted run: {project}/{run}")
|
| 491 |
+
except (PermissionError, OSError) as e:
|
| 492 |
+
logger.error(f"Error deleting run {project}/{run}: {type(e).__name__}")
|
| 493 |
+
raise
|
| 494 |
+
|
| 495 |
+
def exists(self, project: str, run: str) -> bool:
|
| 496 |
+
"""Check if a run exists.
|
| 497 |
+
|
| 498 |
+
Args:
|
| 499 |
+
project: Project name
|
| 500 |
+
run: Run name
|
| 501 |
+
|
| 502 |
+
Returns:
|
| 503 |
+
True if run exists, False otherwise
|
| 504 |
+
"""
|
| 505 |
+
try:
|
| 506 |
+
validate_name(project, "project name")
|
| 507 |
+
validate_name(run, "run name")
|
| 508 |
+
|
| 509 |
+
project_dir = self.data_dir / project
|
| 510 |
+
validate_safe_path(project_dir, self.data_dir)
|
| 511 |
+
|
| 512 |
+
wal_file = project_dir / f"{run}.wal.jsonl"
|
| 513 |
+
jsonl_file = project_dir / f"{run}.jsonl"
|
| 514 |
+
|
| 515 |
+
return wal_file.exists() or jsonl_file.exists()
|
| 516 |
+
except ValueError:
|
| 517 |
+
return False
|
| 518 |
+
|
| 519 |
+
async def subscribe(
|
| 520 |
+
self,
|
| 521 |
+
targets: Mapping[str, list[str] | None],
|
| 522 |
+
since: datetime,
|
| 523 |
+
) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
|
| 524 |
+
"""Subscribe to file changes for specified targets using DataDirWatcher.
|
| 525 |
+
|
| 526 |
+
This method uses a singleton DataDirWatcher instance to minimize inotify
|
| 527 |
+
file descriptor usage. Multiple SSE connections share the same watcher.
|
| 528 |
+
|
| 529 |
+
Args:
|
| 530 |
+
targets: Dictionary mapping project names to list of run names.
|
| 531 |
+
If run list is None, all runs in the project are watched.
|
| 532 |
+
since: Filter to only yield records with timestamp >= since
|
| 533 |
+
|
| 534 |
+
Yields:
|
| 535 |
+
MetricRecord or StatusRecord as files are updated
|
| 536 |
+
"""
|
| 537 |
+
from aspara.catalog.watcher import DataDirWatcher
|
| 538 |
+
|
| 539 |
+
watcher = await DataDirWatcher.get_instance(self.data_dir)
|
| 540 |
+
async for record in watcher.subscribe(targets, since):
|
| 541 |
+
yield record
|
| 542 |
+
|
| 543 |
+
def get_artifacts(self, project: str, run: str) -> list[dict]:
|
| 544 |
+
"""Get artifacts for a run from metadata file.
|
| 545 |
+
|
| 546 |
+
Args:
|
| 547 |
+
project: Project name
|
| 548 |
+
run: Run name
|
| 549 |
+
|
| 550 |
+
Returns:
|
| 551 |
+
List of artifact dictionaries
|
| 552 |
+
"""
|
| 553 |
+
validate_name(project, "project name")
|
| 554 |
+
validate_name(run, "run name")
|
| 555 |
+
|
| 556 |
+
# Read from metadata file
|
| 557 |
+
metadata_file = self.data_dir / project / f"{run}.meta.json"
|
| 558 |
+
validate_safe_path(metadata_file, self.data_dir)
|
| 559 |
+
|
| 560 |
+
if metadata_file.exists():
|
| 561 |
+
try:
|
| 562 |
+
with open(metadata_file) as f:
|
| 563 |
+
metadata = json.load(f)
|
| 564 |
+
return metadata.get("artifacts", [])
|
| 565 |
+
except Exception as e:
|
| 566 |
+
logger.warning(f"Error reading artifacts from metadata file for {run}: {e}")
|
| 567 |
+
|
| 568 |
+
return []
|
| 569 |
+
|
| 570 |
+
def get_metadata(self, project: str, run: str) -> dict:
|
| 571 |
+
"""Get run metadata from .meta.json file.
|
| 572 |
+
|
| 573 |
+
Args:
|
| 574 |
+
project: Project name
|
| 575 |
+
run: Run name
|
| 576 |
+
|
| 577 |
+
Returns:
|
| 578 |
+
Dictionary containing run metadata
|
| 579 |
+
"""
|
| 580 |
+
storage = RunMetadataStorage(self.data_dir, project, run)
|
| 581 |
+
return storage.get_metadata()
|
| 582 |
+
|
| 583 |
+
def update_metadata(self, project: str, run: str, metadata: dict) -> dict:
|
| 584 |
+
"""Update run metadata in .meta.json file.
|
| 585 |
+
|
| 586 |
+
Args:
|
| 587 |
+
project: Project name
|
| 588 |
+
run: Run name
|
| 589 |
+
metadata: Dictionary with fields to update (notes, tags)
|
| 590 |
+
|
| 591 |
+
Returns:
|
| 592 |
+
Updated complete metadata dictionary
|
| 593 |
+
"""
|
| 594 |
+
storage = RunMetadataStorage(self.data_dir, project, run)
|
| 595 |
+
return storage.update_metadata(metadata)
|
| 596 |
+
|
| 597 |
+
def delete_metadata(self, project: str, run: str) -> bool:
|
| 598 |
+
"""Delete run metadata file.
|
| 599 |
+
|
| 600 |
+
Args:
|
| 601 |
+
project: Project name
|
| 602 |
+
run: Run name
|
| 603 |
+
|
| 604 |
+
Returns:
|
| 605 |
+
True if file was deleted, False if it didn't exist
|
| 606 |
+
"""
|
| 607 |
+
storage = RunMetadataStorage(self.data_dir, project, run)
|
| 608 |
+
return storage.delete_metadata()
|
| 609 |
+
|
| 610 |
+
def _guess_artifact_category(self, filename: str) -> str:
|
| 611 |
+
"""Guess artifact category from file extension.
|
| 612 |
+
|
| 613 |
+
Args:
|
| 614 |
+
filename: Name of the artifact file
|
| 615 |
+
|
| 616 |
+
Returns:
|
| 617 |
+
Category string
|
| 618 |
+
"""
|
| 619 |
+
ext = filename.lower().split(".")[-1] if "." in filename else ""
|
| 620 |
+
|
| 621 |
+
if ext in ["py", "js", "ts", "jsx", "tsx", "cpp", "c", "h", "java", "go", "rs", "rb", "php"]:
|
| 622 |
+
return "code"
|
| 623 |
+
if ext in ["yaml", "yml", "json", "toml", "ini", "cfg", "conf", "env"]:
|
| 624 |
+
return "config"
|
| 625 |
+
if ext in ["pt", "pth", "pkl", "pickle", "h5", "hdf5", "onnx", "pb", "tflite", "joblib"]:
|
| 626 |
+
return "model"
|
| 627 |
+
if ext in ["csv", "tsv", "parquet", "feather", "xlsx", "xls", "hdf", "npy", "npz"]:
|
| 628 |
+
return "data"
|
| 629 |
+
|
| 630 |
+
return "other"
|
| 631 |
+
|
| 632 |
+
def load_metrics(
|
| 633 |
+
self,
|
| 634 |
+
project: str,
|
| 635 |
+
run: str,
|
| 636 |
+
start_time: datetime | None = None,
|
| 637 |
+
) -> pl.DataFrame:
|
| 638 |
+
"""Load metrics for a run in wide format (auto-detects storage backend).
|
| 639 |
+
|
| 640 |
+
Args:
|
| 641 |
+
project: Project name
|
| 642 |
+
run: Run name
|
| 643 |
+
start_time: Optional start time to filter metrics from
|
| 644 |
+
|
| 645 |
+
Returns:
|
| 646 |
+
Polars DataFrame in wide format with columns:
|
| 647 |
+
- timestamp: Datetime
|
| 648 |
+
- step: Int64
|
| 649 |
+
- _<metric_name>: Float64 for each metric (underscore-prefixed)
|
| 650 |
+
|
| 651 |
+
Raises:
|
| 652 |
+
ValueError: If project or run name is invalid
|
| 653 |
+
RunNotFoundError: If run does not exist
|
| 654 |
+
"""
|
| 655 |
+
validate_name(project, "project name")
|
| 656 |
+
validate_name(run, "run name")
|
| 657 |
+
|
| 658 |
+
# Create storage using factory function and load metrics
|
| 659 |
+
storage = _open_metrics_storage(self.data_dir, project, run)
|
| 660 |
+
|
| 661 |
+
try:
|
| 662 |
+
df = storage.load()
|
| 663 |
+
except Exception as e:
|
| 664 |
+
logger.warning(f"Failed to load metrics for {project}/{run}: {e}")
|
| 665 |
+
return pl.DataFrame(
|
| 666 |
+
schema={
|
| 667 |
+
"timestamp": pl.Datetime,
|
| 668 |
+
"step": pl.Int64,
|
| 669 |
+
}
|
| 670 |
+
)
|
| 671 |
+
|
| 672 |
+
# Apply start_time filter if specified
|
| 673 |
+
if start_time is not None and len(df) > 0:
|
| 674 |
+
df = df.filter(pl.col("timestamp") >= start_time)
|
| 675 |
+
|
| 676 |
+
return df
|
| 677 |
+
|
| 678 |
+
def get_run_config(self, project: str, run: str) -> dict[str, Any]:
|
| 679 |
+
"""Get run config from .meta.json file.
|
| 680 |
+
|
| 681 |
+
This reads the .meta.json file which contains params, config, status, etc.
|
| 682 |
+
Different from get_metadata which uses ProjectMetadataStorage for notes/tags.
|
| 683 |
+
|
| 684 |
+
Args:
|
| 685 |
+
project: Project name
|
| 686 |
+
run: Run name
|
| 687 |
+
|
| 688 |
+
Returns:
|
| 689 |
+
Dictionary containing run config (params, config, status, etc.)
|
| 690 |
+
"""
|
| 691 |
+
validate_name(project, "project name")
|
| 692 |
+
validate_name(run, "run name")
|
| 693 |
+
|
| 694 |
+
metadata_file = self.data_dir / project / f"{run}.meta.json"
|
| 695 |
+
validate_safe_path(metadata_file, self.data_dir)
|
| 696 |
+
|
| 697 |
+
return _read_metadata_file(metadata_file)
|
| 698 |
+
|
| 699 |
+
async def get_run_config_async(self, project: str, run: str) -> dict[str, Any]:
|
| 700 |
+
"""Get run config asynchronously using run_in_executor.
|
| 701 |
+
|
| 702 |
+
This reads the .meta.json file which contains params, config, status, etc.
|
| 703 |
+
|
| 704 |
+
Args:
|
| 705 |
+
project: Project name
|
| 706 |
+
run: Run name
|
| 707 |
+
|
| 708 |
+
Returns:
|
| 709 |
+
Dictionary containing run config (params, config, status, etc.)
|
| 710 |
+
"""
|
| 711 |
+
loop = asyncio.get_event_loop()
|
| 712 |
+
return await loop.run_in_executor(None, self.get_run_config, project, run)
|
| 713 |
+
|
| 714 |
+
async def get_metadata_async(self, project: str, run: str) -> dict[str, Any]:
|
| 715 |
+
"""Get run metadata asynchronously using run_in_executor.
|
| 716 |
+
|
| 717 |
+
Args:
|
| 718 |
+
project: Project name
|
| 719 |
+
run: Run name
|
| 720 |
+
|
| 721 |
+
Returns:
|
| 722 |
+
Dictionary containing run metadata (tags, notes, params, etc.)
|
| 723 |
+
"""
|
| 724 |
+
loop = asyncio.get_event_loop()
|
| 725 |
+
return await loop.run_in_executor(None, self.get_metadata, project, run)
|
| 726 |
+
|
| 727 |
+
async def get_artifacts_async(self, project: str, run: str) -> list[dict[str, Any]]:
|
| 728 |
+
"""Get artifacts for a run asynchronously using run_in_executor.
|
| 729 |
+
|
| 730 |
+
Args:
|
| 731 |
+
project: Project name
|
| 732 |
+
run: Run name
|
| 733 |
+
|
| 734 |
+
Returns:
|
| 735 |
+
List of artifact dictionaries
|
| 736 |
+
"""
|
| 737 |
+
loop = asyncio.get_event_loop()
|
| 738 |
+
return await loop.run_in_executor(None, self.get_artifacts, project, run)
|
src/aspara/catalog/watcher.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DataDirWatcher - Singleton watcher for data directory.
|
| 3 |
+
|
| 4 |
+
This module provides a centralized file watcher service that uses a single
|
| 5 |
+
inotify watcher for the entire data directory. Multiple SSE connections
|
| 6 |
+
subscribe to this service, reducing inotify file descriptor usage.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import contextlib
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import uuid
|
| 16 |
+
from collections.abc import AsyncGenerator, Mapping
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from watchfiles import awatch
|
| 22 |
+
|
| 23 |
+
from aspara.models import MetricRecord, RunStatus, StatusRecord
|
| 24 |
+
from aspara.utils.timestamp import parse_to_datetime
|
| 25 |
+
from aspara.utils.validators import validate_name
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class Subscription:
|
| 32 |
+
"""Subscription to data directory changes."""
|
| 33 |
+
|
| 34 |
+
id: str
|
| 35 |
+
targets: Mapping[str, list[str] | None] # project -> runs (None means all runs)
|
| 36 |
+
since: datetime
|
| 37 |
+
queue: asyncio.Queue[MetricRecord | StatusRecord | None] = field(default_factory=asyncio.Queue)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DataDirWatcher:
|
| 41 |
+
"""Singleton watcher for data directory.
|
| 42 |
+
|
| 43 |
+
This class provides a single inotify watcher for the entire data directory,
|
| 44 |
+
allowing multiple SSE connections to subscribe without consuming additional
|
| 45 |
+
file descriptors.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
# Size thresholds for initial read strategy
|
| 49 |
+
LARGE_FILE_THRESHOLD = 1 * 1024 * 1024 # 1MB
|
| 50 |
+
TAIL_READ_SIZE = 64 * 1024 # Read last 64KB for large files
|
| 51 |
+
|
| 52 |
+
_instance: DataDirWatcher | None = None
|
| 53 |
+
_lock: asyncio.Lock | None = None
|
| 54 |
+
|
| 55 |
+
def __init__(self, data_dir: Path) -> None:
|
| 56 |
+
"""Initialize the watcher.
|
| 57 |
+
|
| 58 |
+
Note: Use get_instance() to get the singleton instance.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
data_dir: Base directory for data storage
|
| 62 |
+
"""
|
| 63 |
+
# Resolve to absolute path for consistent comparison with awatch paths
|
| 64 |
+
self.data_dir = data_dir.resolve()
|
| 65 |
+
self._subscriptions: dict[str, Subscription] = {}
|
| 66 |
+
self._task: asyncio.Task[None] | None = None
|
| 67 |
+
self._instance_lock = asyncio.Lock()
|
| 68 |
+
# Track file sizes for incremental reading
|
| 69 |
+
self._file_sizes: dict[Path, int] = {}
|
| 70 |
+
# Track run statuses for change detection
|
| 71 |
+
self._run_statuses: dict[tuple[str, str], str | None] = {}
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
async def get_instance(cls, data_dir: Path) -> DataDirWatcher:
|
| 75 |
+
"""Get or create singleton instance.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
data_dir: Base directory for data storage
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
DataDirWatcher singleton instance
|
| 82 |
+
"""
|
| 83 |
+
if cls._lock is None:
|
| 84 |
+
cls._lock = asyncio.Lock()
|
| 85 |
+
|
| 86 |
+
async with cls._lock:
|
| 87 |
+
if cls._instance is None:
|
| 88 |
+
cls._instance = cls(data_dir)
|
| 89 |
+
logger.info(f"[Watcher] Created singleton DataDirWatcher for {data_dir}")
|
| 90 |
+
return cls._instance
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def reset_instance(cls) -> None:
|
| 94 |
+
"""Reset the singleton instance. Used for testing."""
|
| 95 |
+
cls._instance = None
|
| 96 |
+
cls._lock = None
|
| 97 |
+
|
| 98 |
+
def _parse_file_path(self, file_path: Path) -> tuple[str, str, str] | None:
|
| 99 |
+
"""Parse file path to extract project, run name, and file type.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
file_path: Absolute path to a file
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
(project, run_name, file_type) where file_type is 'metrics', 'wal', or 'meta'
|
| 106 |
+
None if path doesn't match expected pattern
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
relative = file_path.relative_to(self.data_dir)
|
| 110 |
+
except ValueError:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
parts = relative.parts
|
| 114 |
+
if len(parts) != 2:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
project = parts[0]
|
| 118 |
+
filename = parts[1]
|
| 119 |
+
|
| 120 |
+
if filename.endswith(".wal.jsonl"):
|
| 121 |
+
return (project, filename[:-10], "wal")
|
| 122 |
+
elif filename.endswith(".meta.json"):
|
| 123 |
+
return (project, filename[:-10], "meta")
|
| 124 |
+
elif filename.endswith(".jsonl"):
|
| 125 |
+
return (project, filename[:-6], "metrics")
|
| 126 |
+
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
def _parse_metric_line(self, line: str, project: str, run: str, since: datetime) -> MetricRecord | None:
|
| 130 |
+
"""Parse a JSONL line and return MetricRecord if it passes the since filter.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
line: A single line from a JSONL file
|
| 134 |
+
project: Project name
|
| 135 |
+
run: Run name
|
| 136 |
+
since: Filter timestamp - only records with timestamp >= since are returned
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
MetricRecord if parsing succeeds and passes filter, None otherwise
|
| 140 |
+
"""
|
| 141 |
+
if not line.strip():
|
| 142 |
+
return None
|
| 143 |
+
try:
|
| 144 |
+
entry = json.loads(line)
|
| 145 |
+
ts_value = entry.get("timestamp")
|
| 146 |
+
record_ts = None
|
| 147 |
+
if ts_value is not None:
|
| 148 |
+
with contextlib.suppress(ValueError):
|
| 149 |
+
record_ts = parse_to_datetime(ts_value)
|
| 150 |
+
if record_ts is None or record_ts >= since:
|
| 151 |
+
entry["run"] = run
|
| 152 |
+
entry["project"] = project
|
| 153 |
+
return MetricRecord(**entry)
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.debug(f"[Watcher] Error parsing line: {e}")
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
def _read_file_with_strategy(self, file_path: Path) -> tuple[str, int]:
|
| 159 |
+
"""Read file content with size-based strategy.
|
| 160 |
+
|
| 161 |
+
For large files, only the tail portion is read to improve initial load time.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
file_path: Path to the file to read
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
Tuple of (content, end_position) where end_position is the file position after reading
|
| 168 |
+
"""
|
| 169 |
+
file_size = file_path.stat().st_size
|
| 170 |
+
|
| 171 |
+
if file_size < self.LARGE_FILE_THRESHOLD:
|
| 172 |
+
with open(file_path) as f:
|
| 173 |
+
content = f.read()
|
| 174 |
+
return content, f.tell()
|
| 175 |
+
|
| 176 |
+
# Large file: read tail only
|
| 177 |
+
logger.debug(f"[Watcher] Large file ({file_size} bytes), reading tail: {file_path}")
|
| 178 |
+
with open(file_path) as f:
|
| 179 |
+
read_start = max(0, file_size - self.TAIL_READ_SIZE)
|
| 180 |
+
f.seek(read_start)
|
| 181 |
+
content = f.read()
|
| 182 |
+
end_pos = f.tell()
|
| 183 |
+
|
| 184 |
+
# Skip partial first line if we didn't start at beginning
|
| 185 |
+
if read_start > 0:
|
| 186 |
+
first_newline = content.find("\n")
|
| 187 |
+
if first_newline != -1:
|
| 188 |
+
content = content[first_newline + 1 :]
|
| 189 |
+
|
| 190 |
+
return content, end_pos
|
| 191 |
+
|
| 192 |
+
def _init_run_status(self, project: str, run: str, meta_file: Path) -> None:
|
| 193 |
+
"""Initialize run status tracking from meta file.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
project: Project name
|
| 197 |
+
run: Run name
|
| 198 |
+
meta_file: Path to the metadata file
|
| 199 |
+
"""
|
| 200 |
+
key = (project, run)
|
| 201 |
+
if meta_file.exists():
|
| 202 |
+
try:
|
| 203 |
+
with open(meta_file) as f:
|
| 204 |
+
meta = json.load(f)
|
| 205 |
+
self._run_statuses[key] = meta.get("status")
|
| 206 |
+
except Exception:
|
| 207 |
+
self._run_statuses[key] = None
|
| 208 |
+
else:
|
| 209 |
+
self._run_statuses[key] = None
|
| 210 |
+
|
| 211 |
+
def _matches_targets(self, targets: Mapping[str, list[str] | None], project: str, run: str) -> bool:
|
| 212 |
+
"""Check if a project/run matches the subscription targets.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
targets: Subscription targets
|
| 216 |
+
project: Project name
|
| 217 |
+
run: Run name
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
True if the project/run matches the targets
|
| 221 |
+
"""
|
| 222 |
+
if project not in targets:
|
| 223 |
+
return False
|
| 224 |
+
|
| 225 |
+
run_list = targets[project]
|
| 226 |
+
if run_list is None:
|
| 227 |
+
# None means watch all runs in the project
|
| 228 |
+
return True
|
| 229 |
+
|
| 230 |
+
return run in run_list
|
| 231 |
+
|
| 232 |
+
async def _read_initial_data(
|
| 233 |
+
self,
|
| 234 |
+
targets: Mapping[str, list[str] | None],
|
| 235 |
+
since: datetime,
|
| 236 |
+
) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
|
| 237 |
+
"""Read initial data from existing files.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
targets: Dictionary mapping project names to run lists
|
| 241 |
+
since: Filter to only yield records with timestamp >= since
|
| 242 |
+
|
| 243 |
+
Yields:
|
| 244 |
+
MetricRecord objects from existing files
|
| 245 |
+
"""
|
| 246 |
+
for project, run_names in targets.items():
|
| 247 |
+
try:
|
| 248 |
+
validate_name(project, "project name")
|
| 249 |
+
except ValueError as e:
|
| 250 |
+
logger.warning(f"[Watcher] Invalid project name {project}: {e}")
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
project_dir = self.data_dir / project
|
| 254 |
+
if not project_dir.exists():
|
| 255 |
+
logger.warning(f"[Watcher] Project directory does not exist: {project_dir}")
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
# If run_names is None, discover all runs
|
| 259 |
+
if run_names is None:
|
| 260 |
+
actual_runs = []
|
| 261 |
+
for f in project_dir.glob("*.jsonl"):
|
| 262 |
+
if f.name.endswith(".wal.jsonl"):
|
| 263 |
+
continue
|
| 264 |
+
# Skip symlinks to prevent symlink-based attacks
|
| 265 |
+
if f.is_symlink():
|
| 266 |
+
logger.warning(f"[Watcher] Skipping symlink: {f}")
|
| 267 |
+
continue
|
| 268 |
+
actual_runs.append(f.stem)
|
| 269 |
+
run_names = actual_runs
|
| 270 |
+
|
| 271 |
+
for run in run_names:
|
| 272 |
+
# Check which files exist for this run
|
| 273 |
+
wal_file = project_dir / f"{run}.wal.jsonl"
|
| 274 |
+
jsonl_file = project_dir / f"{run}.jsonl"
|
| 275 |
+
meta_file = project_dir / f"{run}.meta.json"
|
| 276 |
+
|
| 277 |
+
# Initialize status tracking
|
| 278 |
+
self._init_run_status(project, run, meta_file)
|
| 279 |
+
|
| 280 |
+
# Read metrics files
|
| 281 |
+
for file_path in [wal_file, jsonl_file]:
|
| 282 |
+
if not file_path.exists():
|
| 283 |
+
continue
|
| 284 |
+
|
| 285 |
+
resolved = file_path.resolve()
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
content, end_pos = self._read_file_with_strategy(resolved)
|
| 289 |
+
self._file_sizes[resolved] = end_pos
|
| 290 |
+
|
| 291 |
+
for line in content.splitlines():
|
| 292 |
+
record = self._parse_metric_line(line, project, run, since)
|
| 293 |
+
if record is not None:
|
| 294 |
+
yield record
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.warning(f"[Watcher] Error reading {resolved}: {e}")
|
| 297 |
+
if resolved.exists():
|
| 298 |
+
self._file_sizes[resolved] = resolved.stat().st_size
|
| 299 |
+
|
| 300 |
+
# Record meta file size
|
| 301 |
+
if meta_file.exists():
|
| 302 |
+
self._file_sizes[meta_file.resolve()] = meta_file.stat().st_size
|
| 303 |
+
|
| 304 |
+
async def _dispatch_loop(self) -> None:
|
| 305 |
+
"""Main loop: watch data_dir and dispatch to subscribers."""
|
| 306 |
+
logger.info(f"[Watcher] Starting dispatch loop for {self.data_dir}")
|
| 307 |
+
watcher = None
|
| 308 |
+
|
| 309 |
+
try:
|
| 310 |
+
watcher = awatch(str(self.data_dir))
|
| 311 |
+
loop_count = 0
|
| 312 |
+
async for changes in watcher:
|
| 313 |
+
loop_count += 1
|
| 314 |
+
if loop_count % 10000 == 0:
|
| 315 |
+
logger.warning(f"[Watcher] Loop count: {loop_count}, changes: {len(changes)}")
|
| 316 |
+
logger.debug(f"[Watcher] Received {len(changes)} change(s)")
|
| 317 |
+
|
| 318 |
+
for _change_type, changed_path_str in changes:
|
| 319 |
+
changed_path = Path(changed_path_str).resolve()
|
| 320 |
+
|
| 321 |
+
# Parse file path to get project/run/type
|
| 322 |
+
parsed = self._parse_file_path(changed_path)
|
| 323 |
+
if parsed is None:
|
| 324 |
+
continue
|
| 325 |
+
|
| 326 |
+
project, run, file_type = parsed
|
| 327 |
+
logger.debug(f"[Watcher] File change: {changed_path} (project={project}, run={run}, type={file_type})")
|
| 328 |
+
|
| 329 |
+
# Dispatch to matching subscribers
|
| 330 |
+
async with self._instance_lock:
|
| 331 |
+
for sub in self._subscriptions.values():
|
| 332 |
+
if not self._matches_targets(sub.targets, project, run):
|
| 333 |
+
continue
|
| 334 |
+
|
| 335 |
+
try:
|
| 336 |
+
if file_type == "meta":
|
| 337 |
+
# Handle metadata/status update
|
| 338 |
+
status_record = await self._process_meta_change(changed_path, project, run)
|
| 339 |
+
if status_record:
|
| 340 |
+
await sub.queue.put(status_record)
|
| 341 |
+
else:
|
| 342 |
+
# Handle metrics update
|
| 343 |
+
metric_records = await self._process_metrics_change(changed_path, project, run, sub.since)
|
| 344 |
+
for metric_record in metric_records:
|
| 345 |
+
await sub.queue.put(metric_record)
|
| 346 |
+
except Exception as e:
|
| 347 |
+
logger.error(f"[Watcher] Error dispatching to subscription: {e}")
|
| 348 |
+
|
| 349 |
+
except asyncio.CancelledError:
|
| 350 |
+
logger.info("[Watcher] Dispatch loop cancelled")
|
| 351 |
+
raise
|
| 352 |
+
except Exception as e:
|
| 353 |
+
logger.error(f"[Watcher] Error in dispatch loop: {e}")
|
| 354 |
+
finally:
|
| 355 |
+
if watcher is not None:
|
| 356 |
+
logger.info("[Watcher] Closing awatch instance")
|
| 357 |
+
try:
|
| 358 |
+
await asyncio.wait_for(watcher.aclose(), timeout=2.0)
|
| 359 |
+
except asyncio.TimeoutError:
|
| 360 |
+
logger.warning("[Watcher] Timeout closing awatch instance")
|
| 361 |
+
except Exception as e:
|
| 362 |
+
logger.error(f"[Watcher] Error closing watcher: {e}")
|
| 363 |
+
|
| 364 |
+
async def _process_meta_change(self, file_path: Path, project: str, run: str) -> StatusRecord | None:
|
| 365 |
+
"""Process a metadata file change.
|
| 366 |
+
|
| 367 |
+
Args:
|
| 368 |
+
file_path: Path to the metadata file
|
| 369 |
+
project: Project name
|
| 370 |
+
run: Run name
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
StatusRecord if status changed, None otherwise
|
| 374 |
+
"""
|
| 375 |
+
try:
|
| 376 |
+
with open(file_path) as f:
|
| 377 |
+
meta = json.load(f)
|
| 378 |
+
new_status = meta.get("status")
|
| 379 |
+
|
| 380 |
+
key = (project, run)
|
| 381 |
+
if new_status != self._run_statuses.get(key):
|
| 382 |
+
logger.info(f"[Watcher] Status change for {project}/{run}: {self._run_statuses.get(key)} -> {new_status}")
|
| 383 |
+
self._run_statuses[key] = new_status
|
| 384 |
+
|
| 385 |
+
return StatusRecord(
|
| 386 |
+
run=run,
|
| 387 |
+
project=project,
|
| 388 |
+
status=new_status or RunStatus.WIP.value,
|
| 389 |
+
is_finished=meta.get("is_finished", False),
|
| 390 |
+
exit_code=meta.get("exit_code"),
|
| 391 |
+
)
|
| 392 |
+
except Exception as e:
|
| 393 |
+
logger.error(f"[Watcher] Error reading metadata file {file_path}: {e}")
|
| 394 |
+
|
| 395 |
+
return None
|
| 396 |
+
|
| 397 |
+
async def _process_metrics_change(self, file_path: Path, project: str, run: str, since: datetime) -> list[MetricRecord]:
|
| 398 |
+
"""Process a metrics file change.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
file_path: Path to the metrics file
|
| 402 |
+
project: Project name
|
| 403 |
+
run: Run name
|
| 404 |
+
since: Filter timestamp
|
| 405 |
+
|
| 406 |
+
Returns:
|
| 407 |
+
List of MetricRecord objects
|
| 408 |
+
"""
|
| 409 |
+
records: list[MetricRecord] = []
|
| 410 |
+
|
| 411 |
+
try:
|
| 412 |
+
current_size = self._file_sizes.get(file_path, 0)
|
| 413 |
+
with open(file_path) as f:
|
| 414 |
+
f.seek(current_size)
|
| 415 |
+
new_content = f.read()
|
| 416 |
+
self._file_sizes[file_path] = f.tell()
|
| 417 |
+
|
| 418 |
+
for line in new_content.splitlines():
|
| 419 |
+
record = self._parse_metric_line(line, project, run, since)
|
| 420 |
+
if record is not None:
|
| 421 |
+
records.append(record)
|
| 422 |
+
|
| 423 |
+
except Exception as e:
|
| 424 |
+
logger.error(f"[Watcher] Error processing metrics file {file_path}: {e}")
|
| 425 |
+
|
| 426 |
+
return records
|
| 427 |
+
|
| 428 |
+
async def subscribe(
|
| 429 |
+
self,
|
| 430 |
+
targets: Mapping[str, list[str] | None],
|
| 431 |
+
since: datetime,
|
| 432 |
+
) -> AsyncGenerator[MetricRecord | StatusRecord, None]:
|
| 433 |
+
"""Subscribe to file changes for specified targets.
|
| 434 |
+
|
| 435 |
+
Args:
|
| 436 |
+
targets: Dictionary mapping project names to list of run names.
|
| 437 |
+
If run list is None, all runs in the project are watched.
|
| 438 |
+
since: Filter to only yield records with timestamp >= since
|
| 439 |
+
|
| 440 |
+
Yields:
|
| 441 |
+
MetricRecord or StatusRecord as files are updated
|
| 442 |
+
"""
|
| 443 |
+
# Ensure since is timezone-aware
|
| 444 |
+
if since.tzinfo is None:
|
| 445 |
+
since = since.replace(tzinfo=timezone.utc)
|
| 446 |
+
|
| 447 |
+
subscription_id = str(uuid.uuid4())
|
| 448 |
+
queue: asyncio.Queue[MetricRecord | StatusRecord | None] = asyncio.Queue()
|
| 449 |
+
|
| 450 |
+
subscription = Subscription(
|
| 451 |
+
id=subscription_id,
|
| 452 |
+
targets=targets,
|
| 453 |
+
since=since,
|
| 454 |
+
queue=queue,
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
logger.info(f"[Watcher] New subscription {subscription_id} for targets={targets}")
|
| 458 |
+
|
| 459 |
+
async with self._instance_lock:
|
| 460 |
+
self._subscriptions[subscription_id] = subscription
|
| 461 |
+
# Start watcher task if not running
|
| 462 |
+
if self._task is None or self._task.done():
|
| 463 |
+
logger.info("[Watcher] Starting dispatch task")
|
| 464 |
+
self._task = asyncio.create_task(self._dispatch_loop())
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
# Yield initial data (existing records >= since)
|
| 468 |
+
async for record in self._read_initial_data(targets, since):
|
| 469 |
+
yield record
|
| 470 |
+
|
| 471 |
+
# Yield updates from queue
|
| 472 |
+
while True:
|
| 473 |
+
queued_record: MetricRecord | StatusRecord | None = await queue.get()
|
| 474 |
+
if queued_record is None: # Sentinel for unsubscribe
|
| 475 |
+
break
|
| 476 |
+
yield queued_record
|
| 477 |
+
finally:
|
| 478 |
+
await self._unsubscribe(subscription_id)
|
| 479 |
+
|
| 480 |
+
async def _unsubscribe(self, subscription_id: str) -> None:
|
| 481 |
+
"""Unsubscribe from file changes.
|
| 482 |
+
|
| 483 |
+
Args:
|
| 484 |
+
subscription_id: Subscription ID to remove
|
| 485 |
+
"""
|
| 486 |
+
logger.info(f"[Watcher] Unsubscribing {subscription_id}")
|
| 487 |
+
|
| 488 |
+
async with self._instance_lock:
|
| 489 |
+
if subscription_id in self._subscriptions:
|
| 490 |
+
del self._subscriptions[subscription_id]
|
| 491 |
+
|
| 492 |
+
# Stop watcher task if no more subscribers
|
| 493 |
+
if not self._subscriptions and self._task is not None:
|
| 494 |
+
logger.info("[Watcher] No more subscribers, stopping dispatch task")
|
| 495 |
+
self._task.cancel()
|
| 496 |
+
try:
|
| 497 |
+
await asyncio.wait_for(self._task, timeout=2.0)
|
| 498 |
+
except asyncio.TimeoutError:
|
| 499 |
+
logger.warning("[Watcher] Timeout waiting for dispatch task to finish")
|
| 500 |
+
except asyncio.CancelledError:
|
| 501 |
+
pass
|
| 502 |
+
self._task = None
|
| 503 |
+
|
| 504 |
+
@property
|
| 505 |
+
def subscription_count(self) -> int:
|
| 506 |
+
"""Get the number of active subscriptions."""
|
| 507 |
+
return len(self._subscriptions)
|
src/aspara/cli.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Aspara CLI tool
|
| 4 |
+
|
| 5 |
+
Command line interface for starting dashboard and tracker API
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import os
|
| 12 |
+
import socket
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import uvicorn
|
| 16 |
+
|
| 17 |
+
from aspara.config import get_data_dir, get_storage_backend
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_serve_components(components: list[str]) -> tuple[bool, bool]:
|
| 21 |
+
"""
|
| 22 |
+
Parse and validate component list for serve command
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
components: List of component names
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Tuple of (enable_dashboard, enable_tracker)
|
| 29 |
+
|
| 30 |
+
Raises:
|
| 31 |
+
ValueError: If invalid component name is provided
|
| 32 |
+
"""
|
| 33 |
+
valid_components = {"dashboard", "tracker", "together"}
|
| 34 |
+
|
| 35 |
+
# Default: dashboard only
|
| 36 |
+
if not components:
|
| 37 |
+
return (True, False)
|
| 38 |
+
|
| 39 |
+
# Normalize and validate
|
| 40 |
+
normalized = [c.lower() for c in components]
|
| 41 |
+
for comp in normalized:
|
| 42 |
+
if comp not in valid_components:
|
| 43 |
+
raise ValueError(f"Invalid component: {comp}. Valid options are: dashboard, tracker, together")
|
| 44 |
+
|
| 45 |
+
# Handle 'together' keyword
|
| 46 |
+
if "together" in normalized:
|
| 47 |
+
return (True, True)
|
| 48 |
+
|
| 49 |
+
# Handle explicit component list
|
| 50 |
+
enable_dashboard = "dashboard" in normalized
|
| 51 |
+
enable_tracker = "tracker" in normalized
|
| 52 |
+
|
| 53 |
+
# If both specified, enable both
|
| 54 |
+
if enable_dashboard and enable_tracker:
|
| 55 |
+
return (True, True)
|
| 56 |
+
|
| 57 |
+
return (enable_dashboard, enable_tracker)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_default_port(enable_dashboard: bool, enable_tracker: bool) -> int:
|
| 61 |
+
"""
|
| 62 |
+
Get default port based on enabled components
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
enable_dashboard: Whether dashboard is enabled
|
| 66 |
+
enable_tracker: Whether tracker is enabled
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Default port number (3142 for tracker-only, 3141 otherwise)
|
| 70 |
+
"""
|
| 71 |
+
if enable_tracker and not enable_dashboard:
|
| 72 |
+
return 3142
|
| 73 |
+
return 3141
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def find_available_port(start_port: int = 3141, max_attempts: int = 100) -> int | None:
|
| 77 |
+
"""
|
| 78 |
+
Find an available port number
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
start_port: Starting port number
|
| 82 |
+
max_attempts: Maximum number of attempts
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Available port number, None if not found
|
| 86 |
+
"""
|
| 87 |
+
for port in range(start_port, start_port + max_attempts):
|
| 88 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 89 |
+
# If connection fails, that port is available
|
| 90 |
+
result = sock.connect_ex(("127.0.0.1", port))
|
| 91 |
+
if result != 0:
|
| 92 |
+
return port
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def run_dashboard(
|
| 97 |
+
host: str = "127.0.0.1",
|
| 98 |
+
port: int = 3141,
|
| 99 |
+
with_tracker: bool = False,
|
| 100 |
+
data_dir: str | None = None,
|
| 101 |
+
dev: bool = False,
|
| 102 |
+
project_search_mode: str = "realtime",
|
| 103 |
+
) -> None:
|
| 104 |
+
"""
|
| 105 |
+
Start dashboard server
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
host: Host name
|
| 109 |
+
port: Port number
|
| 110 |
+
with_tracker: Whether to run integrated tracker in same process
|
| 111 |
+
data_dir: Data directory for local data
|
| 112 |
+
dev: Enable development mode with auto-reload
|
| 113 |
+
project_search_mode: Project search mode on dashboard home (realtime or manual)
|
| 114 |
+
"""
|
| 115 |
+
# Set env vars for component mounting
|
| 116 |
+
os.environ["ASPARA_SERVE_DASHBOARD"] = "1"
|
| 117 |
+
os.environ["ASPARA_SERVE_TRACKER"] = "1" if with_tracker else "0"
|
| 118 |
+
|
| 119 |
+
if with_tracker:
|
| 120 |
+
os.environ["ASPARA_WITH_TRACKER"] = "1"
|
| 121 |
+
|
| 122 |
+
if dev:
|
| 123 |
+
os.environ["ASPARA_DEV_MODE"] = "1"
|
| 124 |
+
|
| 125 |
+
if data_dir is None:
|
| 126 |
+
data_dir = str(get_data_dir())
|
| 127 |
+
|
| 128 |
+
os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
|
| 129 |
+
|
| 130 |
+
if project_search_mode:
|
| 131 |
+
os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
|
| 132 |
+
|
| 133 |
+
from aspara.dashboard.router import configure_data_dir
|
| 134 |
+
|
| 135 |
+
configure_data_dir(data_dir)
|
| 136 |
+
|
| 137 |
+
print("Starting Aspara Dashboard server...")
|
| 138 |
+
print(f"Access http://{host}:{port} in your browser!")
|
| 139 |
+
print(f"Data directory: {os.path.abspath(data_dir)}")
|
| 140 |
+
backend = get_storage_backend() or "jsonl (default)"
|
| 141 |
+
print(f"Storage backend: {backend}")
|
| 142 |
+
if dev:
|
| 143 |
+
print("Development mode: auto-reload enabled")
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
|
| 147 |
+
except ImportError:
|
| 148 |
+
print("Error: Dashboard functionality is not installed!")
|
| 149 |
+
print('To install: uv pip install "aspara[dashboard]"')
|
| 150 |
+
sys.exit(1)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def run_tui(data_dir: str | None = None) -> None:
|
| 154 |
+
"""
|
| 155 |
+
Start TUI dashboard
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
|
| 159 |
+
"""
|
| 160 |
+
if data_dir is None:
|
| 161 |
+
data_dir = str(get_data_dir())
|
| 162 |
+
|
| 163 |
+
print("Starting Aspara TUI...")
|
| 164 |
+
print(f"Data directory: {os.path.abspath(data_dir)}")
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
from aspara.tui import run_tui as _run_tui
|
| 168 |
+
|
| 169 |
+
_run_tui(data_dir=data_dir)
|
| 170 |
+
except ImportError:
|
| 171 |
+
print("TUI functionality is not installed!")
|
| 172 |
+
print('To install: uv pip install "aspara[tui]"')
|
| 173 |
+
sys.exit(1)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def run_tracker(
|
| 177 |
+
host: str = "127.0.0.1",
|
| 178 |
+
port: int = 3142,
|
| 179 |
+
data_dir: str | None = None,
|
| 180 |
+
dev: bool = False,
|
| 181 |
+
storage_backend: str | None = None,
|
| 182 |
+
) -> None:
|
| 183 |
+
"""
|
| 184 |
+
Start tracker API server
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
host: Host name
|
| 188 |
+
port: Port number
|
| 189 |
+
data_dir: Data directory. Defaults to XDG-based default (~/.local/share/aspara)
|
| 190 |
+
dev: Enable development mode with auto-reload
|
| 191 |
+
storage_backend: Metrics storage backend (jsonl or polars)
|
| 192 |
+
"""
|
| 193 |
+
# Set env vars for backward compatibility
|
| 194 |
+
os.environ["ASPARA_SERVE_TRACKER"] = "1"
|
| 195 |
+
os.environ["ASPARA_SERVE_DASHBOARD"] = "0"
|
| 196 |
+
|
| 197 |
+
if dev:
|
| 198 |
+
os.environ["ASPARA_DEV_MODE"] = "1"
|
| 199 |
+
|
| 200 |
+
if storage_backend is not None:
|
| 201 |
+
os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
|
| 202 |
+
|
| 203 |
+
if data_dir is None:
|
| 204 |
+
data_dir = str(get_data_dir())
|
| 205 |
+
|
| 206 |
+
os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
|
| 207 |
+
|
| 208 |
+
print("Starting Aspara Tracker API server...")
|
| 209 |
+
print(f"Endpoint: http://{host}:{port}/tracker/api/v1")
|
| 210 |
+
print(f"Data directory: {os.path.abspath(data_dir)}")
|
| 211 |
+
backend = get_storage_backend() or "jsonl (default)"
|
| 212 |
+
print(f"Storage backend: {backend}")
|
| 213 |
+
if dev:
|
| 214 |
+
print("Development mode: auto-reload enabled")
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
|
| 218 |
+
except ImportError:
|
| 219 |
+
print("Error: Tracker functionality is not installed!")
|
| 220 |
+
print('To install: uv pip install "aspara[tracker]"')
|
| 221 |
+
sys.exit(1)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def run_serve(
|
| 225 |
+
components: list[str],
|
| 226 |
+
host: str = "127.0.0.1",
|
| 227 |
+
port: int | None = None,
|
| 228 |
+
data_dir: str | None = None,
|
| 229 |
+
dev: bool = False,
|
| 230 |
+
project_search_mode: str = "realtime",
|
| 231 |
+
storage_backend: str | None = None,
|
| 232 |
+
) -> None:
|
| 233 |
+
"""
|
| 234 |
+
Start Aspara server with specified components
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
components: List of components to enable (dashboard, tracker, together)
|
| 238 |
+
host: Host name
|
| 239 |
+
port: Port number (auto-detected if None)
|
| 240 |
+
data_dir: Data directory
|
| 241 |
+
dev: Enable development mode with auto-reload
|
| 242 |
+
project_search_mode: Project search mode on dashboard home (realtime or manual)
|
| 243 |
+
storage_backend: Metrics storage backend (jsonl or polars)
|
| 244 |
+
"""
|
| 245 |
+
try:
|
| 246 |
+
enable_dashboard, enable_tracker = parse_serve_components(components)
|
| 247 |
+
except ValueError as e:
|
| 248 |
+
print(f"Error: {e}")
|
| 249 |
+
sys.exit(1)
|
| 250 |
+
|
| 251 |
+
# Set environment variables for component mounting
|
| 252 |
+
os.environ["ASPARA_SERVE_DASHBOARD"] = "1" if enable_dashboard else "0"
|
| 253 |
+
os.environ["ASPARA_SERVE_TRACKER"] = "1" if enable_tracker else "0"
|
| 254 |
+
|
| 255 |
+
if dev:
|
| 256 |
+
os.environ["ASPARA_DEV_MODE"] = "1"
|
| 257 |
+
|
| 258 |
+
if storage_backend is not None:
|
| 259 |
+
os.environ["ASPARA_STORAGE_BACKEND"] = storage_backend
|
| 260 |
+
|
| 261 |
+
# Determine port
|
| 262 |
+
if port is None:
|
| 263 |
+
port = get_default_port(enable_dashboard, enable_tracker)
|
| 264 |
+
|
| 265 |
+
# Configure data directory
|
| 266 |
+
if data_dir is None:
|
| 267 |
+
data_dir = str(get_data_dir())
|
| 268 |
+
|
| 269 |
+
os.environ["ASPARA_DATA_DIR"] = os.path.abspath(data_dir)
|
| 270 |
+
|
| 271 |
+
# Configure dashboard if enabled
|
| 272 |
+
if enable_dashboard:
|
| 273 |
+
if project_search_mode:
|
| 274 |
+
os.environ["ASPARA_PROJECT_SEARCH_MODE"] = project_search_mode
|
| 275 |
+
|
| 276 |
+
from aspara.dashboard.router import configure_data_dir
|
| 277 |
+
|
| 278 |
+
configure_data_dir(data_dir)
|
| 279 |
+
|
| 280 |
+
# Build component description
|
| 281 |
+
if enable_dashboard and enable_tracker:
|
| 282 |
+
component_desc = "Dashboard + Tracker"
|
| 283 |
+
elif enable_dashboard:
|
| 284 |
+
component_desc = "Dashboard"
|
| 285 |
+
else:
|
| 286 |
+
component_desc = "Tracker"
|
| 287 |
+
|
| 288 |
+
print(f"Starting Aspara {component_desc} server...")
|
| 289 |
+
print(f"Access http://{host}:{port} in your browser!")
|
| 290 |
+
print(f"Data directory: {os.path.abspath(data_dir)}")
|
| 291 |
+
backend = get_storage_backend() or "jsonl (default)"
|
| 292 |
+
print(f"Storage backend: {backend}")
|
| 293 |
+
if dev:
|
| 294 |
+
print("Development mode: auto-reload enabled")
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
uvicorn.run("aspara.server:app", host=host, port=port, reload=dev)
|
| 298 |
+
except ImportError as e:
|
| 299 |
+
print(f"Error: Required functionality is not installed: {e}")
|
| 300 |
+
sys.exit(1)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def main() -> None:
|
| 304 |
+
"""
|
| 305 |
+
CLI main entry point
|
| 306 |
+
"""
|
| 307 |
+
parser = argparse.ArgumentParser(description="Aspara management tool")
|
| 308 |
+
subparsers = parser.add_subparsers(dest="command", help="Subcommands")
|
| 309 |
+
|
| 310 |
+
dashboard_parser = subparsers.add_parser("dashboard", help="Start dashboard server")
|
| 311 |
+
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
|
| 312 |
+
dashboard_parser.add_argument("--port", type=int, default=3141, help="Port number (default: 3141)")
|
| 313 |
+
dashboard_parser.add_argument("--with-tracker", action="store_true", help="Run dashboard with integrated tracker in same process")
|
| 314 |
+
dashboard_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
|
| 315 |
+
dashboard_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
|
| 316 |
+
dashboard_parser.add_argument(
|
| 317 |
+
"--project-search-mode",
|
| 318 |
+
choices=["realtime", "manual"],
|
| 319 |
+
default="realtime",
|
| 320 |
+
help="Project search mode on dashboard home (realtime or manual, default: realtime)",
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
tracker_parser = subparsers.add_parser("tracker", help="Start tracker API server")
|
| 324 |
+
tracker_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
|
| 325 |
+
tracker_parser.add_argument("--port", type=int, default=3142, help="Port number (default: 3142)")
|
| 326 |
+
tracker_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
|
| 327 |
+
tracker_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
|
| 328 |
+
tracker_parser.add_argument(
|
| 329 |
+
"--storage-backend",
|
| 330 |
+
choices=["jsonl", "polars"],
|
| 331 |
+
default=None,
|
| 332 |
+
help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
tui_parser = subparsers.add_parser("tui", help="Start terminal UI dashboard")
|
| 336 |
+
tui_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
|
| 337 |
+
|
| 338 |
+
serve_parser = subparsers.add_parser("serve", help="Start Aspara server")
|
| 339 |
+
serve_parser.add_argument(
|
| 340 |
+
"components",
|
| 341 |
+
nargs="*",
|
| 342 |
+
default=[],
|
| 343 |
+
help="Components to run: dashboard, tracker, together (default: dashboard only)",
|
| 344 |
+
)
|
| 345 |
+
serve_parser.add_argument("--host", default="127.0.0.1", help="Host name (default: 127.0.0.1)")
|
| 346 |
+
serve_parser.add_argument("--port", type=int, default=None, help="Port number (default: 3141 for dashboard, 3142 for tracker-only)")
|
| 347 |
+
serve_parser.add_argument("--data-dir", default=None, help="Data directory (default: XDG-based ~/.local/share/aspara)")
|
| 348 |
+
serve_parser.add_argument("--dev", action="store_true", help="Enable development mode with auto-reload")
|
| 349 |
+
serve_parser.add_argument(
|
| 350 |
+
"--project-search-mode",
|
| 351 |
+
choices=["realtime", "manual"],
|
| 352 |
+
default="realtime",
|
| 353 |
+
help="Project search mode on dashboard home (realtime or manual, default: realtime)",
|
| 354 |
+
)
|
| 355 |
+
serve_parser.add_argument(
|
| 356 |
+
"--storage-backend",
|
| 357 |
+
choices=["jsonl", "polars"],
|
| 358 |
+
default=None,
|
| 359 |
+
help="Metrics storage backend (default: jsonl or ASPARA_STORAGE_BACKEND)",
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
args = parser.parse_args()
|
| 363 |
+
|
| 364 |
+
if args.command == "dashboard":
|
| 365 |
+
run_dashboard(
|
| 366 |
+
host=args.host,
|
| 367 |
+
port=args.port,
|
| 368 |
+
with_tracker=args.with_tracker,
|
| 369 |
+
data_dir=args.data_dir,
|
| 370 |
+
dev=args.dev,
|
| 371 |
+
project_search_mode=args.project_search_mode,
|
| 372 |
+
)
|
| 373 |
+
elif args.command == "tracker":
|
| 374 |
+
run_tracker(
|
| 375 |
+
host=args.host,
|
| 376 |
+
port=args.port,
|
| 377 |
+
data_dir=args.data_dir,
|
| 378 |
+
dev=args.dev,
|
| 379 |
+
storage_backend=args.storage_backend,
|
| 380 |
+
)
|
| 381 |
+
elif args.command == "tui":
|
| 382 |
+
run_tui(data_dir=args.data_dir)
|
| 383 |
+
elif args.command == "serve":
|
| 384 |
+
run_serve(
|
| 385 |
+
components=args.components,
|
| 386 |
+
host=args.host,
|
| 387 |
+
port=args.port,
|
| 388 |
+
data_dir=args.data_dir,
|
| 389 |
+
dev=args.dev,
|
| 390 |
+
project_search_mode=args.project_search_mode,
|
| 391 |
+
storage_backend=args.storage_backend,
|
| 392 |
+
)
|
| 393 |
+
else:
|
| 394 |
+
port = find_available_port(start_port=3141)
|
| 395 |
+
if port is None:
|
| 396 |
+
print("Error: No available port found!")
|
| 397 |
+
return
|
| 398 |
+
|
| 399 |
+
run_dashboard(port=port)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
if __name__ == "__main__":
|
| 403 |
+
main()
|
src/aspara/config.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration and environment handling for Aspara."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"ResourceLimits",
|
| 10 |
+
"get_data_dir",
|
| 11 |
+
"get_resource_limits",
|
| 12 |
+
"get_storage_backend",
|
| 13 |
+
"get_project_search_mode",
|
| 14 |
+
"is_dev_mode",
|
| 15 |
+
"is_read_only",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ResourceLimits(BaseModel):
|
| 20 |
+
"""Resource limits configuration.
|
| 21 |
+
|
| 22 |
+
Includes security-related limits (file size, JSONL lines) and
|
| 23 |
+
performance/resource constraints (metric names, note length, tags count).
|
| 24 |
+
|
| 25 |
+
All limits can be customized via environment variables.
|
| 26 |
+
Defaults are set for internal use with generous limits.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
max_file_size: int = Field(
|
| 30 |
+
default=1024 * 1024 * 1024, # 1024MB (1GB)
|
| 31 |
+
description="Maximum file size in bytes",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
max_jsonl_lines: int = Field(
|
| 35 |
+
default=1_000_000, # 1M lines
|
| 36 |
+
description="Maximum number of lines when reading JSONL files",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
max_zip_size: int = Field(
|
| 40 |
+
default=1024 * 1024 * 1024, # 1GB
|
| 41 |
+
description="Maximum ZIP file size in bytes",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
max_metric_names: int = Field(
|
| 45 |
+
default=100,
|
| 46 |
+
description="Maximum number of metric names in comma-separated list",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
max_notes_length: int = Field(
|
| 50 |
+
default=10 * 1024, # 10KB
|
| 51 |
+
description="Maximum notes text length in characters",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
max_tags_count: int = Field(
|
| 55 |
+
default=100,
|
| 56 |
+
description="Maximum number of tags",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
lttb_threshold: int = Field(
|
| 60 |
+
default=1_000,
|
| 61 |
+
description="Downsample metrics using LTTB algorithm when metric series length exceeds this threshold",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
@classmethod
|
| 65 |
+
def from_env(cls) -> "ResourceLimits":
|
| 66 |
+
"""Create ResourceLimits from environment variables.
|
| 67 |
+
|
| 68 |
+
Environment variables:
|
| 69 |
+
- ASPARA_MAX_FILE_SIZE: Maximum file size in bytes (default: 1GB)
|
| 70 |
+
- ASPARA_MAX_JSONL_LINES: Maximum JSONL lines (default: 1M)
|
| 71 |
+
- ASPARA_MAX_ZIP_SIZE: Maximum ZIP size in bytes (default: 1GB)
|
| 72 |
+
- ASPARA_MAX_METRIC_NAMES: Maximum metric names (default: 100)
|
| 73 |
+
- ASPARA_MAX_NOTES_LENGTH: Maximum notes length (default: 10KB)
|
| 74 |
+
- ASPARA_MAX_TAGS_COUNT: Maximum tags count (default: 100)
|
| 75 |
+
- ASPARA_LTTB_THRESHOLD: Threshold for LTTB downsampling (default: 1000)
|
| 76 |
+
"""
|
| 77 |
+
return cls(
|
| 78 |
+
max_file_size=int(os.environ.get("ASPARA_MAX_FILE_SIZE", cls.model_fields["max_file_size"].default)),
|
| 79 |
+
max_jsonl_lines=int(os.environ.get("ASPARA_MAX_JSONL_LINES", cls.model_fields["max_jsonl_lines"].default)),
|
| 80 |
+
max_zip_size=int(os.environ.get("ASPARA_MAX_ZIP_SIZE", cls.model_fields["max_zip_size"].default)),
|
| 81 |
+
max_metric_names=int(os.environ.get("ASPARA_MAX_METRIC_NAMES", cls.model_fields["max_metric_names"].default)),
|
| 82 |
+
max_notes_length=int(os.environ.get("ASPARA_MAX_NOTES_LENGTH", cls.model_fields["max_notes_length"].default)),
|
| 83 |
+
max_tags_count=int(os.environ.get("ASPARA_MAX_TAGS_COUNT", cls.model_fields["max_tags_count"].default)),
|
| 84 |
+
lttb_threshold=int(os.environ.get("ASPARA_LTTB_THRESHOLD", cls.model_fields["lttb_threshold"].default)),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Global resource limits instance
|
| 89 |
+
_resource_limits: ResourceLimits | None = None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def get_resource_limits() -> ResourceLimits:
|
| 93 |
+
"""Get resource limits configuration.
|
| 94 |
+
|
| 95 |
+
Returns cached instance if already initialized.
|
| 96 |
+
"""
|
| 97 |
+
global _resource_limits
|
| 98 |
+
if _resource_limits is None:
|
| 99 |
+
_resource_limits = ResourceLimits.from_env()
|
| 100 |
+
return _resource_limits
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# Forbidden system directories that cannot be used as data directories
|
| 104 |
+
_FORBIDDEN_PATHS = frozenset(["/", "/etc", "/sys", "/dev", "/bin", "/sbin", "/usr", "/var", "/boot", "/proc"])
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _validate_data_dir(data_path: Path) -> None:
|
| 108 |
+
"""Validate that data directory is not a dangerous system path.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
data_path: Path to validate
|
| 112 |
+
|
| 113 |
+
Raises:
|
| 114 |
+
ValueError: If path is a forbidden system directory
|
| 115 |
+
"""
|
| 116 |
+
resolved = data_path.resolve()
|
| 117 |
+
resolved_str = str(resolved)
|
| 118 |
+
|
| 119 |
+
for forbidden in _FORBIDDEN_PATHS:
|
| 120 |
+
if resolved_str == forbidden or resolved_str.rstrip("/") == forbidden:
|
| 121 |
+
raise ValueError(f"ASPARA_DATA_DIR cannot be set to system directory: {forbidden}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def get_data_dir() -> Path:
|
| 125 |
+
"""Get the default data directory for Aspara.
|
| 126 |
+
|
| 127 |
+
Resolution priority:
|
| 128 |
+
1. ASPARA_DATA_DIR environment variable (if set)
|
| 129 |
+
2. XDG_DATA_HOME/aspara (if XDG_DATA_HOME is set)
|
| 130 |
+
3. ~/.local/share/aspara (fallback)
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Path object pointing to the data directory.
|
| 134 |
+
|
| 135 |
+
Raises:
|
| 136 |
+
ValueError: If ASPARA_DATA_DIR points to a system directory
|
| 137 |
+
|
| 138 |
+
Examples:
|
| 139 |
+
>>> # Using ASPARA_DATA_DIR
|
| 140 |
+
>>> os.environ["ASPARA_DATA_DIR"] = "/custom/path"
|
| 141 |
+
>>> get_data_dir()
|
| 142 |
+
Path('/custom/path')
|
| 143 |
+
|
| 144 |
+
>>> # Using XDG_DATA_HOME
|
| 145 |
+
>>> os.environ["XDG_DATA_HOME"] = "/home/user/.local/share"
|
| 146 |
+
>>> get_data_dir()
|
| 147 |
+
Path('/home/user/.local/share/aspara')
|
| 148 |
+
|
| 149 |
+
>>> # Using fallback
|
| 150 |
+
>>> get_data_dir()
|
| 151 |
+
Path('/home/user/.local/share/aspara')
|
| 152 |
+
"""
|
| 153 |
+
# Priority 1: ASPARA_DATA_DIR environment variable
|
| 154 |
+
aspara_data_dir = os.environ.get("ASPARA_DATA_DIR")
|
| 155 |
+
if aspara_data_dir:
|
| 156 |
+
data_path = Path(aspara_data_dir).expanduser().resolve()
|
| 157 |
+
_validate_data_dir(data_path)
|
| 158 |
+
return data_path
|
| 159 |
+
|
| 160 |
+
# Priority 2: XDG_DATA_HOME/aspara
|
| 161 |
+
xdg_data_home = os.environ.get("XDG_DATA_HOME")
|
| 162 |
+
if xdg_data_home:
|
| 163 |
+
return Path(xdg_data_home).expanduser() / "aspara"
|
| 164 |
+
|
| 165 |
+
# Priority 3: ~/.local/share/aspara (fallback)
|
| 166 |
+
return Path.home() / ".local" / "share" / "aspara"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def get_project_search_mode() -> str:
|
| 170 |
+
"""Get project search mode from environment variable.
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Project search mode ("realtime" or "manual"). Defaults to "realtime".
|
| 174 |
+
"""
|
| 175 |
+
mode = os.environ.get("ASPARA_PROJECT_SEARCH_MODE", "realtime")
|
| 176 |
+
if mode not in ("realtime", "manual"):
|
| 177 |
+
return "realtime"
|
| 178 |
+
return mode
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def get_storage_backend() -> str | None:
|
| 182 |
+
"""Get storage backend from environment variable.
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
Storage backend name if ASPARA_STORAGE_BACKEND is set, None otherwise.
|
| 186 |
+
"""
|
| 187 |
+
return os.environ.get("ASPARA_STORAGE_BACKEND")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def use_lttb_fast() -> bool:
|
| 191 |
+
"""Check if fast LTTB implementation should be used.
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
True if ASPARA_LTTB_FAST is set to "1", False otherwise.
|
| 195 |
+
"""
|
| 196 |
+
return os.environ.get("ASPARA_LTTB_FAST") == "1"
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def is_dev_mode() -> bool:
|
| 200 |
+
"""Check if running in development mode.
|
| 201 |
+
|
| 202 |
+
Returns:
|
| 203 |
+
True if ASPARA_DEV_MODE is set to "1", False otherwise.
|
| 204 |
+
"""
|
| 205 |
+
return os.environ.get("ASPARA_DEV_MODE") == "1"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def is_read_only() -> bool:
|
| 209 |
+
"""Check if running in read-only mode.
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
True if ASPARA_READ_ONLY is set to "1", False otherwise.
|
| 213 |
+
"""
|
| 214 |
+
return os.environ.get("ASPARA_READ_ONLY") == "1"
|
src/aspara/dashboard/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara metrics visualization dashboard package!
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
__version__ = "0.1.0"
|
src/aspara/dashboard/dependencies.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI dependency injection for Aspara Dashboard.
|
| 3 |
+
|
| 4 |
+
This module provides reusable dependencies for:
|
| 5 |
+
- Catalog instance management (ProjectCatalog, RunCatalog)
|
| 6 |
+
- Path parameter validation (project names, run names)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Annotated
|
| 14 |
+
|
| 15 |
+
from fastapi import Depends, HTTPException
|
| 16 |
+
from fastapi import Path as PathParam
|
| 17 |
+
|
| 18 |
+
from aspara.catalog import ProjectCatalog, RunCatalog
|
| 19 |
+
from aspara.config import get_data_dir
|
| 20 |
+
from aspara.utils import validators
|
| 21 |
+
|
| 22 |
+
# Mutable container for custom data directory configuration
|
| 23 |
+
_custom_data_dir: list[str | None] = [None]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _get_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
|
| 27 |
+
"""Get or create catalog instances.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Tuple of (ProjectCatalog, RunCatalog, data_dir Path)
|
| 31 |
+
"""
|
| 32 |
+
if _custom_data_dir[0] is not None:
|
| 33 |
+
data_dir = Path(_custom_data_dir[0])
|
| 34 |
+
else:
|
| 35 |
+
data_dir = Path(get_data_dir())
|
| 36 |
+
return ProjectCatalog(str(data_dir)), RunCatalog(str(data_dir)), data_dir
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Cached version for performance
|
| 40 |
+
@lru_cache(maxsize=1)
|
| 41 |
+
def _get_cached_catalogs() -> tuple[ProjectCatalog, RunCatalog, Path]:
|
| 42 |
+
"""Get cached catalog instances."""
|
| 43 |
+
return _get_catalogs()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_project_catalog() -> ProjectCatalog:
|
| 47 |
+
"""Get the ProjectCatalog singleton instance."""
|
| 48 |
+
return _get_cached_catalogs()[0]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_run_catalog() -> RunCatalog:
|
| 52 |
+
"""Get the RunCatalog singleton instance."""
|
| 53 |
+
return _get_cached_catalogs()[1]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def get_data_dir_path() -> Path:
|
| 57 |
+
"""Get the data directory path."""
|
| 58 |
+
return _get_cached_catalogs()[2]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def configure_data_dir(data_dir: str | None = None) -> None:
|
| 62 |
+
"""Configure data directory and reinitialize catalogs.
|
| 63 |
+
|
| 64 |
+
This function clears the cached catalogs and reinitializes them
|
| 65 |
+
with the specified data directory.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
data_dir: Custom data directory path. If None, uses default.
|
| 69 |
+
"""
|
| 70 |
+
# Clear the cache to force reinitialization
|
| 71 |
+
_get_cached_catalogs.cache_clear()
|
| 72 |
+
|
| 73 |
+
# Set custom data directory
|
| 74 |
+
_custom_data_dir[0] = data_dir
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_validated_project(project: Annotated[str, PathParam(description="Project name")]) -> str:
|
| 78 |
+
"""Validate project name path parameter.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
project: Project name from URL path.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
Validated project name.
|
| 85 |
+
|
| 86 |
+
Raises:
|
| 87 |
+
HTTPException: 400 if project name is invalid.
|
| 88 |
+
"""
|
| 89 |
+
try:
|
| 90 |
+
validators.validate_project_name(project)
|
| 91 |
+
except ValueError as e:
|
| 92 |
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
| 93 |
+
return project
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_validated_run(run: Annotated[str, PathParam(description="Run name")]) -> str:
|
| 97 |
+
"""Validate run name path parameter.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
run: Run name from URL path.
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Validated run name.
|
| 104 |
+
|
| 105 |
+
Raises:
|
| 106 |
+
HTTPException: 400 if run name is invalid.
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
validators.validate_run_name(run)
|
| 110 |
+
except ValueError as e:
|
| 111 |
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
| 112 |
+
return run
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# Type aliases for dependency injection
|
| 116 |
+
ValidatedProject = Annotated[str, Depends(get_validated_project)]
|
| 117 |
+
ValidatedRun = Annotated[str, Depends(get_validated_run)]
|
| 118 |
+
ProjectCatalogDep = Annotated[ProjectCatalog, Depends(get_project_catalog)]
|
| 119 |
+
RunCatalogDep = Annotated[RunCatalog, Depends(get_run_catalog)]
|
| 120 |
+
DataDirDep = Annotated[Path, Depends(get_data_dir_path)]
|
src/aspara/dashboard/main.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application for Aspara Dashboard
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import contextlib
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, Request
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.staticfiles import StaticFiles
|
| 15 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 16 |
+
from starlette.responses import Response
|
| 17 |
+
|
| 18 |
+
from aspara.config import is_dev_mode
|
| 19 |
+
|
| 20 |
+
from .router import router
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Global state for SSE connection management
|
| 26 |
+
class AppState:
|
| 27 |
+
"""Application state for managing SSE connections during shutdown."""
|
| 28 |
+
|
| 29 |
+
def __init__(self) -> None:
|
| 30 |
+
self.active_sse_connections: set[asyncio.Queue] = set()
|
| 31 |
+
self.active_sse_tasks: set[asyncio.Task] = set()
|
| 32 |
+
self.shutting_down = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
app_state = AppState()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
| 39 |
+
"""Middleware to add security headers to all responses."""
|
| 40 |
+
|
| 41 |
+
async def dispatch(self, request: Request, call_next) -> Response:
|
| 42 |
+
response = await call_next(request)
|
| 43 |
+
|
| 44 |
+
# Prevent MIME type sniffing
|
| 45 |
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
| 46 |
+
|
| 47 |
+
# Prevent clickjacking by denying framing
|
| 48 |
+
response.headers["X-Frame-Options"] = "DENY"
|
| 49 |
+
|
| 50 |
+
# Enable XSS filter in browsers (legacy but still useful)
|
| 51 |
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 52 |
+
|
| 53 |
+
# Content Security Policy - basic policy
|
| 54 |
+
# Allows self-origin scripts/styles, inline styles for chart libraries,
|
| 55 |
+
# and data: URIs for images (used by chart exports)
|
| 56 |
+
response.headers["Content-Security-Policy"] = (
|
| 57 |
+
"default-src 'self'; "
|
| 58 |
+
"script-src 'self'; "
|
| 59 |
+
"style-src 'self' 'unsafe-inline'; "
|
| 60 |
+
"img-src 'self' data:; "
|
| 61 |
+
"font-src 'self'; "
|
| 62 |
+
"connect-src 'self'; "
|
| 63 |
+
"frame-ancestors 'none'"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Allow iframe embedding when ASPARA_ALLOW_IFRAME=1 (e.g., HF Spaces)
|
| 67 |
+
if os.environ.get("ASPARA_ALLOW_IFRAME") == "1":
|
| 68 |
+
del response.headers["X-Frame-Options"]
|
| 69 |
+
response.headers["Content-Security-Policy"] = (
|
| 70 |
+
"default-src 'self'; "
|
| 71 |
+
"script-src 'self'; "
|
| 72 |
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
| 73 |
+
"img-src 'self' data:; "
|
| 74 |
+
"font-src 'self' https://fonts.gstatic.com; "
|
| 75 |
+
"connect-src 'self'; "
|
| 76 |
+
"frame-ancestors https://huggingface.co https://*.hf.space"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
return response
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@asynccontextmanager
|
| 83 |
+
async def lifespan(app: FastAPI):
|
| 84 |
+
"""Manage application lifecycle.
|
| 85 |
+
|
| 86 |
+
On shutdown, signal all active SSE connections to close gracefully.
|
| 87 |
+
In development mode, forcefully cancel SSE tasks for fast restart.
|
| 88 |
+
"""
|
| 89 |
+
# Startup
|
| 90 |
+
yield
|
| 91 |
+
|
| 92 |
+
# Shutdown
|
| 93 |
+
app_state.shutting_down = True
|
| 94 |
+
|
| 95 |
+
# Signal all active SSE connections to stop
|
| 96 |
+
for queue in list(app_state.active_sse_connections):
|
| 97 |
+
# Queue might already be closed or event loop shutting down
|
| 98 |
+
with contextlib.suppress(RuntimeError, OSError):
|
| 99 |
+
await queue.put(None) # Sentinel value to signal shutdown
|
| 100 |
+
|
| 101 |
+
if is_dev_mode():
|
| 102 |
+
# Development mode: forcefully cancel SSE tasks for fast restart
|
| 103 |
+
logger.info(f"[DEV MODE] Cancelling {len(app_state.active_sse_tasks)} active SSE tasks")
|
| 104 |
+
for task in list(app_state.active_sse_tasks):
|
| 105 |
+
task.cancel()
|
| 106 |
+
|
| 107 |
+
# Wait briefly for tasks to be cancelled
|
| 108 |
+
if app_state.active_sse_tasks:
|
| 109 |
+
with contextlib.suppress(asyncio.TimeoutError):
|
| 110 |
+
await asyncio.wait_for(
|
| 111 |
+
asyncio.gather(*app_state.active_sse_tasks, return_exceptions=True),
|
| 112 |
+
timeout=0.1,
|
| 113 |
+
)
|
| 114 |
+
logger.info("[DEV MODE] SSE tasks cancelled, shutdown complete")
|
| 115 |
+
else:
|
| 116 |
+
# Production mode: graceful shutdown with 30 second timeout
|
| 117 |
+
await asyncio.sleep(0.5)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
app = FastAPI(
|
| 121 |
+
title="Aspara Dashboard",
|
| 122 |
+
description="Real-time metrics visualization for machine learning experiments",
|
| 123 |
+
docs_url="/docs/dashboard", # /docs/dashboard としてアクセスできるようにする
|
| 124 |
+
redoc_url=None, # ReDocは使わない
|
| 125 |
+
lifespan=lifespan,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Security headers middleware
|
| 129 |
+
app.add_middleware(SecurityHeadersMiddleware) # ty: ignore[invalid-argument-type]
|
| 130 |
+
|
| 131 |
+
# CORS middleware - credentials disabled for security with wildcard origins
|
| 132 |
+
# Note: allow_credentials=True with allow_origins=["*"] is a security vulnerability
|
| 133 |
+
# as it allows any site to make credentialed requests to our API
|
| 134 |
+
app.add_middleware(
|
| 135 |
+
CORSMiddleware, # ty: ignore[invalid-argument-type]
|
| 136 |
+
allow_origins=["*"],
|
| 137 |
+
allow_credentials=False,
|
| 138 |
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
| 139 |
+
allow_headers=["Content-Type", "X-Requested-With"],
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
BASE_DIR = Path(__file__).parent
|
| 143 |
+
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
| 144 |
+
|
| 145 |
+
app.include_router(router)
|
src/aspara/dashboard/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara dashboard data model definitions!
|
| 3 |
+
"""
|
src/aspara/dashboard/models/metrics.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Models for metrics data.
|
| 3 |
+
|
| 4 |
+
This module defines the data models for the dashboard API.
|
| 5 |
+
Note: experiment concept has been removed - data structure is now project/run.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
|
| 12 |
+
from aspara.catalog.project_catalog import ProjectInfo
|
| 13 |
+
from aspara.catalog.run_catalog import RunInfo
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"Metadata",
|
| 17 |
+
"MetadataUpdateRequest",
|
| 18 |
+
"MetricSeries",
|
| 19 |
+
"ProjectInfo",
|
| 20 |
+
"RunInfo",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class Metadata(BaseModel):
|
| 25 |
+
"""Metadata for projects and runs."""
|
| 26 |
+
|
| 27 |
+
notes: str = ""
|
| 28 |
+
tags: list[str] = []
|
| 29 |
+
created_at: datetime | None = None
|
| 30 |
+
updated_at: datetime | None = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class MetadataUpdateRequest(BaseModel):
|
| 34 |
+
"""Request model for updating metadata."""
|
| 35 |
+
|
| 36 |
+
notes: str | None = None
|
| 37 |
+
tags: list[str] | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class MetricSeries(BaseModel):
|
| 41 |
+
"""A single metric time series with steps, values, and timestamps.
|
| 42 |
+
|
| 43 |
+
Used in the metrics API response to represent one metric's data.
|
| 44 |
+
Arrays are delta-compressed where applicable.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
steps: list[int | float]
|
| 48 |
+
values: list[int | float]
|
| 49 |
+
timestamps: list[int | float]
|
src/aspara/dashboard/router.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara Dashboard APIRouter aggregation.
|
| 3 |
+
|
| 4 |
+
This module aggregates all route handlers from sub-modules:
|
| 5 |
+
- html_routes: HTML page endpoints
|
| 6 |
+
- api_routes: REST API endpoints
|
| 7 |
+
- sse_routes: Server-Sent Events streaming endpoints
|
| 8 |
+
|
| 9 |
+
Note: experiment concept has been removed - URL structure is now /projects/{project}/runs/{run}
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter
|
| 15 |
+
|
| 16 |
+
# Re-export configure_data_dir for backwards compatibility
|
| 17 |
+
from .dependencies import configure_data_dir
|
| 18 |
+
from .routes import api_router, html_router, sse_router
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
router.include_router(html_router)
|
| 22 |
+
router.include_router(api_router)
|
| 23 |
+
router.include_router(sse_router)
|
| 24 |
+
|
| 25 |
+
__all__ = ["router", "configure_data_dir"]
|
src/aspara/dashboard/routes/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara Dashboard routes.
|
| 3 |
+
|
| 4 |
+
This package contains route handlers organized by type:
|
| 5 |
+
- html_routes: HTML page endpoints
|
| 6 |
+
- api_routes: REST API endpoints
|
| 7 |
+
- sse_routes: Server-Sent Events streaming endpoints
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from .api_routes import router as api_router
|
| 11 |
+
from .html_routes import router as html_router
|
| 12 |
+
from .sse_routes import router as sse_router
|
| 13 |
+
|
| 14 |
+
__all__ = ["html_router", "api_router", "sse_router"]
|
src/aspara/dashboard/routes/api_routes.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
REST API routes for Aspara Dashboard.
|
| 3 |
+
|
| 4 |
+
This module handles all REST API endpoints:
|
| 5 |
+
- Artifacts download API
|
| 6 |
+
- Bulk metrics API
|
| 7 |
+
- Project/Run metadata APIs
|
| 8 |
+
- Delete APIs
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import io
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import zipfile
|
| 18 |
+
from collections import defaultdict
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
import msgpack
|
| 23 |
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
| 24 |
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
| 25 |
+
|
| 26 |
+
from aspara.config import get_resource_limits, is_read_only
|
| 27 |
+
from aspara.exceptions import ProjectNotFoundError, RunNotFoundError
|
| 28 |
+
from aspara.utils import validators
|
| 29 |
+
|
| 30 |
+
from ..dependencies import (
|
| 31 |
+
DataDirDep,
|
| 32 |
+
ProjectCatalogDep,
|
| 33 |
+
RunCatalogDep,
|
| 34 |
+
ValidatedProject,
|
| 35 |
+
ValidatedRun,
|
| 36 |
+
)
|
| 37 |
+
from ..models.metrics import Metadata, MetadataUpdateRequest
|
| 38 |
+
from ..utils.compression import compress_metrics
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def verify_csrf_header(x_requested_with: str | None = Header(None, alias="X-Requested-With")) -> None:
|
| 42 |
+
"""CSRF protection via custom header check.
|
| 43 |
+
|
| 44 |
+
Verifies that requests include the X-Requested-With header, which cannot be set
|
| 45 |
+
by cross-origin requests without CORS preflight. This prevents CSRF attacks.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
x_requested_with: The X-Requested-With header value
|
| 49 |
+
|
| 50 |
+
Raises:
|
| 51 |
+
HTTPException: 403 if header is missing
|
| 52 |
+
"""
|
| 53 |
+
if x_requested_with is None:
|
| 54 |
+
raise HTTPException(status_code=403, detail="Missing X-Requested-With header")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
logger = logging.getLogger(__name__)
|
| 58 |
+
|
| 59 |
+
router = APIRouter()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.get("/api/projects/{project}/runs/{run}/artifacts/download")
|
| 63 |
+
async def download_artifacts_zip(
|
| 64 |
+
project: ValidatedProject,
|
| 65 |
+
run: ValidatedRun,
|
| 66 |
+
data_dir: DataDirDep,
|
| 67 |
+
) -> StreamingResponse:
|
| 68 |
+
"""Download all artifacts for a run as a ZIP file.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
project: Project name.
|
| 72 |
+
run: Run name.
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
StreamingResponse with ZIP file containing all artifacts.
|
| 76 |
+
Filename format: `{project}_{run}_artifacts_{timestamp}.zip`
|
| 77 |
+
|
| 78 |
+
Raises:
|
| 79 |
+
HTTPException: 400 if project/run name is invalid or total size exceeds limit,
|
| 80 |
+
404 if no artifacts found.
|
| 81 |
+
"""
|
| 82 |
+
# Get the artifacts directory path
|
| 83 |
+
artifacts_dir = data_dir / project / run / "artifacts"
|
| 84 |
+
|
| 85 |
+
# Validate path to prevent path traversal
|
| 86 |
+
try:
|
| 87 |
+
validators.validate_safe_path(artifacts_dir, data_dir)
|
| 88 |
+
except ValueError as e:
|
| 89 |
+
raise HTTPException(status_code=400, detail=f"Invalid artifacts directory path: {e}") from None
|
| 90 |
+
|
| 91 |
+
artifacts_dir_str = str(artifacts_dir)
|
| 92 |
+
|
| 93 |
+
if not os.path.exists(artifacts_dir_str):
|
| 94 |
+
raise HTTPException(status_code=404, detail="No artifacts found for this run")
|
| 95 |
+
|
| 96 |
+
# Single-pass: collect file info using scandir (caches stat results)
|
| 97 |
+
artifact_entries: list[tuple[str, str, int]] = [] # (name, path, size)
|
| 98 |
+
total_size = 0
|
| 99 |
+
|
| 100 |
+
with os.scandir(artifacts_dir_str) as entries:
|
| 101 |
+
for entry in entries:
|
| 102 |
+
if entry.is_file():
|
| 103 |
+
size = entry.stat().st_size # Uses cached stat
|
| 104 |
+
artifact_entries.append((entry.name, entry.path, size))
|
| 105 |
+
total_size += size
|
| 106 |
+
|
| 107 |
+
if not artifact_entries:
|
| 108 |
+
raise HTTPException(status_code=404, detail="No artifact files found")
|
| 109 |
+
|
| 110 |
+
# Check total size
|
| 111 |
+
limits = get_resource_limits()
|
| 112 |
+
if total_size > limits.max_zip_size:
|
| 113 |
+
raise HTTPException(
|
| 114 |
+
status_code=400,
|
| 115 |
+
detail=(f"Total artifacts size ({total_size} bytes) exceeds maximum ZIP size limit ({limits.max_zip_size} bytes)"),
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Create ZIP file in memory
|
| 119 |
+
zip_buffer = io.BytesIO()
|
| 120 |
+
|
| 121 |
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
| 122 |
+
for filename, file_path, _ in artifact_entries:
|
| 123 |
+
# Add file to zip with just the filename (no directory structure)
|
| 124 |
+
zip_file.write(file_path, filename)
|
| 125 |
+
|
| 126 |
+
zip_buffer.seek(0)
|
| 127 |
+
|
| 128 |
+
# Generate filename with timestamp
|
| 129 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 130 |
+
zip_filename = f"{project}_{run}_artifacts_{timestamp}.zip"
|
| 131 |
+
|
| 132 |
+
# Return as streaming response
|
| 133 |
+
# Encode filename for Content-Disposition header to prevent header injection
|
| 134 |
+
# Use RFC 5987 encoding for non-ASCII characters
|
| 135 |
+
import urllib.parse
|
| 136 |
+
|
| 137 |
+
encoded_filename = urllib.parse.quote(zip_filename, safe="")
|
| 138 |
+
return StreamingResponse(
|
| 139 |
+
io.BytesIO(zip_buffer.read()),
|
| 140 |
+
media_type="application/zip",
|
| 141 |
+
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.get("/api/projects/{project}/runs/metrics")
|
| 146 |
+
async def runs_metrics_api(
|
| 147 |
+
project: ValidatedProject,
|
| 148 |
+
run_catalog: RunCatalogDep,
|
| 149 |
+
runs: str,
|
| 150 |
+
format: str = "json",
|
| 151 |
+
since: int | None = Query(
|
| 152 |
+
default=None,
|
| 153 |
+
description="Filter metrics since this UNIX timestamp in milliseconds",
|
| 154 |
+
),
|
| 155 |
+
) -> Response:
|
| 156 |
+
"""Get metrics for multiple runs in a single request.
|
| 157 |
+
|
| 158 |
+
Useful for comparing metrics across runs. Returns data in metric-first structure
|
| 159 |
+
where each metric contains data from all requested runs.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
project: Project name.
|
| 163 |
+
runs: Comma-separated list of run names (e.g., "run1,run2,run3").
|
| 164 |
+
format: Response format - "json" (default) or "msgpack".
|
| 165 |
+
since: Optional filter to only return metrics with timestamp >= since (UNIX ms).
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
Response with structure: `{"project": str, "metrics": {metric: {run: {...}}}}`
|
| 169 |
+
- For "json" format: JSONResponse
|
| 170 |
+
- For "msgpack" format: Response with application/x-msgpack content type
|
| 171 |
+
|
| 172 |
+
Raises:
|
| 173 |
+
HTTPException: 400 if project name is invalid, format is invalid,
|
| 174 |
+
or too many runs specified.
|
| 175 |
+
"""
|
| 176 |
+
# Validate format parameter
|
| 177 |
+
if format not in ("json", "msgpack"):
|
| 178 |
+
raise HTTPException(
|
| 179 |
+
status_code=400,
|
| 180 |
+
detail=f"Invalid format: {format}. Must be 'json' or 'msgpack'",
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
if not runs:
|
| 184 |
+
if format == "msgpack":
|
| 185 |
+
raise HTTPException(status_code=400, detail="No runs specified")
|
| 186 |
+
return JSONResponse(content={"error": "No runs specified"})
|
| 187 |
+
|
| 188 |
+
run_list = [r.strip() for r in runs.split(",") if r.strip()]
|
| 189 |
+
if not run_list:
|
| 190 |
+
if format == "msgpack":
|
| 191 |
+
raise HTTPException(status_code=400, detail="No valid runs specified")
|
| 192 |
+
return JSONResponse(content={"error": "No valid runs specified"})
|
| 193 |
+
|
| 194 |
+
# Validate number of runs
|
| 195 |
+
limits = get_resource_limits()
|
| 196 |
+
if len(run_list) > limits.max_metric_names: # Reuse max_metric_names limit for runs
|
| 197 |
+
raise HTTPException(
|
| 198 |
+
status_code=400,
|
| 199 |
+
detail=f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Validate each run name
|
| 203 |
+
for run_name in run_list:
|
| 204 |
+
try:
|
| 205 |
+
validators.validate_run_name(run_name)
|
| 206 |
+
except ValueError as e:
|
| 207 |
+
raise HTTPException(status_code=400, detail=str(e)) from None
|
| 208 |
+
|
| 209 |
+
# Convert since (UNIX ms) to datetime if provided
|
| 210 |
+
# Create timezone-naive datetime (matches DataFrame storage)
|
| 211 |
+
since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc).replace(tzinfo=None) if since is not None else None
|
| 212 |
+
|
| 213 |
+
# Load and downsample metrics for all runs in parallel
|
| 214 |
+
async def load_and_downsample(
|
| 215 |
+
run_name: str,
|
| 216 |
+
) -> tuple[str, dict[str, dict[str, list]] | None]:
|
| 217 |
+
"""Load and downsample metrics for a single run."""
|
| 218 |
+
try:
|
| 219 |
+
df = await asyncio.to_thread(run_catalog.load_metrics, project, run_name, since_dt)
|
| 220 |
+
return (run_name, compress_metrics(df))
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.warning(f"Failed to load metrics for {project}/{run_name}: {e}")
|
| 223 |
+
return (run_name, None)
|
| 224 |
+
|
| 225 |
+
# Execute all loads in parallel
|
| 226 |
+
results = await asyncio.gather(*[load_and_downsample(run_name) for run_name in run_list])
|
| 227 |
+
|
| 228 |
+
# Build metrics_by_run from results
|
| 229 |
+
metrics_by_run: dict[str, dict[str, dict[str, list]]] = {}
|
| 230 |
+
for run_name, metrics in results:
|
| 231 |
+
if metrics is not None:
|
| 232 |
+
metrics_by_run[run_name] = metrics
|
| 233 |
+
|
| 234 |
+
# Reorganize to metric-first structure using defaultdict for O(1) key insertion
|
| 235 |
+
metrics_data: dict[str, dict[str, dict[str, list]]] = defaultdict(dict)
|
| 236 |
+
for run_name, run_metrics in metrics_by_run.items():
|
| 237 |
+
for metric_name, metric_arrays in run_metrics.items():
|
| 238 |
+
metrics_data[metric_name][run_name] = metric_arrays
|
| 239 |
+
|
| 240 |
+
response_data = {"project": project, "metrics": metrics_data}
|
| 241 |
+
|
| 242 |
+
# Return response based on format
|
| 243 |
+
if format == "msgpack":
|
| 244 |
+
# Serialize to MessagePack
|
| 245 |
+
packed_data = msgpack.packb(response_data, use_single_float=True)
|
| 246 |
+
return Response(content=packed_data, media_type="application/x-msgpack")
|
| 247 |
+
|
| 248 |
+
return JSONResponse(content=response_data)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@router.get("/api/projects/{project}/metadata")
|
| 252 |
+
async def get_project_metadata_api(
|
| 253 |
+
project: ValidatedProject,
|
| 254 |
+
project_catalog: ProjectCatalogDep,
|
| 255 |
+
) -> Metadata:
|
| 256 |
+
"""Get project metadata.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
project: Project name.
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
Metadata object containing project metadata (tags, notes, etc.).
|
| 263 |
+
|
| 264 |
+
Raises:
|
| 265 |
+
HTTPException: 400 if project name is invalid.
|
| 266 |
+
"""
|
| 267 |
+
# Use ProjectCatalog metadata API (synchronous call inside async endpoint)
|
| 268 |
+
metadata = project_catalog.get_metadata(project)
|
| 269 |
+
return Metadata.model_validate(metadata)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@router.put("/api/projects/{project}/metadata")
|
| 273 |
+
async def update_project_metadata_api(
|
| 274 |
+
project: ValidatedProject,
|
| 275 |
+
metadata: MetadataUpdateRequest,
|
| 276 |
+
project_catalog: ProjectCatalogDep,
|
| 277 |
+
_csrf: None = Depends(verify_csrf_header),
|
| 278 |
+
) -> Metadata:
|
| 279 |
+
"""Update project metadata.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
project: Project name.
|
| 283 |
+
metadata: MetadataUpdateRequest containing fields to update.
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Metadata object containing the updated project metadata.
|
| 287 |
+
|
| 288 |
+
Raises:
|
| 289 |
+
HTTPException: 400 if project name is invalid.
|
| 290 |
+
"""
|
| 291 |
+
if is_read_only():
|
| 292 |
+
existing = project_catalog.get_metadata(project)
|
| 293 |
+
return Metadata.model_validate(existing)
|
| 294 |
+
|
| 295 |
+
update_data = metadata.model_dump(exclude_none=True)
|
| 296 |
+
|
| 297 |
+
# Use ProjectCatalog metadata API
|
| 298 |
+
updated_metadata = project_catalog.update_metadata(project, update_data)
|
| 299 |
+
return Metadata.model_validate(updated_metadata)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@router.delete("/api/projects/{project}")
|
| 303 |
+
async def delete_project(
|
| 304 |
+
project: ValidatedProject,
|
| 305 |
+
project_catalog: ProjectCatalogDep,
|
| 306 |
+
_csrf: None = Depends(verify_csrf_header),
|
| 307 |
+
) -> Response:
|
| 308 |
+
"""Delete a project and all its runs.
|
| 309 |
+
|
| 310 |
+
**Warning**: This operation is irreversible.
|
| 311 |
+
|
| 312 |
+
Args:
|
| 313 |
+
project: Project name.
|
| 314 |
+
|
| 315 |
+
Returns:
|
| 316 |
+
204 No Content on success.
|
| 317 |
+
|
| 318 |
+
Raises:
|
| 319 |
+
HTTPException: 400 if project name is invalid, 403 if permission denied,
|
| 320 |
+
404 if project not found, 500 for unexpected errors.
|
| 321 |
+
"""
|
| 322 |
+
if is_read_only():
|
| 323 |
+
return Response(status_code=204)
|
| 324 |
+
|
| 325 |
+
try:
|
| 326 |
+
project_catalog.delete(project)
|
| 327 |
+
logger.info(f"Deleted project: {project}")
|
| 328 |
+
return Response(status_code=204)
|
| 329 |
+
except ProjectNotFoundError as e:
|
| 330 |
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
| 331 |
+
except PermissionError as e:
|
| 332 |
+
logger.warning(f"Permission denied deleting project {project}: {e}")
|
| 333 |
+
raise HTTPException(status_code=403, detail="Permission denied") from e
|
| 334 |
+
except Exception as e:
|
| 335 |
+
logger.error(f"Error deleting project {project}: {e}")
|
| 336 |
+
raise HTTPException(status_code=500, detail="Failed to delete project") from e
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
@router.get("/api/projects/{project}/runs/{run}/metadata")
|
| 340 |
+
async def get_run_metadata_api(
|
| 341 |
+
project: ValidatedProject,
|
| 342 |
+
run: ValidatedRun,
|
| 343 |
+
run_catalog: RunCatalogDep,
|
| 344 |
+
) -> dict[str, Any]:
|
| 345 |
+
"""Get run metadata.
|
| 346 |
+
|
| 347 |
+
Args:
|
| 348 |
+
project: Project name.
|
| 349 |
+
run: Run name.
|
| 350 |
+
|
| 351 |
+
Returns:
|
| 352 |
+
Dictionary containing run metadata (tags, notes, params, etc.).
|
| 353 |
+
|
| 354 |
+
Raises:
|
| 355 |
+
HTTPException: 400 if project/run name is invalid.
|
| 356 |
+
"""
|
| 357 |
+
# Use RunCatalog metadata API
|
| 358 |
+
metadata = run_catalog.get_metadata(project, run)
|
| 359 |
+
return metadata
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@router.put("/api/projects/{project}/runs/{run}/metadata")
|
| 363 |
+
async def update_run_metadata_api(
|
| 364 |
+
project: ValidatedProject,
|
| 365 |
+
run: ValidatedRun,
|
| 366 |
+
metadata: MetadataUpdateRequest,
|
| 367 |
+
run_catalog: RunCatalogDep,
|
| 368 |
+
_csrf: None = Depends(verify_csrf_header),
|
| 369 |
+
) -> dict[str, Any]:
|
| 370 |
+
"""Update run metadata.
|
| 371 |
+
|
| 372 |
+
Args:
|
| 373 |
+
project: Project name.
|
| 374 |
+
run: Run name.
|
| 375 |
+
metadata: MetadataUpdateRequest containing fields to update.
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
Dictionary containing the updated run metadata.
|
| 379 |
+
|
| 380 |
+
Raises:
|
| 381 |
+
HTTPException: 400 if project/run name is invalid.
|
| 382 |
+
"""
|
| 383 |
+
if is_read_only():
|
| 384 |
+
existing = run_catalog.get_metadata(project, run)
|
| 385 |
+
return existing
|
| 386 |
+
|
| 387 |
+
update_data = metadata.model_dump(exclude_none=True)
|
| 388 |
+
|
| 389 |
+
# Use RunCatalog metadata API
|
| 390 |
+
updated_metadata = run_catalog.update_metadata(project, run, update_data)
|
| 391 |
+
return updated_metadata
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
@router.delete("/api/projects/{project}/runs/{run}")
|
| 395 |
+
async def delete_run(
|
| 396 |
+
project: ValidatedProject,
|
| 397 |
+
run: ValidatedRun,
|
| 398 |
+
run_catalog: RunCatalogDep,
|
| 399 |
+
_csrf: None = Depends(verify_csrf_header),
|
| 400 |
+
) -> Response:
|
| 401 |
+
"""Delete a run and its artifacts.
|
| 402 |
+
|
| 403 |
+
**Warning**: This operation is irreversible.
|
| 404 |
+
|
| 405 |
+
Args:
|
| 406 |
+
project: Project name.
|
| 407 |
+
run: Run name.
|
| 408 |
+
|
| 409 |
+
Returns:
|
| 410 |
+
204 No Content on success.
|
| 411 |
+
|
| 412 |
+
Raises:
|
| 413 |
+
HTTPException: 400 if project/run name is invalid, 403 if permission denied,
|
| 414 |
+
404 if project or run not found, 500 for unexpected errors.
|
| 415 |
+
"""
|
| 416 |
+
if is_read_only():
|
| 417 |
+
return Response(status_code=204)
|
| 418 |
+
|
| 419 |
+
try:
|
| 420 |
+
run_catalog.delete(project, run)
|
| 421 |
+
logger.info(f"Deleted run: {project}/{run}")
|
| 422 |
+
return Response(status_code=204)
|
| 423 |
+
except ProjectNotFoundError as e:
|
| 424 |
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
| 425 |
+
except RunNotFoundError as e:
|
| 426 |
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
| 427 |
+
except PermissionError as e:
|
| 428 |
+
logger.warning(f"Permission denied deleting run {project}/{run}: {e}")
|
| 429 |
+
raise HTTPException(status_code=403, detail="Permission denied") from e
|
| 430 |
+
except Exception as e:
|
| 431 |
+
logger.error(f"Error deleting run {project}/{run}: {e}")
|
| 432 |
+
raise HTTPException(status_code=500, detail="Failed to delete run") from e
|
src/aspara/dashboard/routes/html_routes.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HTML page routes for Aspara Dashboard.
|
| 3 |
+
|
| 4 |
+
This module handles all HTML page rendering endpoints:
|
| 5 |
+
- Home page (projects list)
|
| 6 |
+
- Project detail page
|
| 7 |
+
- Runs list page
|
| 8 |
+
- Run detail page
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, HTTPException
|
| 18 |
+
from fastapi.responses import HTMLResponse
|
| 19 |
+
from starlette.requests import Request
|
| 20 |
+
|
| 21 |
+
from aspara.config import is_read_only
|
| 22 |
+
from aspara.exceptions import RunNotFoundError
|
| 23 |
+
|
| 24 |
+
from ..dependencies import (
|
| 25 |
+
ProjectCatalogDep,
|
| 26 |
+
RunCatalogDep,
|
| 27 |
+
ValidatedProject,
|
| 28 |
+
ValidatedRun,
|
| 29 |
+
)
|
| 30 |
+
from ..services.template_service import (
|
| 31 |
+
TemplateService,
|
| 32 |
+
create_breadcrumbs,
|
| 33 |
+
render_mustache_response,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
router = APIRouter()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("/")
|
| 40 |
+
async def home(
|
| 41 |
+
request: Request,
|
| 42 |
+
project_catalog: ProjectCatalogDep,
|
| 43 |
+
) -> HTMLResponse:
|
| 44 |
+
"""Render the projects list page."""
|
| 45 |
+
projects = project_catalog.get_projects()
|
| 46 |
+
|
| 47 |
+
# Format projects for template, including metadata tags
|
| 48 |
+
formatted_projects = []
|
| 49 |
+
for project in projects:
|
| 50 |
+
metadata = project_catalog.get_metadata(project.name)
|
| 51 |
+
tags = metadata.get("tags") or []
|
| 52 |
+
formatted_projects.append(TemplateService.format_project_for_template(project, tags))
|
| 53 |
+
|
| 54 |
+
from aspara.config import get_project_search_mode
|
| 55 |
+
|
| 56 |
+
project_search_mode = get_project_search_mode()
|
| 57 |
+
|
| 58 |
+
context = {
|
| 59 |
+
"page_title": "Aspara",
|
| 60 |
+
"breadcrumbs": create_breadcrumbs([{"label": "Home", "is_home": True}]),
|
| 61 |
+
"projects": formatted_projects,
|
| 62 |
+
"has_projects": len(formatted_projects) > 0,
|
| 63 |
+
"project_search_mode": project_search_mode,
|
| 64 |
+
"read_only": is_read_only(),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
html = render_mustache_response("projects_list", context)
|
| 68 |
+
return HTMLResponse(content=html)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/projects/{project}")
|
| 72 |
+
async def project_detail(
|
| 73 |
+
request: Request,
|
| 74 |
+
project: ValidatedProject,
|
| 75 |
+
project_catalog: ProjectCatalogDep,
|
| 76 |
+
run_catalog: RunCatalogDep,
|
| 77 |
+
) -> HTMLResponse:
|
| 78 |
+
"""Project detail page - shows metrics charts."""
|
| 79 |
+
# Check if project exists
|
| 80 |
+
if not project_catalog.exists(project):
|
| 81 |
+
raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
|
| 82 |
+
|
| 83 |
+
runs = run_catalog.get_runs(project)
|
| 84 |
+
|
| 85 |
+
# Format runs for template (excluding corrupted runs)
|
| 86 |
+
formatted_runs = []
|
| 87 |
+
for run in runs:
|
| 88 |
+
formatted = TemplateService.format_run_for_project_detail(run)
|
| 89 |
+
if formatted is not None:
|
| 90 |
+
formatted_runs.append(formatted)
|
| 91 |
+
|
| 92 |
+
# Find the most recent last_update from all runs
|
| 93 |
+
project_last_update = None
|
| 94 |
+
if runs:
|
| 95 |
+
last_updates = [r.last_update for r in runs if r.last_update is not None]
|
| 96 |
+
if last_updates:
|
| 97 |
+
project_last_update = max(last_updates)
|
| 98 |
+
|
| 99 |
+
context = {
|
| 100 |
+
"page_title": f"{project} - Metrics",
|
| 101 |
+
"breadcrumbs": create_breadcrumbs([
|
| 102 |
+
{"label": "Home", "url": "/", "is_home": True},
|
| 103 |
+
{"label": project},
|
| 104 |
+
]),
|
| 105 |
+
"project": project,
|
| 106 |
+
"runs": formatted_runs,
|
| 107 |
+
"has_runs": len(formatted_runs) > 0,
|
| 108 |
+
"run_count": len(formatted_runs),
|
| 109 |
+
"formatted_project_last_update": (project_last_update.strftime("%b %d, %Y at %I:%M %p") if project_last_update else "N/A"),
|
| 110 |
+
"read_only": is_read_only(),
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
html = render_mustache_response("project_detail", context)
|
| 114 |
+
return HTMLResponse(content=html)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@router.get("/projects/{project}/runs")
|
| 118 |
+
async def list_project_runs(
|
| 119 |
+
request: Request,
|
| 120 |
+
project: ValidatedProject,
|
| 121 |
+
project_catalog: ProjectCatalogDep,
|
| 122 |
+
run_catalog: RunCatalogDep,
|
| 123 |
+
) -> HTMLResponse:
|
| 124 |
+
"""List runs in a project."""
|
| 125 |
+
# Check if project exists
|
| 126 |
+
if not project_catalog.exists(project):
|
| 127 |
+
raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
|
| 128 |
+
|
| 129 |
+
runs = run_catalog.get_runs(project)
|
| 130 |
+
|
| 131 |
+
# Format runs for template
|
| 132 |
+
formatted_runs = [TemplateService.format_run_for_list(run) for run in runs]
|
| 133 |
+
|
| 134 |
+
context = {
|
| 135 |
+
"page_title": f"{project} - Runs",
|
| 136 |
+
"breadcrumbs": create_breadcrumbs([
|
| 137 |
+
{"label": "Home", "url": "/", "is_home": True},
|
| 138 |
+
{"label": project, "url": f"/projects/{project}"},
|
| 139 |
+
{"label": "Runs"},
|
| 140 |
+
]),
|
| 141 |
+
"project": project,
|
| 142 |
+
"runs": formatted_runs,
|
| 143 |
+
"has_runs": len(formatted_runs) > 0,
|
| 144 |
+
"read_only": is_read_only(),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
html = render_mustache_response("runs_list", context)
|
| 148 |
+
return HTMLResponse(content=html)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.get("/projects/{project}/runs/{run}")
|
| 152 |
+
async def get_run(
|
| 153 |
+
request: Request,
|
| 154 |
+
project: ValidatedProject,
|
| 155 |
+
run: ValidatedRun,
|
| 156 |
+
project_catalog: ProjectCatalogDep,
|
| 157 |
+
run_catalog: RunCatalogDep,
|
| 158 |
+
) -> HTMLResponse:
|
| 159 |
+
"""Get run details including parameters and metrics."""
|
| 160 |
+
# Check if project exists
|
| 161 |
+
if not project_catalog.exists(project):
|
| 162 |
+
raise HTTPException(status_code=404, detail=f"Project '{project}' not found")
|
| 163 |
+
|
| 164 |
+
# Get Run information and check if it's corrupted
|
| 165 |
+
try:
|
| 166 |
+
current_run = run_catalog.get(project, run)
|
| 167 |
+
except RunNotFoundError as e:
|
| 168 |
+
raise HTTPException(status_code=404, detail=f"Run '{run}' not found in project '{project}'") from e
|
| 169 |
+
|
| 170 |
+
is_corrupted = current_run.is_corrupted
|
| 171 |
+
error_message = current_run.error_message
|
| 172 |
+
run_tags = current_run.tags
|
| 173 |
+
|
| 174 |
+
# Load metrics, artifacts, and metadata in parallel
|
| 175 |
+
df_metrics, artifacts, metadata = await asyncio.gather(
|
| 176 |
+
asyncio.to_thread(run_catalog.load_metrics, project, run),
|
| 177 |
+
run_catalog.get_artifacts_async(project, run),
|
| 178 |
+
run_catalog.get_run_config_async(project, run),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Extract params from metadata
|
| 182 |
+
params: dict[str, Any] = {}
|
| 183 |
+
params.update(metadata.get("params", {}))
|
| 184 |
+
params.update(metadata.get("config", {}))
|
| 185 |
+
|
| 186 |
+
# Format data for template
|
| 187 |
+
formatted_params = [{"key": k, "value": v} for k, v in params.items()]
|
| 188 |
+
|
| 189 |
+
# Get latest metrics for scalar display from wide-format DataFrame
|
| 190 |
+
latest_metrics: dict[str, Any] = {}
|
| 191 |
+
if len(df_metrics) > 0:
|
| 192 |
+
# Get last row (latest metrics)
|
| 193 |
+
last_row = df_metrics.tail(1).to_dicts()[0]
|
| 194 |
+
# Extract metric columns (those starting with underscore)
|
| 195 |
+
for col, value in last_row.items():
|
| 196 |
+
if col.startswith("_") and value is not None:
|
| 197 |
+
# Remove underscore prefix
|
| 198 |
+
metric_name = col[1:]
|
| 199 |
+
latest_metrics[metric_name] = value
|
| 200 |
+
|
| 201 |
+
formatted_latest_metrics = [{"key": k, "value": f"{v:.4f}" if isinstance(v, int | float) else str(v)} for k, v in latest_metrics.items()]
|
| 202 |
+
|
| 203 |
+
# Get run start time from DataFrame
|
| 204 |
+
start_time = None
|
| 205 |
+
if len(df_metrics) > 0 and "timestamp" in df_metrics.columns:
|
| 206 |
+
start_time = df_metrics.select("timestamp").to_series().min()
|
| 207 |
+
|
| 208 |
+
context = {
|
| 209 |
+
"page_title": f"{run} - Details",
|
| 210 |
+
"breadcrumbs": create_breadcrumbs([
|
| 211 |
+
{"label": "Home", "url": "/", "is_home": True},
|
| 212 |
+
{"label": project, "url": f"/projects/{project}"},
|
| 213 |
+
{"label": "Runs", "url": f"/projects/{project}/runs"},
|
| 214 |
+
{"label": run},
|
| 215 |
+
]),
|
| 216 |
+
"project": project,
|
| 217 |
+
"run_name": run,
|
| 218 |
+
"params": formatted_params,
|
| 219 |
+
"has_params": len(formatted_params) > 0,
|
| 220 |
+
"latest_metrics": formatted_latest_metrics,
|
| 221 |
+
"has_latest_metrics": len(formatted_latest_metrics) > 0,
|
| 222 |
+
"formatted_start_time": (start_time.strftime("%B %d, %Y at %I:%M %p") if isinstance(start_time, datetime) else "N/A"),
|
| 223 |
+
"duration": "N/A", # We don't have duration data in current format
|
| 224 |
+
"has_tags": len(run_tags) > 0,
|
| 225 |
+
"tags": run_tags,
|
| 226 |
+
"artifacts": [TemplateService.format_artifact_for_template(artifact) for artifact in artifacts],
|
| 227 |
+
"has_artifacts": len(artifacts) > 0,
|
| 228 |
+
"is_corrupted": is_corrupted,
|
| 229 |
+
"error_message": error_message,
|
| 230 |
+
"read_only": is_read_only(),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
html = render_mustache_response("run_detail", context)
|
| 234 |
+
return HTMLResponse(content=html)
|
src/aspara/dashboard/routes/sse_routes.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Server-Sent Events (SSE) routes for Aspara Dashboard.
|
| 3 |
+
|
| 4 |
+
This module handles real-time streaming endpoints:
|
| 5 |
+
- Multiple runs metrics streaming
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
from collections.abc import Coroutine
|
| 13 |
+
from contextlib import suppress
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import Any, cast
|
| 16 |
+
|
| 17 |
+
from fastapi import APIRouter, Query
|
| 18 |
+
from sse_starlette.sse import EventSourceResponse
|
| 19 |
+
|
| 20 |
+
from aspara.config import get_resource_limits, is_dev_mode
|
| 21 |
+
from aspara.models import MetricRecord, StatusRecord
|
| 22 |
+
from aspara.utils import validators
|
| 23 |
+
|
| 24 |
+
from ..dependencies import RunCatalogDep, ValidatedProject
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/api/projects/{project}/runs/stream")
|
| 32 |
+
async def stream_multiple_runs(
|
| 33 |
+
project: ValidatedProject,
|
| 34 |
+
run_catalog: RunCatalogDep,
|
| 35 |
+
runs: str,
|
| 36 |
+
since: int = Query(
|
| 37 |
+
...,
|
| 38 |
+
description="Filter metrics since this UNIX timestamp in milliseconds",
|
| 39 |
+
),
|
| 40 |
+
) -> EventSourceResponse:
|
| 41 |
+
"""Stream metrics for multiple runs using Server-Sent Events (SSE).
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
project: Project name.
|
| 45 |
+
runs: Comma-separated list of run names (e.g., "run1,run2,run3").
|
| 46 |
+
since: Filter to only stream metrics with timestamp >= since (required, UNIX ms).
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
EventSourceResponse streaming metric and status events from all specified runs.
|
| 50 |
+
Event types:
|
| 51 |
+
- `metric`: `{"event": "metric", "data": <MetricRecord JSON>}`
|
| 52 |
+
- `status`: `{"event": "status", "data": <StatusRecord JSON>}`
|
| 53 |
+
|
| 54 |
+
Raises:
|
| 55 |
+
HTTPException: 400 if project/run name is invalid, 422 if since is missing.
|
| 56 |
+
"""
|
| 57 |
+
logger.info(f"[SSE ENDPOINT] Called with project={project}, runs={runs}")
|
| 58 |
+
|
| 59 |
+
from ..main import app_state
|
| 60 |
+
|
| 61 |
+
if not runs:
|
| 62 |
+
|
| 63 |
+
async def no_runs_error_generator():
|
| 64 |
+
yield {"event": "error", "data": "No runs specified"}
|
| 65 |
+
|
| 66 |
+
return EventSourceResponse(no_runs_error_generator())
|
| 67 |
+
|
| 68 |
+
# Parse and validate run names
|
| 69 |
+
run_list = [r.strip() for r in runs.split(",") if r.strip()]
|
| 70 |
+
|
| 71 |
+
if not run_list:
|
| 72 |
+
|
| 73 |
+
async def no_valid_runs_error_generator():
|
| 74 |
+
yield {"event": "error", "data": "No valid runs specified"}
|
| 75 |
+
|
| 76 |
+
return EventSourceResponse(no_valid_runs_error_generator())
|
| 77 |
+
|
| 78 |
+
# Validate run count limit
|
| 79 |
+
limits = get_resource_limits()
|
| 80 |
+
if len(run_list) > limits.max_metric_names:
|
| 81 |
+
too_many_runs_msg = f"Too many runs: {len(run_list)} (max: {limits.max_metric_names})"
|
| 82 |
+
|
| 83 |
+
async def too_many_runs_error_generator():
|
| 84 |
+
yield {"event": "error", "data": too_many_runs_msg}
|
| 85 |
+
|
| 86 |
+
return EventSourceResponse(too_many_runs_error_generator())
|
| 87 |
+
|
| 88 |
+
# Validate each run name
|
| 89 |
+
for run_name in run_list:
|
| 90 |
+
try:
|
| 91 |
+
validators.validate_run_name(run_name)
|
| 92 |
+
except ValueError:
|
| 93 |
+
invalid_run_msg = f"Invalid run name: {run_name}"
|
| 94 |
+
|
| 95 |
+
async def invalid_run_error_generator(msg: str = invalid_run_msg):
|
| 96 |
+
yield {"event": "error", "data": msg}
|
| 97 |
+
|
| 98 |
+
return EventSourceResponse(invalid_run_error_generator())
|
| 99 |
+
|
| 100 |
+
# Convert UNIX ms to datetime
|
| 101 |
+
since_dt = datetime.fromtimestamp(since / 1000, tz=timezone.utc)
|
| 102 |
+
|
| 103 |
+
async def event_generator():
|
| 104 |
+
logger.info(f"[SSE] event_generator started for project={project}, runs={run_list}")
|
| 105 |
+
|
| 106 |
+
# Register current task for dev mode forced cancellation
|
| 107 |
+
current_task = asyncio.current_task()
|
| 108 |
+
if current_task is not None:
|
| 109 |
+
app_state.active_sse_tasks.add(current_task)
|
| 110 |
+
|
| 111 |
+
# Create shutdown queue for this connection
|
| 112 |
+
shutdown_queue: asyncio.Queue[None] = asyncio.Queue()
|
| 113 |
+
app_state.active_sse_connections.add(shutdown_queue)
|
| 114 |
+
|
| 115 |
+
# Use new subscribe() method with singleton watcher
|
| 116 |
+
targets = {project: run_list}
|
| 117 |
+
metrics_iterator = run_catalog.subscribe(targets, since=since_dt).__aiter__()
|
| 118 |
+
logger.info("[SSE] Created metrics_iterator using subscribe()")
|
| 119 |
+
|
| 120 |
+
# In dev mode, use shorter timeout for faster shutdown detection
|
| 121 |
+
dev_mode = is_dev_mode()
|
| 122 |
+
wait_timeout = 1.0 if dev_mode else None
|
| 123 |
+
|
| 124 |
+
# Track pending metric task to avoid re-creating it after timeout
|
| 125 |
+
# IMPORTANT: Cancelling a task that's awaiting inside an async generator
|
| 126 |
+
# will close the generator. We must NOT cancel metric_task on timeout.
|
| 127 |
+
pending_metric_task: asyncio.Task[MetricRecord | StatusRecord] | None = None
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
while True:
|
| 131 |
+
# Check shutdown flag in dev mode
|
| 132 |
+
if dev_mode and app_state.shutting_down:
|
| 133 |
+
logger.info("[SSE] Dev mode: shutdown flag detected")
|
| 134 |
+
# Cancel pending metric_task before exiting
|
| 135 |
+
if pending_metric_task is not None:
|
| 136 |
+
pending_metric_task.cancel()
|
| 137 |
+
with suppress(asyncio.CancelledError):
|
| 138 |
+
await pending_metric_task
|
| 139 |
+
break
|
| 140 |
+
|
| 141 |
+
# Create metric_task only if we don't have a pending one
|
| 142 |
+
if pending_metric_task is None:
|
| 143 |
+
metric_coro = cast(
|
| 144 |
+
"Coroutine[Any, Any, MetricRecord | StatusRecord]",
|
| 145 |
+
metrics_iterator.__anext__(),
|
| 146 |
+
)
|
| 147 |
+
pending_metric_task = asyncio.create_task(metric_coro, name="metric_task")
|
| 148 |
+
|
| 149 |
+
# Always create a new shutdown_task
|
| 150 |
+
shutdown_coro = cast("Coroutine[Any, Any, Any]", shutdown_queue.get())
|
| 151 |
+
shutdown_task = asyncio.create_task(shutdown_coro, name="shutdown_task")
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
done, pending = await asyncio.wait(
|
| 155 |
+
[pending_metric_task, shutdown_task],
|
| 156 |
+
return_when=asyncio.FIRST_COMPLETED,
|
| 157 |
+
timeout=wait_timeout,
|
| 158 |
+
)
|
| 159 |
+
except asyncio.CancelledError:
|
| 160 |
+
# Cancelled by lifespan handler in dev mode
|
| 161 |
+
logger.info("[SSE] Task cancelled (dev mode shutdown)")
|
| 162 |
+
pending_metric_task.cancel()
|
| 163 |
+
shutdown_task.cancel()
|
| 164 |
+
with suppress(asyncio.CancelledError):
|
| 165 |
+
await pending_metric_task
|
| 166 |
+
with suppress(asyncio.CancelledError):
|
| 167 |
+
await shutdown_task
|
| 168 |
+
raise
|
| 169 |
+
|
| 170 |
+
# Handle timeout (dev mode only)
|
| 171 |
+
if not done:
|
| 172 |
+
# Timeout occurred - only cancel shutdown_task, NOT metric_task
|
| 173 |
+
# Cancelling metric_task would close the async generator!
|
| 174 |
+
shutdown_task.cancel()
|
| 175 |
+
with suppress(asyncio.CancelledError):
|
| 176 |
+
await shutdown_task
|
| 177 |
+
# pending_metric_task is kept and will be reused in next iteration
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
logger.debug(f"[SSE] asyncio.wait returned: done={[t.get_name() for t in done]}, pending={[t.get_name() for t in pending]}")
|
| 181 |
+
|
| 182 |
+
# Cancel pending tasks (but NOT metric_task if it's pending)
|
| 183 |
+
if shutdown_task in pending:
|
| 184 |
+
shutdown_task.cancel()
|
| 185 |
+
with suppress(asyncio.CancelledError):
|
| 186 |
+
await shutdown_task
|
| 187 |
+
|
| 188 |
+
# Check which task completed
|
| 189 |
+
if pending_metric_task in done:
|
| 190 |
+
# Reset so we create a new task in next iteration
|
| 191 |
+
completed_task = pending_metric_task
|
| 192 |
+
pending_metric_task = None
|
| 193 |
+
try:
|
| 194 |
+
record = completed_task.result()
|
| 195 |
+
if isinstance(record, MetricRecord):
|
| 196 |
+
logger.debug(f"[SSE] Sending metric to client: run={record.run}, step={record.step}")
|
| 197 |
+
yield {"event": "metric", "data": record.model_dump_json()}
|
| 198 |
+
elif isinstance(record, StatusRecord):
|
| 199 |
+
logger.info(f"[SSE] Sending status update to client: run={record.run}, status={record.status}")
|
| 200 |
+
yield {"event": "status", "data": record.model_dump_json()}
|
| 201 |
+
except StopAsyncIteration:
|
| 202 |
+
logger.info("[SSE] No more records (StopAsyncIteration)")
|
| 203 |
+
break
|
| 204 |
+
elif shutdown_task in done:
|
| 205 |
+
logger.info("[SSE] Shutdown requested")
|
| 206 |
+
# Cancel metric_task since we're shutting down
|
| 207 |
+
if pending_metric_task is not None:
|
| 208 |
+
pending_metric_task.cancel()
|
| 209 |
+
with suppress(asyncio.CancelledError):
|
| 210 |
+
await pending_metric_task
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
except asyncio.CancelledError:
|
| 214 |
+
logger.info("[SSE] Generator cancelled")
|
| 215 |
+
raise
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"[SSE] Exception in event_generator: {e}", exc_info=True)
|
| 218 |
+
yield {"event": "error", "data": str(e)}
|
| 219 |
+
finally:
|
| 220 |
+
# Clean up: remove this connection from active set
|
| 221 |
+
logger.info("[SSE] event_generator finished, cleaning up")
|
| 222 |
+
app_state.active_sse_connections.discard(shutdown_queue)
|
| 223 |
+
if current_task is not None:
|
| 224 |
+
app_state.active_sse_tasks.discard(current_task)
|
| 225 |
+
# Cancel pending metric task if still running
|
| 226 |
+
if pending_metric_task is not None and not pending_metric_task.done():
|
| 227 |
+
pending_metric_task.cancel()
|
| 228 |
+
with suppress(asyncio.CancelledError):
|
| 229 |
+
await pending_metric_task
|
| 230 |
+
# Close the async generator to trigger watcher unsubscribe
|
| 231 |
+
try:
|
| 232 |
+
await asyncio.wait_for(metrics_iterator.aclose(), timeout=1.0)
|
| 233 |
+
except asyncio.TimeoutError:
|
| 234 |
+
logger.warning("[SSE] Timeout closing metrics_iterator")
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.warning(f"[SSE] Error closing metrics_iterator: {e}")
|
| 237 |
+
|
| 238 |
+
logger.info(f"[SSE ENDPOINT] Returning EventSourceResponse for runs={run_list}")
|
| 239 |
+
return EventSourceResponse(event_generator())
|
src/aspara/dashboard/services/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Aspara Dashboard services.
|
| 3 |
+
|
| 4 |
+
This package contains business logic services for the dashboard.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .template_service import TemplateService, create_breadcrumbs, render_mustache_response
|
| 8 |
+
|
| 9 |
+
__all__ = ["TemplateService", "create_breadcrumbs", "render_mustache_response"]
|
src/aspara/dashboard/services/template_service.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Template rendering service for Aspara Dashboard.
|
| 3 |
+
|
| 4 |
+
Provides Mustache template rendering and context formatting utilities.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import pystache
|
| 14 |
+
|
| 15 |
+
from aspara.catalog import ProjectInfo, RunInfo
|
| 16 |
+
|
| 17 |
+
BASE_DIR = Path(__file__).parent.parent
|
| 18 |
+
_mustache_renderer = pystache.Renderer(search_dirs=[str(BASE_DIR / "templates")])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_breadcrumbs(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 22 |
+
"""Create standardized breadcrumbs with consistent formatting.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
items: List of breadcrumb items with 'label' and optional 'url' keys.
|
| 26 |
+
First item is assumed to be Home.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
List of breadcrumb items with consistent is_not_first flags.
|
| 30 |
+
"""
|
| 31 |
+
result = []
|
| 32 |
+
|
| 33 |
+
for i, item in enumerate(items):
|
| 34 |
+
crumb = item.copy()
|
| 35 |
+
crumb["is_not_first"] = i != 0
|
| 36 |
+
|
| 37 |
+
# Add home icon to first item if not already specified
|
| 38 |
+
if i == 0 and "is_home" not in crumb:
|
| 39 |
+
crumb["is_home"] = True
|
| 40 |
+
|
| 41 |
+
result.append(crumb)
|
| 42 |
+
|
| 43 |
+
return result
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def render_mustache_response(template_name: str, context: dict[str, Any]) -> str:
|
| 47 |
+
"""Render mustache template with context.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
template_name: Name of the template file (without extension).
|
| 51 |
+
context: Template context dictionary.
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Rendered HTML string.
|
| 55 |
+
"""
|
| 56 |
+
# Add common context variables
|
| 57 |
+
context.update({
|
| 58 |
+
"current_year": datetime.now().year,
|
| 59 |
+
"page_title": context.get("page_title", "Aspara"),
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
# Render content template
|
| 63 |
+
content = _mustache_renderer.render_name(template_name, context)
|
| 64 |
+
|
| 65 |
+
# Render layout with content
|
| 66 |
+
layout_context = context.copy()
|
| 67 |
+
layout_context["content"] = content
|
| 68 |
+
|
| 69 |
+
return _mustache_renderer.render_name("layout", layout_context)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TemplateService:
|
| 73 |
+
"""Service for template rendering and data formatting.
|
| 74 |
+
|
| 75 |
+
This class provides methods for formatting data objects for template rendering.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def format_project_for_template(project: ProjectInfo, tags: list[str] | None = None) -> dict[str, Any]:
|
| 80 |
+
"""Format a ProjectInfo for template rendering.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
project: ProjectInfo object.
|
| 84 |
+
tags: Optional list of tags from metadata.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
Dictionary suitable for template rendering.
|
| 88 |
+
"""
|
| 89 |
+
return {
|
| 90 |
+
"name": project.name,
|
| 91 |
+
"run_count": project.run_count or 0,
|
| 92 |
+
"last_update": int(project.last_update.timestamp() * 1000) if project.last_update else 0,
|
| 93 |
+
"formatted_last_update": (project.last_update.strftime("%B %d, %Y at %I:%M %p") if project.last_update else "N/A"),
|
| 94 |
+
"tags": tags or [],
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def format_run_for_list(run: RunInfo) -> dict[str, Any]:
|
| 99 |
+
"""Format a RunInfo for runs list template.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
run: RunInfo object.
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Dictionary suitable for runs list template rendering.
|
| 106 |
+
"""
|
| 107 |
+
return {
|
| 108 |
+
"name": run.name,
|
| 109 |
+
"param_count": run.param_count or 0,
|
| 110 |
+
"last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
|
| 111 |
+
"formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
|
| 112 |
+
"is_corrupted": run.is_corrupted,
|
| 113 |
+
"error_message": run.error_message,
|
| 114 |
+
"tags": run.tags,
|
| 115 |
+
"has_tags": len(run.tags) > 0,
|
| 116 |
+
"is_finished": run.is_finished,
|
| 117 |
+
"is_wip": run.status.value == "wip",
|
| 118 |
+
"status": run.status.value,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
@staticmethod
|
| 122 |
+
def format_run_for_project_detail(run: RunInfo) -> dict[str, Any] | None:
|
| 123 |
+
"""Format a RunInfo for project detail template (excludes corrupted runs).
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
run: RunInfo object.
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Dictionary suitable for project detail template rendering,
|
| 130 |
+
or None if the run is corrupted.
|
| 131 |
+
"""
|
| 132 |
+
if run.is_corrupted:
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
return {
|
| 136 |
+
"name": run.name,
|
| 137 |
+
"last_update": int(run.last_update.timestamp() * 1000) if run.last_update else 0,
|
| 138 |
+
"formatted_last_update": (run.last_update.strftime("%B %d, %Y at %I:%M %p") if run.last_update else "N/A"),
|
| 139 |
+
"is_finished": run.is_finished,
|
| 140 |
+
"is_wip": run.status.value == "wip",
|
| 141 |
+
"status": run.status.value,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def format_artifact_for_template(artifact: dict[str, Any]) -> dict[str, Any]:
|
| 146 |
+
"""Format an artifact for template rendering with category flags.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
artifact: Artifact dictionary.
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dictionary with category boolean flags added.
|
| 153 |
+
"""
|
| 154 |
+
category = artifact.get("category")
|
| 155 |
+
return {
|
| 156 |
+
**artifact,
|
| 157 |
+
"is_code": category == "code",
|
| 158 |
+
"is_config": category == "config",
|
| 159 |
+
"is_model": category == "model",
|
| 160 |
+
"is_data": category == "data",
|
| 161 |
+
"is_other": category == "other" or category is None,
|
| 162 |
+
}
|
src/aspara/dashboard/static/css/input.css
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@source "../../templates/**/*.mustache";
|
| 3 |
+
|
| 4 |
+
@theme {
|
| 5 |
+
--color-action: #2C2520;
|
| 6 |
+
--color-action-hover: #1a1512;
|
| 7 |
+
--color-action-disabled: #d4cfc9;
|
| 8 |
+
|
| 9 |
+
--color-secondary: #8B7F75;
|
| 10 |
+
--color-secondary-hover: #6B5F55;
|
| 11 |
+
|
| 12 |
+
--color-accent: #CC785C;
|
| 13 |
+
--color-accent-hover: #B5654A;
|
| 14 |
+
--color-accent-light: #E8A892;
|
| 15 |
+
|
| 16 |
+
--color-base-bg: #F5F3F0;
|
| 17 |
+
--color-base-border: #E6E3E0;
|
| 18 |
+
--color-base-surface: #FDFCFB;
|
| 19 |
+
|
| 20 |
+
--color-text-primary: #2C2520;
|
| 21 |
+
--color-text-secondary: #6B5F55;
|
| 22 |
+
--color-text-muted: #9B8F85;
|
| 23 |
+
|
| 24 |
+
--color-status-error: #C84C3C;
|
| 25 |
+
--color-status-success: #5A8B6F;
|
| 26 |
+
--color-status-warning: #D4864E;
|
| 27 |
+
|
| 28 |
+
--font-family-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 29 |
+
--font-family-mono: "JetBrains Mono", Consolas, Monaco, monospace;
|
| 30 |
+
|
| 31 |
+
--radius-button: 0.5rem;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Custom styles beyond Tailwind */
|
| 35 |
+
.plot-container {
|
| 36 |
+
height: 24rem;
|
| 37 |
+
background: #FDFCFB;
|
| 38 |
+
padding: 1rem;
|
| 39 |
+
border-radius: 0;
|
| 40 |
+
border: 1px solid #E6E3E0;
|
| 41 |
+
box-shadow: none;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.sidebar {
|
| 45 |
+
width: 16rem;
|
| 46 |
+
background: #FDFCFB;
|
| 47 |
+
border: 1px solid #E6E3E0;
|
| 48 |
+
box-shadow: none;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* Sidebar animation - optimized for responsiveness */
|
| 52 |
+
#runs-sidebar {
|
| 53 |
+
transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* Accessibility: respect reduced motion preference */
|
| 57 |
+
@media (prefers-reduced-motion: reduce) {
|
| 58 |
+
#runs-sidebar {
|
| 59 |
+
transition: none;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.content-area {
|
| 64 |
+
padding: 2rem;
|
| 65 |
+
flex: 1;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/* === @jcubic/tagger Aspara Theme Override === */
|
| 69 |
+
|
| 70 |
+
/* Container - border and background only */
|
| 71 |
+
.tagger {
|
| 72 |
+
border: 1px solid var(--color-base-border);
|
| 73 |
+
border-radius: 0.375rem;
|
| 74 |
+
background: var(--color-base-surface);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/* Tags - color and border-radius only, keep original padding/display */
|
| 78 |
+
.tagger > ul > li:not(.tagger-new) > :first-child {
|
| 79 |
+
background: var(--color-base-bg);
|
| 80 |
+
border: 1px solid var(--color-base-border);
|
| 81 |
+
border-radius: 9999px;
|
| 82 |
+
/* padding is kept as original 4px 4px 4px 8px - do not override */
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Tag text color */
|
| 86 |
+
.tagger > ul > li:not(.tagger-new) span.label {
|
| 87 |
+
color: var(--color-text-muted);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Close button */
|
| 91 |
+
.tagger li a.close {
|
| 92 |
+
color: var(--color-text-muted);
|
| 93 |
+
}
|
| 94 |
+
.tagger li a.close:hover {
|
| 95 |
+
color: var(--color-text-primary);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Input field */
|
| 99 |
+
.tagger .tagger-new input {
|
| 100 |
+
font-size: 0.75rem;
|
| 101 |
+
color: var(--color-text-primary);
|
| 102 |
+
}
|
| 103 |
+
.tagger .tagger-new input::placeholder {
|
| 104 |
+
color: var(--color-text-muted);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* === Status Icon Styles (based on data-status attribute) === */
|
| 108 |
+
|
| 109 |
+
[data-status="wip"] {
|
| 110 |
+
@apply animate-pulse text-status-warning;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
[data-status="completed"] {
|
| 114 |
+
@apply text-status-success;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
[data-status="failed"] {
|
| 118 |
+
@apply text-status-error;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
[data-status="maybe_failed"] {
|
| 122 |
+
@apply text-status-warning;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* === Note Editor Cursor Styles === */
|
| 126 |
+
|
| 127 |
+
/* Placeholder (Add note...) cursor */
|
| 128 |
+
.note-content .text-text-muted.italic {
|
| 129 |
+
cursor: pointer;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* Edit link cursor */
|
| 133 |
+
.note-edit-btn {
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* === Dialog Styles === */
|
| 138 |
+
|
| 139 |
+
dialog.delete-dialog {
|
| 140 |
+
position: fixed;
|
| 141 |
+
top: 50%;
|
| 142 |
+
left: 50%;
|
| 143 |
+
transform: translate(-50%, -50%);
|
| 144 |
+
margin: 0;
|
| 145 |
+
border: 1px solid var(--color-base-border);
|
| 146 |
+
border-radius: 0.5rem;
|
| 147 |
+
background: var(--color-base-surface);
|
| 148 |
+
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
| 149 |
+
max-width: 28rem;
|
| 150 |
+
width: calc(100% - 2rem);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
dialog.delete-dialog::backdrop {
|
| 154 |
+
background: rgb(0 0 0 / 0.5);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* === Card Interactive Styles === */
|
| 158 |
+
|
| 159 |
+
/* Common card styles */
|
| 160 |
+
.card-interactive {
|
| 161 |
+
@apply transition-colors duration-150 outline-none;
|
| 162 |
+
@apply hover:border-accent;
|
| 163 |
+
@apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* Reduced motion support */
|
| 167 |
+
@media (prefers-reduced-motion: reduce) {
|
| 168 |
+
.card-interactive {
|
| 169 |
+
@apply transition-none;
|
| 170 |
+
}
|
| 171 |
+
}
|
src/aspara/dashboard/static/css/tagger.css
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**@license
|
| 2 |
+
* _____
|
| 3 |
+
* |_ _|___ ___ ___ ___ ___
|
| 4 |
+
* | | | .'| . | . | -_| _|
|
| 5 |
+
* |_| |__,|_ |_ |___|_|
|
| 6 |
+
* |___|___| version 0.6.2
|
| 7 |
+
*
|
| 8 |
+
* Tagger - Zero dependency, Vanilla JavaScript Tag Editor
|
| 9 |
+
*
|
| 10 |
+
* Copyright (c) 2018-2024 Jakub T. Jankiewicz <https://jcubic.pl/me>
|
| 11 |
+
* Released under the MIT license
|
| 12 |
+
*/
|
| 13 |
+
/* Border/background defined in input.css */
|
| 14 |
+
.tagger input[type="hidden"] {
|
| 15 |
+
/* fix for bootstrap */
|
| 16 |
+
display: none;
|
| 17 |
+
}
|
| 18 |
+
.tagger > ul {
|
| 19 |
+
display: flex;
|
| 20 |
+
width: 100%;
|
| 21 |
+
align-items: center;
|
| 22 |
+
padding: 4px 5px 0;
|
| 23 |
+
justify-content: space-between;
|
| 24 |
+
box-sizing: border-box;
|
| 25 |
+
height: auto;
|
| 26 |
+
flex: 0 0 auto;
|
| 27 |
+
overflow-y: auto;
|
| 28 |
+
margin: 0;
|
| 29 |
+
list-style: none;
|
| 30 |
+
}
|
| 31 |
+
.tagger > ul > li {
|
| 32 |
+
padding-bottom: 0.4rem;
|
| 33 |
+
margin: 0.4rem 5px 4px;
|
| 34 |
+
}
|
| 35 |
+
.tagger > ul > li:not(.tagger-new) a,
|
| 36 |
+
.tagger > ul > li:not(.tagger-new) a:visited {
|
| 37 |
+
text-decoration: none;
|
| 38 |
+
/* color defined in input.css */
|
| 39 |
+
}
|
| 40 |
+
.tagger > ul > li:not(.tagger-new) > :first-child {
|
| 41 |
+
padding: 4px 4px 4px 8px;
|
| 42 |
+
/* background, border, border-radius defined in input.css */
|
| 43 |
+
}
|
| 44 |
+
.tagger > ul > li:not(.tagger-new) > span,
|
| 45 |
+
.tagger > ul > li:not(.tagger-new) > a > span {
|
| 46 |
+
white-space: nowrap;
|
| 47 |
+
}
|
| 48 |
+
.tagger li a.close {
|
| 49 |
+
padding: 4px;
|
| 50 |
+
margin-left: 4px;
|
| 51 |
+
/* for bootstrap */
|
| 52 |
+
float: none;
|
| 53 |
+
filter: alpha(opacity=100);
|
| 54 |
+
opacity: 1;
|
| 55 |
+
font-size: 16px;
|
| 56 |
+
line-height: 16px;
|
| 57 |
+
}
|
| 58 |
+
.tagger li a.close:hover {
|
| 59 |
+
/* color defined in input.css */
|
| 60 |
+
}
|
| 61 |
+
.tagger .tagger-new input {
|
| 62 |
+
border: none;
|
| 63 |
+
outline: none;
|
| 64 |
+
box-shadow: none;
|
| 65 |
+
width: 100%;
|
| 66 |
+
padding-left: 0;
|
| 67 |
+
box-sizing: border-box;
|
| 68 |
+
background: transparent;
|
| 69 |
+
}
|
| 70 |
+
.tagger .tagger-new {
|
| 71 |
+
flex-grow: 1;
|
| 72 |
+
position: relative;
|
| 73 |
+
min-width: 40px;
|
| 74 |
+
width: 1px;
|
| 75 |
+
}
|
| 76 |
+
.tagger.wrap > ul {
|
| 77 |
+
flex-wrap: wrap;
|
| 78 |
+
justify-content: start;
|
| 79 |
+
}
|
src/aspara/dashboard/static/favicon.ico
ADDED
|
|
src/aspara/dashboard/static/images/aspara-icon.png
ADDED
|
|
src/aspara/dashboard/static/js/api/delete-api.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Delete API utility functions
|
| 3 |
+
* Pure API calls without UI logic
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Delete a project via API
|
| 8 |
+
* @param {string} projectName - The project name to delete
|
| 9 |
+
* @returns {Promise<object>} - Response data
|
| 10 |
+
* @throws {Error} - API error
|
| 11 |
+
*/
|
| 12 |
+
export async function deleteProjectApi(projectName) {
|
| 13 |
+
const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}`, {
|
| 14 |
+
method: 'DELETE',
|
| 15 |
+
headers: {
|
| 16 |
+
'Content-Type': 'application/json',
|
| 17 |
+
'X-Requested-With': 'XMLHttpRequest',
|
| 18 |
+
},
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
if (!response.ok) {
|
| 22 |
+
const errorData = await response.json();
|
| 23 |
+
throw new Error(errorData.detail || 'Unknown error');
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Handle 204 No Content responses
|
| 27 |
+
if (response.status === 204) {
|
| 28 |
+
return { message: 'Project deleted successfully' };
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return response.json();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Delete a run via API
|
| 36 |
+
* @param {string} projectName - The project name
|
| 37 |
+
* @param {string} runName - The run name to delete
|
| 38 |
+
* @returns {Promise<object>} - Response data
|
| 39 |
+
* @throws {Error} - API error
|
| 40 |
+
*/
|
| 41 |
+
export async function deleteRunApi(projectName, runName) {
|
| 42 |
+
const response = await fetch(`/api/projects/${encodeURIComponent(projectName)}/runs/${encodeURIComponent(runName)}`, {
|
| 43 |
+
method: 'DELETE',
|
| 44 |
+
headers: {
|
| 45 |
+
'Content-Type': 'application/json',
|
| 46 |
+
'X-Requested-With': 'XMLHttpRequest',
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
if (!response.ok) {
|
| 51 |
+
const errorData = await response.json();
|
| 52 |
+
throw new Error(errorData.detail || 'Unknown error');
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Handle 204 No Content responses
|
| 56 |
+
if (response.status === 204) {
|
| 57 |
+
return { message: 'Run deleted successfully' };
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return response.json();
|
| 61 |
+
}
|
src/aspara/dashboard/static/js/chart.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Canvas-based chart component for metrics visualization.
|
| 3 |
+
* Supports multiple series, zoom, hover tooltips, and data export.
|
| 4 |
+
*/
|
| 5 |
+
import { ChartColorPalette } from './chart/color-palette.js';
|
| 6 |
+
import { ChartControls } from './chart/controls.js';
|
| 7 |
+
import { ChartExport } from './chart/export.js';
|
| 8 |
+
import { ChartInteraction } from './chart/interaction.js';
|
| 9 |
+
import { ChartRenderer } from './chart/renderer.js';
|
| 10 |
+
|
| 11 |
+
export class Chart {
|
| 12 |
+
// Chart layout constants
|
| 13 |
+
static MARGIN = 60;
|
| 14 |
+
static CANVAS_SCALE_FACTOR = 1.5; // Reduced from 2.5 for better performance
|
| 15 |
+
static SIZE_UPDATE_RETRY_DELAY_MS = 100;
|
| 16 |
+
static FULLSCREEN_UPDATE_DELAY_MS = 100;
|
| 17 |
+
static MIN_DRAG_DISTANCE = 10;
|
| 18 |
+
|
| 19 |
+
// Grid constants
|
| 20 |
+
static X_GRID_COUNT = 10;
|
| 21 |
+
static Y_GRID_COUNT = 8;
|
| 22 |
+
static Y_PADDING_RATIO = 0.1;
|
| 23 |
+
|
| 24 |
+
// Style constants
|
| 25 |
+
static LINE_WIDTH = 1.5; // Normal view
|
| 26 |
+
static LINE_WIDTH_FULLSCREEN = 2.5; // Fullscreen view
|
| 27 |
+
static GRID_LINE_WIDTH = 0.5;
|
| 28 |
+
static LEGEND_ITEM_SPACING = 16;
|
| 29 |
+
static LEGEND_LINE_LENGTH = 16;
|
| 30 |
+
static LEGEND_TEXT_OFFSET = 4;
|
| 31 |
+
static LEGEND_Y_OFFSET = 30;
|
| 32 |
+
|
| 33 |
+
// Animation constants
|
| 34 |
+
static ANIMATION_PULSE_DURATION_MS = 1000;
|
| 35 |
+
|
| 36 |
+
constructor(containerId, options = {}) {
|
| 37 |
+
this.container = document.querySelector(containerId);
|
| 38 |
+
if (!this.container) {
|
| 39 |
+
throw new Error(`Container ${containerId} not found`);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
this.data = null;
|
| 43 |
+
this.width = 0;
|
| 44 |
+
this.height = 0;
|
| 45 |
+
this.onZoomChange = options.onZoomChange || null;
|
| 46 |
+
|
| 47 |
+
// Color palette for managing series styles
|
| 48 |
+
this.colorPalette = new ChartColorPalette();
|
| 49 |
+
|
| 50 |
+
// Initialize modules
|
| 51 |
+
this.renderer = new ChartRenderer(this);
|
| 52 |
+
this.chartExport = new ChartExport(this);
|
| 53 |
+
this.interaction = new ChartInteraction(this, this.renderer);
|
| 54 |
+
this.controls = new ChartControls(this, this.chartExport);
|
| 55 |
+
|
| 56 |
+
this.hoverPoint = null;
|
| 57 |
+
|
| 58 |
+
this.zoomState = {
|
| 59 |
+
active: false,
|
| 60 |
+
startX: null,
|
| 61 |
+
startY: null,
|
| 62 |
+
currentX: null,
|
| 63 |
+
currentY: null,
|
| 64 |
+
};
|
| 65 |
+
this.zoom = { x: null, y: null };
|
| 66 |
+
|
| 67 |
+
// Fullscreen event handler (stored for cleanup)
|
| 68 |
+
this.fullscreenChangeHandler = null;
|
| 69 |
+
|
| 70 |
+
// Data range cache for performance optimization
|
| 71 |
+
this._cachedDataRanges = null;
|
| 72 |
+
this._lastDataRef = null;
|
| 73 |
+
|
| 74 |
+
this.init();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
init() {
|
| 78 |
+
this.container.innerHTML = '';
|
| 79 |
+
|
| 80 |
+
this.canvas = document.createElement('canvas');
|
| 81 |
+
this.canvas.style.border = '1px solid #e5e7eb';
|
| 82 |
+
this.canvas.style.display = 'block';
|
| 83 |
+
this.canvas.style.maxWidth = '100%';
|
| 84 |
+
|
| 85 |
+
this.container.appendChild(this.canvas);
|
| 86 |
+
this.ctx = this.canvas.getContext('2d');
|
| 87 |
+
|
| 88 |
+
this.ctx.imageSmoothingEnabled = true;
|
| 89 |
+
this.ctx.imageSmoothingQuality = 'high';
|
| 90 |
+
|
| 91 |
+
// For throttling draw calls
|
| 92 |
+
this.pendingDraw = false;
|
| 93 |
+
|
| 94 |
+
this.updateSize();
|
| 95 |
+
this.interaction.setupEventListeners();
|
| 96 |
+
this.setupFullscreenListener();
|
| 97 |
+
this.controls.create();
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
updateSize() {
|
| 101 |
+
// Use clientWidth/clientHeight to get size excluding border
|
| 102 |
+
const rect = this.container.getBoundingClientRect?.();
|
| 103 |
+
const width = this.container.clientWidth || rect?.width || 0;
|
| 104 |
+
const height = this.container.clientHeight || rect?.height || 0;
|
| 105 |
+
|
| 106 |
+
// Retry later if container is not yet visible
|
| 107 |
+
if (width === 0 || height === 0) {
|
| 108 |
+
// Avoid infinite retry loops - only retry if we haven't set a size yet
|
| 109 |
+
if (this.width === 0 && this.height === 0) {
|
| 110 |
+
setTimeout(() => this.updateSize(), Chart.SIZE_UPDATE_RETRY_DELAY_MS);
|
| 111 |
+
}
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
this.width = width;
|
| 116 |
+
this.height = height;
|
| 117 |
+
|
| 118 |
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 119 |
+
|
| 120 |
+
const dpr = window.devicePixelRatio || 1;
|
| 121 |
+
const totalScale = dpr * Chart.CANVAS_SCALE_FACTOR;
|
| 122 |
+
|
| 123 |
+
// Set internal canvas resolution (high-DPI)
|
| 124 |
+
this.canvas.width = this.width * totalScale;
|
| 125 |
+
this.canvas.height = this.height * totalScale;
|
| 126 |
+
|
| 127 |
+
// Set CSS display size to exact pixel values (matching internal aspect ratio)
|
| 128 |
+
this.canvas.style.width = `${this.width}px`;
|
| 129 |
+
this.canvas.style.height = `${this.height}px`;
|
| 130 |
+
this.canvas.style.display = 'block';
|
| 131 |
+
|
| 132 |
+
this.ctx.scale(totalScale, totalScale);
|
| 133 |
+
|
| 134 |
+
// Redraw if data is already set
|
| 135 |
+
if (this.data) {
|
| 136 |
+
this.draw();
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
setData(data) {
|
| 141 |
+
this.data = data;
|
| 142 |
+
// Invalidate data range cache when data changes
|
| 143 |
+
this._cachedDataRanges = null;
|
| 144 |
+
this._lastDataRef = null;
|
| 145 |
+
if (data?.series) {
|
| 146 |
+
this.colorPalette.ensureRunStyles(data.series.map((s) => s.name));
|
| 147 |
+
}
|
| 148 |
+
this.draw();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/**
|
| 152 |
+
* Get cached data ranges, recalculating only when data changes.
|
| 153 |
+
* @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
|
| 154 |
+
*/
|
| 155 |
+
_getDataRanges() {
|
| 156 |
+
// Check if data reference changed
|
| 157 |
+
if (this.data?.series !== this._lastDataRef) {
|
| 158 |
+
this._lastDataRef = this.data?.series;
|
| 159 |
+
this._cachedDataRanges = this._calculateDataRanges();
|
| 160 |
+
}
|
| 161 |
+
return this._cachedDataRanges;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Calculate data ranges from all series.
|
| 166 |
+
* @returns {Object|null} Object with xMin, xMax, yMin, yMax or null
|
| 167 |
+
*/
|
| 168 |
+
_calculateDataRanges() {
|
| 169 |
+
if (!this.data?.series?.length) return null;
|
| 170 |
+
|
| 171 |
+
let xMin = Number.POSITIVE_INFINITY;
|
| 172 |
+
let xMax = Number.NEGATIVE_INFINITY;
|
| 173 |
+
let yMin = Number.POSITIVE_INFINITY;
|
| 174 |
+
let yMax = Number.NEGATIVE_INFINITY;
|
| 175 |
+
|
| 176 |
+
for (const series of this.data.series) {
|
| 177 |
+
if (!series.data?.steps?.length) continue;
|
| 178 |
+
const { steps, values } = series.data;
|
| 179 |
+
|
| 180 |
+
// steps are sorted, so O(1) for min/max
|
| 181 |
+
xMin = Math.min(xMin, steps[0]);
|
| 182 |
+
xMax = Math.max(xMax, steps[steps.length - 1]);
|
| 183 |
+
|
| 184 |
+
// values min/max
|
| 185 |
+
for (let i = 0; i < values.length; i++) {
|
| 186 |
+
if (values[i] < yMin) yMin = values[i];
|
| 187 |
+
if (values[i] > yMax) yMax = values[i];
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (xMin === Number.POSITIVE_INFINITY) return null;
|
| 192 |
+
|
| 193 |
+
return { xMin, xMax, yMin, yMax };
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
draw() {
|
| 197 |
+
// Skip drawing if canvas size is not yet initialized
|
| 198 |
+
if (this.width === 0 || this.height === 0) {
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
this.ctx.fillStyle = 'white';
|
| 203 |
+
this.ctx.fillRect(0, 0, this.width, this.height);
|
| 204 |
+
|
| 205 |
+
if (!this.data) {
|
| 206 |
+
console.warn('Chart.draw(): No data set');
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
if (!this.data.series || !Array.isArray(this.data.series)) {
|
| 211 |
+
console.error('Chart.draw(): Invalid data format - series must be an array');
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if (this.data.series.length === 0) {
|
| 216 |
+
console.warn('Chart.draw(): Empty series array');
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const margin = Chart.MARGIN;
|
| 221 |
+
const plotWidth = this.width - margin * 2;
|
| 222 |
+
const plotHeight = this.height - margin * 2;
|
| 223 |
+
|
| 224 |
+
// Use cached data ranges for performance
|
| 225 |
+
const ranges = this._getDataRanges();
|
| 226 |
+
if (!ranges) {
|
| 227 |
+
console.warn('Chart.draw(): No valid data points in series');
|
| 228 |
+
return;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
let { xMin, xMax, yMin, yMax } = ranges;
|
| 232 |
+
|
| 233 |
+
if (this.zoom.x) {
|
| 234 |
+
xMin = this.zoom.x.min;
|
| 235 |
+
xMax = this.zoom.x.max;
|
| 236 |
+
}
|
| 237 |
+
if (this.zoom.y) {
|
| 238 |
+
yMin = this.zoom.y.min;
|
| 239 |
+
yMax = this.zoom.y.max;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const yRange = yMax - yMin;
|
| 243 |
+
const yMinPadded = yMin - yRange * Chart.Y_PADDING_RATIO;
|
| 244 |
+
const yMaxPadded = yMax + yRange * Chart.Y_PADDING_RATIO;
|
| 245 |
+
|
| 246 |
+
this.renderer.drawGrid(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
|
| 247 |
+
this.renderer.drawAxisLabels(margin, plotWidth, plotHeight, xMin, xMax, yMinPadded, yMaxPadded);
|
| 248 |
+
|
| 249 |
+
// Clip to plot area
|
| 250 |
+
this.ctx.save();
|
| 251 |
+
this.ctx.beginPath();
|
| 252 |
+
this.ctx.rect(margin, margin, plotWidth, plotHeight);
|
| 253 |
+
this.ctx.clip();
|
| 254 |
+
|
| 255 |
+
for (const series of this.data.series) {
|
| 256 |
+
if (!series.data?.steps?.length) continue;
|
| 257 |
+
const { steps, values } = series.data;
|
| 258 |
+
|
| 259 |
+
const style = this.colorPalette.getRunStyle(series.name);
|
| 260 |
+
|
| 261 |
+
this.ctx.strokeStyle = style.borderColor;
|
| 262 |
+
this.ctx.lineWidth = this.getLineWidth();
|
| 263 |
+
this.ctx.lineCap = 'round';
|
| 264 |
+
this.ctx.lineJoin = 'round';
|
| 265 |
+
|
| 266 |
+
// Apply border dash pattern
|
| 267 |
+
if (style.borderDash && style.borderDash.length > 0) {
|
| 268 |
+
this.ctx.setLineDash(style.borderDash);
|
| 269 |
+
} else {
|
| 270 |
+
this.ctx.setLineDash([]);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
this.ctx.beginPath();
|
| 274 |
+
|
| 275 |
+
for (let i = 0; i < steps.length; i++) {
|
| 276 |
+
const x = margin + ((steps[i] - xMin) / (xMax - xMin)) * plotWidth;
|
| 277 |
+
const y = margin + plotHeight - ((values[i] - yMinPadded) / (yMaxPadded - yMinPadded)) * plotHeight;
|
| 278 |
+
|
| 279 |
+
if (i === 0) {
|
| 280 |
+
this.ctx.moveTo(x, y);
|
| 281 |
+
} else {
|
| 282 |
+
this.ctx.lineTo(x, y);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
this.ctx.stroke();
|
| 287 |
+
this.ctx.setLineDash([]); // Reset dash pattern
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
this.ctx.restore();
|
| 291 |
+
this.renderer.drawLegend();
|
| 292 |
+
this.interaction.drawHoverEffects();
|
| 293 |
+
this.interaction.drawZoomSelection();
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
getLineWidth() {
|
| 297 |
+
if (document.fullscreenElement === this.container) {
|
| 298 |
+
return Chart.LINE_WIDTH_FULLSCREEN;
|
| 299 |
+
}
|
| 300 |
+
return Chart.LINE_WIDTH;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
getRunStyle(seriesName) {
|
| 304 |
+
return this.colorPalette.getRunStyle(seriesName);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
setupFullscreenListener() {
|
| 308 |
+
// Store handler for cleanup
|
| 309 |
+
this.fullscreenChangeHandler = () => {
|
| 310 |
+
setTimeout(() => {
|
| 311 |
+
this.updateSize();
|
| 312 |
+
}, Chart.FULLSCREEN_UPDATE_DELAY_MS);
|
| 313 |
+
};
|
| 314 |
+
document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
resetZoom() {
|
| 318 |
+
this.zoom.x = null;
|
| 319 |
+
this.zoom.y = null;
|
| 320 |
+
this.draw();
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
setExternalZoom(zoomState) {
|
| 324 |
+
if (zoomState?.x) {
|
| 325 |
+
this.zoom.x = { ...zoomState.x };
|
| 326 |
+
this.draw();
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/**
|
| 331 |
+
* Add a new data point to an existing series (SoA format)
|
| 332 |
+
* @param {string} runName - Name of the run
|
| 333 |
+
* @param {number} step - Step number
|
| 334 |
+
* @param {number} value - Metric value
|
| 335 |
+
*/
|
| 336 |
+
addDataPoint(runName, step, value) {
|
| 337 |
+
console.log(`[Chart] addDataPoint called: run=${runName}, step=${step}, value=${value}`);
|
| 338 |
+
|
| 339 |
+
if (!this.data || !this.data.series) {
|
| 340 |
+
console.warn('[Chart] No data or series available');
|
| 341 |
+
return;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// Find the series for this run
|
| 345 |
+
let series = this.data.series.find((s) => s.name === runName);
|
| 346 |
+
|
| 347 |
+
if (!series) {
|
| 348 |
+
// Create new series if it doesn't exist (SoA format)
|
| 349 |
+
series = {
|
| 350 |
+
name: runName,
|
| 351 |
+
data: { steps: [], values: [] },
|
| 352 |
+
};
|
| 353 |
+
this.data.series.push(series);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
const { steps, values } = series.data;
|
| 357 |
+
|
| 358 |
+
// Binary search to find insertion position
|
| 359 |
+
let left = 0;
|
| 360 |
+
let right = steps.length;
|
| 361 |
+
while (left < right) {
|
| 362 |
+
const mid = (left + right) >> 1;
|
| 363 |
+
if (steps[mid] < step) {
|
| 364 |
+
left = mid + 1;
|
| 365 |
+
} else if (steps[mid] > step) {
|
| 366 |
+
right = mid;
|
| 367 |
+
} else {
|
| 368 |
+
// Exact match - update existing value
|
| 369 |
+
values[mid] = value;
|
| 370 |
+
this.scheduleDraw();
|
| 371 |
+
return;
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
// Insert at the found position (usually at the end, so O(1) in practice)
|
| 376 |
+
steps.splice(left, 0, step);
|
| 377 |
+
values.splice(left, 0, value);
|
| 378 |
+
|
| 379 |
+
// Invalidate data range cache when data changes
|
| 380 |
+
this._cachedDataRanges = null;
|
| 381 |
+
|
| 382 |
+
// Schedule redraw using requestAnimationFrame to throttle updates
|
| 383 |
+
this.scheduleDraw();
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
/**
|
| 387 |
+
* Schedule a draw operation using requestAnimationFrame
|
| 388 |
+
* This prevents excessive redraws when multiple data points arrive rapidly
|
| 389 |
+
*/
|
| 390 |
+
scheduleDraw() {
|
| 391 |
+
console.log('[Chart] scheduleDraw called, pendingDraw:', this.pendingDraw);
|
| 392 |
+
|
| 393 |
+
if (this.pendingDraw) {
|
| 394 |
+
return; // Draw already scheduled
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
this.pendingDraw = true;
|
| 398 |
+
requestAnimationFrame(() => {
|
| 399 |
+
console.log('[Chart] requestAnimationFrame callback executing');
|
| 400 |
+
this.pendingDraw = false;
|
| 401 |
+
this.draw();
|
| 402 |
+
});
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/**
|
| 406 |
+
* Clean up event listeners and resources.
|
| 407 |
+
*/
|
| 408 |
+
destroy() {
|
| 409 |
+
if (this.fullscreenChangeHandler) {
|
| 410 |
+
document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
|
| 411 |
+
this.fullscreenChangeHandler = null;
|
| 412 |
+
}
|
| 413 |
+
if (this.interaction) {
|
| 414 |
+
this.interaction.removeEventListeners();
|
| 415 |
+
}
|
| 416 |
+
if (this.controls) {
|
| 417 |
+
this.controls.destroy();
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
}
|
src/aspara/dashboard/static/js/chart/color-palette.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ChartColorPalette - Color management for chart series
|
| 3 |
+
* Handles color generation, style assignment, and run-to-style mapping
|
| 4 |
+
*/
|
| 5 |
+
export class ChartColorPalette {
|
| 6 |
+
constructor() {
|
| 7 |
+
// Modern 16-color base palette with well-distributed hues for easy differentiation
|
| 8 |
+
// Colors are arranged by hue (0-360°) with ~22.5° spacing for maximum visual distinction
|
| 9 |
+
// Red-family colors and dark blues have varied saturation for better distinction
|
| 10 |
+
this.baseColors = [
|
| 11 |
+
'#FF3B47', // red (0°) - high saturation
|
| 12 |
+
'#F77F00', // orange (30°)
|
| 13 |
+
'#FCBF49', // yellow (45°)
|
| 14 |
+
'#06D6A0', // mint/turquoise (165°)
|
| 15 |
+
'#118AB2', // blue (195°)
|
| 16 |
+
'#69808b', // dark blue (200°) - higher saturation, more vivid
|
| 17 |
+
'#4361EE', // bright blue (225°)
|
| 18 |
+
'#7209B7', // purple (270°)
|
| 19 |
+
'#E85D9A', // magenta (330°) - medium saturation, lighter
|
| 20 |
+
'#B8252D', // crimson (355°) - lower saturation, darker
|
| 21 |
+
'#F4A261', // peach (35°)
|
| 22 |
+
'#2A9D8F', // teal (170°)
|
| 23 |
+
'#408828', // dark teal (190°) - lower saturation, more muted
|
| 24 |
+
'#3A86FF', // sky blue (215°)
|
| 25 |
+
'#8338EC', // violet (265°)
|
| 26 |
+
'#FF1F7D', // hot pink (340°) - very high saturation
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
// Border dash patterns for additional differentiation
|
| 30 |
+
this.borderDashPatterns = [
|
| 31 |
+
[], // solid
|
| 32 |
+
[6, 4], // dashed
|
| 33 |
+
[2, 3], // dotted
|
| 34 |
+
[10, 3, 2, 3], // dash-dot
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
// Registry to maintain stable run->style mapping
|
| 38 |
+
this.runStyleRegistry = new Map();
|
| 39 |
+
this.nextStyleIndex = 0;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Convert hex color to RGB
|
| 44 |
+
* @param {string} hex - Hex color string
|
| 45 |
+
* @returns {Object|null} RGB object or null if invalid
|
| 46 |
+
*/
|
| 47 |
+
hexToRgb(hex) {
|
| 48 |
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
| 49 |
+
return result
|
| 50 |
+
? {
|
| 51 |
+
r: Number.parseInt(result[1], 16),
|
| 52 |
+
g: Number.parseInt(result[2], 16),
|
| 53 |
+
b: Number.parseInt(result[3], 16),
|
| 54 |
+
}
|
| 55 |
+
: null;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Convert RGB to HSL
|
| 60 |
+
* @param {number} r - Red (0-255)
|
| 61 |
+
* @param {number} g - Green (0-255)
|
| 62 |
+
* @param {number} b - Blue (0-255)
|
| 63 |
+
* @returns {Object} HSL object
|
| 64 |
+
*/
|
| 65 |
+
rgbToHsl(r, g, b) {
|
| 66 |
+
const rNorm = r / 255;
|
| 67 |
+
const gNorm = g / 255;
|
| 68 |
+
const bNorm = b / 255;
|
| 69 |
+
|
| 70 |
+
const max = Math.max(rNorm, gNorm, bNorm);
|
| 71 |
+
const min = Math.min(rNorm, gNorm, bNorm);
|
| 72 |
+
let h;
|
| 73 |
+
let s;
|
| 74 |
+
const l = (max + min) / 2;
|
| 75 |
+
|
| 76 |
+
if (max === min) {
|
| 77 |
+
h = 0;
|
| 78 |
+
s = 0;
|
| 79 |
+
} else {
|
| 80 |
+
const d = max - min;
|
| 81 |
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
| 82 |
+
|
| 83 |
+
switch (max) {
|
| 84 |
+
case rNorm:
|
| 85 |
+
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
|
| 86 |
+
break;
|
| 87 |
+
case gNorm:
|
| 88 |
+
h = ((bNorm - rNorm) / d + 2) / 6;
|
| 89 |
+
break;
|
| 90 |
+
case bNorm:
|
| 91 |
+
h = ((rNorm - gNorm) / d + 4) / 6;
|
| 92 |
+
break;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return { h: h * 360, s: s * 100, l: l * 100 };
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Apply variant transformation to HSL color
|
| 101 |
+
* @param {Object} hsl - HSL color object
|
| 102 |
+
* @param {number} variantIndex - Variant index (0-2)
|
| 103 |
+
* @returns {Object} Modified HSL object
|
| 104 |
+
*/
|
| 105 |
+
applyVariant(hsl, variantIndex) {
|
| 106 |
+
const variants = [
|
| 107 |
+
{ sDelta: 0, lDelta: 0 }, // normal
|
| 108 |
+
{ sDelta: -15, lDelta: -6 }, // muted
|
| 109 |
+
{ sDelta: 8, lDelta: 6 }, // bright
|
| 110 |
+
];
|
| 111 |
+
|
| 112 |
+
const variant = variants[variantIndex];
|
| 113 |
+
let s = hsl.s + variant.sDelta;
|
| 114 |
+
let l = hsl.l + variant.lDelta;
|
| 115 |
+
|
| 116 |
+
// Clamp to safe ranges
|
| 117 |
+
s = Math.max(35, Math.min(95, s));
|
| 118 |
+
l = Math.max(30, Math.min(70, l));
|
| 119 |
+
|
| 120 |
+
return { h: hsl.h, s, l };
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Convert HSL to CSS string
|
| 125 |
+
* @param {Object} hsl - HSL color object
|
| 126 |
+
* @returns {string} CSS HSL string
|
| 127 |
+
*/
|
| 128 |
+
hslToString(hsl) {
|
| 129 |
+
return `hsl(${Math.round(hsl.h)}, ${Math.round(hsl.s)}%, ${Math.round(hsl.l)}%)`;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Generate style for a given style index
|
| 134 |
+
* @param {number} styleIndex - Style index
|
| 135 |
+
* @returns {Object} Style object with borderColor, backgroundColor, borderDash
|
| 136 |
+
*/
|
| 137 |
+
generateStyle(styleIndex) {
|
| 138 |
+
const M = this.baseColors.length; // 16
|
| 139 |
+
const V = 3; // variants
|
| 140 |
+
const D = this.borderDashPatterns.length; // 4
|
| 141 |
+
|
| 142 |
+
const baseIndex = styleIndex % M;
|
| 143 |
+
const variantIndex = Math.floor(styleIndex / M) % V;
|
| 144 |
+
const dashIndex = Math.floor(styleIndex / (M * V)) % D;
|
| 145 |
+
|
| 146 |
+
// Get base color and convert to HSL
|
| 147 |
+
const hex = this.baseColors[baseIndex];
|
| 148 |
+
const rgb = this.hexToRgb(hex);
|
| 149 |
+
const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b);
|
| 150 |
+
|
| 151 |
+
// Apply variant
|
| 152 |
+
const variantHsl = this.applyVariant(hsl, variantIndex);
|
| 153 |
+
const borderColor = this.hslToString(variantHsl);
|
| 154 |
+
|
| 155 |
+
// Get border dash pattern
|
| 156 |
+
const borderDash = this.borderDashPatterns[dashIndex];
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
borderColor,
|
| 160 |
+
backgroundColor: borderColor,
|
| 161 |
+
borderDash,
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Ensure all runs have stable styles assigned
|
| 167 |
+
* @param {Array<string>} runIds - Array of run IDs
|
| 168 |
+
*/
|
| 169 |
+
ensureRunStyles(runIds) {
|
| 170 |
+
// Sort run IDs for stable ordering
|
| 171 |
+
const sortedRunIds = [...new Set(runIds)].sort();
|
| 172 |
+
|
| 173 |
+
for (const runId of sortedRunIds) {
|
| 174 |
+
if (!this.runStyleRegistry.has(runId)) {
|
| 175 |
+
const style = this.generateStyle(this.nextStyleIndex);
|
| 176 |
+
this.runStyleRegistry.set(runId, style);
|
| 177 |
+
this.nextStyleIndex++;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Get style for a specific run
|
| 184 |
+
* @param {string} runId - Run ID
|
| 185 |
+
* @returns {Object} Style object
|
| 186 |
+
*/
|
| 187 |
+
getRunStyle(runId) {
|
| 188 |
+
return this.runStyleRegistry.get(runId) || this.generateStyle(0);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Reset the style registry
|
| 193 |
+
*/
|
| 194 |
+
reset() {
|
| 195 |
+
this.runStyleRegistry.clear();
|
| 196 |
+
this.nextStyleIndex = 0;
|
| 197 |
+
}
|
| 198 |
+
}
|
src/aspara/dashboard/static/js/chart/controls.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ICON_DOWNLOAD, ICON_FULLSCREEN, ICON_RESET_ZOOM } from '../html-utils.js';
|
| 2 |
+
|
| 3 |
+
export class ChartControls {
|
| 4 |
+
constructor(chart, chartExport) {
|
| 5 |
+
this.chart = chart;
|
| 6 |
+
this.chartExport = chartExport;
|
| 7 |
+
this.buttonContainer = null;
|
| 8 |
+
this.resetButton = null;
|
| 9 |
+
this.fullSizeButton = null;
|
| 10 |
+
this.downloadButton = null;
|
| 11 |
+
this.downloadMenu = null;
|
| 12 |
+
|
| 13 |
+
// Document click handler (stored for cleanup)
|
| 14 |
+
this.documentClickHandler = null;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
create() {
|
| 18 |
+
this.chart.container.style.position = 'relative';
|
| 19 |
+
|
| 20 |
+
this.buttonContainer = document.createElement('div');
|
| 21 |
+
this.buttonContainer.style.cssText = `
|
| 22 |
+
position: absolute;
|
| 23 |
+
top: 10px;
|
| 24 |
+
right: 10px;
|
| 25 |
+
display: flex;
|
| 26 |
+
gap: 8px;
|
| 27 |
+
z-index: 10;
|
| 28 |
+
`;
|
| 29 |
+
|
| 30 |
+
this.createResetButton();
|
| 31 |
+
this.createFullSizeButton();
|
| 32 |
+
this.createDownloadButton();
|
| 33 |
+
|
| 34 |
+
this.buttonContainer.appendChild(this.resetButton);
|
| 35 |
+
this.buttonContainer.appendChild(this.fullSizeButton);
|
| 36 |
+
this.buttonContainer.appendChild(this.downloadButton);
|
| 37 |
+
this.chart.container.appendChild(this.buttonContainer);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
createResetButton() {
|
| 41 |
+
this.resetButton = document.createElement('button');
|
| 42 |
+
this.resetButton.innerHTML = ICON_RESET_ZOOM;
|
| 43 |
+
this.resetButton.title = 'Reset zoom';
|
| 44 |
+
this.resetButton.style.cssText = `
|
| 45 |
+
width: 32px;
|
| 46 |
+
height: 32px;
|
| 47 |
+
border: 1px solid #ddd;
|
| 48 |
+
background: white;
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
border-radius: 6px;
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
justify-content: center;
|
| 54 |
+
color: #555;
|
| 55 |
+
`;
|
| 56 |
+
|
| 57 |
+
this.attachButtonHover(this.resetButton);
|
| 58 |
+
this.resetButton.addEventListener('click', () => this.chart.resetZoom());
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
createFullSizeButton() {
|
| 62 |
+
this.fullSizeButton = document.createElement('button');
|
| 63 |
+
this.fullSizeButton.innerHTML = ICON_FULLSCREEN;
|
| 64 |
+
this.fullSizeButton.title = 'Fit to full size';
|
| 65 |
+
this.fullSizeButton.style.cssText = `
|
| 66 |
+
width: 32px;
|
| 67 |
+
height: 32px;
|
| 68 |
+
border: 1px solid #ddd;
|
| 69 |
+
background: white;
|
| 70 |
+
cursor: pointer;
|
| 71 |
+
border-radius: 6px;
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
color: #555;
|
| 76 |
+
`;
|
| 77 |
+
|
| 78 |
+
this.attachButtonHover(this.fullSizeButton);
|
| 79 |
+
this.fullSizeButton.addEventListener('click', () => this.fitToFullSize());
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
createDownloadButton() {
|
| 83 |
+
this.downloadButton = document.createElement('button');
|
| 84 |
+
this.downloadButton.innerHTML = ICON_DOWNLOAD;
|
| 85 |
+
this.downloadButton.title = 'Download data';
|
| 86 |
+
this.downloadButton.style.cssText = `
|
| 87 |
+
width: 32px;
|
| 88 |
+
height: 32px;
|
| 89 |
+
border: 1px solid #ddd;
|
| 90 |
+
background: white;
|
| 91 |
+
cursor: pointer;
|
| 92 |
+
border-radius: 6px;
|
| 93 |
+
display: flex;
|
| 94 |
+
align-items: center;
|
| 95 |
+
justify-content: center;
|
| 96 |
+
color: #555;
|
| 97 |
+
position: relative;
|
| 98 |
+
`;
|
| 99 |
+
|
| 100 |
+
this.downloadMenu = document.createElement('div');
|
| 101 |
+
this.downloadMenu.style.cssText = `
|
| 102 |
+
position: absolute;
|
| 103 |
+
top: 100%;
|
| 104 |
+
right: 0;
|
| 105 |
+
background: white;
|
| 106 |
+
border: 1px solid #ddd;
|
| 107 |
+
border-radius: 6px;
|
| 108 |
+
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
| 109 |
+
display: none;
|
| 110 |
+
flex-direction: column;
|
| 111 |
+
width: 120px;
|
| 112 |
+
z-index: 20;
|
| 113 |
+
`;
|
| 114 |
+
|
| 115 |
+
const downloadOptions = [
|
| 116 |
+
{ format: 'CSV', label: 'CSV format' },
|
| 117 |
+
{ format: 'SVG', label: 'SVG image' },
|
| 118 |
+
{ format: 'PNG', label: 'PNG image' },
|
| 119 |
+
];
|
| 120 |
+
|
| 121 |
+
for (const option of downloadOptions) {
|
| 122 |
+
const menuItem = document.createElement('button');
|
| 123 |
+
menuItem.textContent = option.label;
|
| 124 |
+
menuItem.style.cssText = `
|
| 125 |
+
padding: 8px 12px;
|
| 126 |
+
text-align: left;
|
| 127 |
+
background: none;
|
| 128 |
+
border: none;
|
| 129 |
+
cursor: pointer;
|
| 130 |
+
font-size: 13px;
|
| 131 |
+
color: #333;
|
| 132 |
+
`;
|
| 133 |
+
menuItem.addEventListener('mouseenter', () => {
|
| 134 |
+
menuItem.style.background = '#f5f5f5';
|
| 135 |
+
});
|
| 136 |
+
menuItem.addEventListener('mouseleave', () => {
|
| 137 |
+
menuItem.style.background = 'none';
|
| 138 |
+
});
|
| 139 |
+
menuItem.addEventListener('click', (e) => {
|
| 140 |
+
e.stopPropagation();
|
| 141 |
+
this.chartExport.downloadData(option.format);
|
| 142 |
+
this.toggleDownloadMenu(false);
|
| 143 |
+
});
|
| 144 |
+
this.downloadMenu.appendChild(menuItem);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
this.downloadButton.appendChild(this.downloadMenu);
|
| 148 |
+
|
| 149 |
+
this.attachButtonHover(this.downloadButton);
|
| 150 |
+
this.downloadButton.addEventListener('click', () => this.toggleDownloadMenu());
|
| 151 |
+
|
| 152 |
+
// Store handler for cleanup
|
| 153 |
+
this.documentClickHandler = (e) => {
|
| 154 |
+
if (!this.downloadButton.contains(e.target)) {
|
| 155 |
+
this.toggleDownloadMenu(false);
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
document.addEventListener('click', this.documentClickHandler);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
fitToFullSize() {
|
| 162 |
+
if (!document.fullscreenElement) {
|
| 163 |
+
if (this.chart.container.requestFullscreen) {
|
| 164 |
+
this.chart.container.requestFullscreen();
|
| 165 |
+
} else if (this.chart.container.webkitRequestFullscreen) {
|
| 166 |
+
this.chart.container.webkitRequestFullscreen();
|
| 167 |
+
} else if (this.chart.container.mozRequestFullScreen) {
|
| 168 |
+
this.chart.container.mozRequestFullScreen();
|
| 169 |
+
} else if (this.chart.container.msRequestFullscreen) {
|
| 170 |
+
this.chart.container.msRequestFullscreen();
|
| 171 |
+
}
|
| 172 |
+
} else {
|
| 173 |
+
if (document.exitFullscreen) {
|
| 174 |
+
document.exitFullscreen();
|
| 175 |
+
} else if (document.webkitExitFullscreen) {
|
| 176 |
+
document.webkitExitFullscreen();
|
| 177 |
+
} else if (document.mozCancelFullScreen) {
|
| 178 |
+
document.mozCancelFullScreen();
|
| 179 |
+
} else if (document.msExitFullscreen) {
|
| 180 |
+
document.msExitFullscreen();
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
toggleDownloadMenu(forceState) {
|
| 186 |
+
const isVisible = this.downloadMenu.style.display === 'flex';
|
| 187 |
+
const newState = forceState !== undefined ? forceState : !isVisible;
|
| 188 |
+
|
| 189 |
+
this.downloadMenu.style.display = newState ? 'flex' : 'none';
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/**
|
| 193 |
+
* Attach hover effect to a button element
|
| 194 |
+
* @param {HTMLButtonElement} button - Button element to attach hover effect
|
| 195 |
+
*/
|
| 196 |
+
attachButtonHover(button) {
|
| 197 |
+
button.addEventListener('mouseenter', () => {
|
| 198 |
+
button.style.background = '#f5f5f5';
|
| 199 |
+
button.style.borderColor = '#bbb';
|
| 200 |
+
});
|
| 201 |
+
button.addEventListener('mouseleave', () => {
|
| 202 |
+
button.style.background = 'white';
|
| 203 |
+
button.style.borderColor = '#ddd';
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/**
|
| 208 |
+
* Clean up event listeners and remove DOM elements.
|
| 209 |
+
*/
|
| 210 |
+
destroy() {
|
| 211 |
+
if (this.documentClickHandler) {
|
| 212 |
+
document.removeEventListener('click', this.documentClickHandler);
|
| 213 |
+
this.documentClickHandler = null;
|
| 214 |
+
}
|
| 215 |
+
if (this.buttonContainer?.parentNode) {
|
| 216 |
+
this.buttonContainer.parentNode.removeChild(this.buttonContainer);
|
| 217 |
+
}
|
| 218 |
+
this.buttonContainer = null;
|
| 219 |
+
this.resetButton = null;
|
| 220 |
+
this.fullSizeButton = null;
|
| 221 |
+
this.downloadButton = null;
|
| 222 |
+
this.downloadMenu = null;
|
| 223 |
+
}
|
| 224 |
+
}
|
src/aspara/dashboard/static/js/chart/export-utils.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pure utility functions for chart export
|
| 3 |
+
* These functions have no side effects and are easy to test
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Generate CSV content from series data (SoA format)
|
| 8 |
+
* @param {Array} series - Array of series objects with name and data in SoA format
|
| 9 |
+
* @returns {string} CSV formatted string
|
| 10 |
+
*/
|
| 11 |
+
export function generateCSVContent(series) {
|
| 12 |
+
const lines = ['series,step,value'];
|
| 13 |
+
|
| 14 |
+
for (const s of series) {
|
| 15 |
+
if (!s.data?.steps?.length) continue;
|
| 16 |
+
const { steps, values } = s.data;
|
| 17 |
+
|
| 18 |
+
const seriesName = s.name.replace(/"/g, '""');
|
| 19 |
+
|
| 20 |
+
for (let i = 0; i < steps.length; i++) {
|
| 21 |
+
lines.push(`"${seriesName}",${steps[i]},${values[i]}`);
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return `${lines.join('\n')}\n`;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Sanitize a string for use as a filename
|
| 30 |
+
* @param {string} name - Original name
|
| 31 |
+
* @returns {string} Sanitized filename
|
| 32 |
+
*/
|
| 33 |
+
export function sanitizeFileName(name) {
|
| 34 |
+
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Get export filename from chart data
|
| 39 |
+
* @param {Object} data - Chart data object with optional title and series
|
| 40 |
+
* @returns {string} Filename without extension
|
| 41 |
+
*/
|
| 42 |
+
export function getExportFileName(data) {
|
| 43 |
+
if (data.title) {
|
| 44 |
+
return sanitizeFileName(data.title);
|
| 45 |
+
}
|
| 46 |
+
if (data.series && data.series.length === 1) {
|
| 47 |
+
return sanitizeFileName(data.series[0].name);
|
| 48 |
+
}
|
| 49 |
+
return 'chart';
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Calculate dimensions for zoomed/unzoomed export
|
| 54 |
+
* @param {Object} chart - Chart object with zoom, width, height, and MARGIN
|
| 55 |
+
* @returns {Object} Dimensions info including useZoomedArea, margin, plotWidth, plotHeight
|
| 56 |
+
*/
|
| 57 |
+
export function calculateExportDimensions(chart) {
|
| 58 |
+
const useZoomedArea = chart.zoom.x !== null || chart.zoom.y !== null;
|
| 59 |
+
const margin = chart.constructor.MARGIN;
|
| 60 |
+
const plotWidth = chart.width - margin * 2;
|
| 61 |
+
const plotHeight = chart.height - margin * 2;
|
| 62 |
+
|
| 63 |
+
return { useZoomedArea, margin, plotWidth, plotHeight };
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* Build filename with optional zoom suffix
|
| 68 |
+
* @param {string} baseName - Base filename
|
| 69 |
+
* @param {boolean} isZoomed - Whether to add zoomed suffix
|
| 70 |
+
* @returns {string} Final filename
|
| 71 |
+
*/
|
| 72 |
+
export function buildExportFileName(baseName, isZoomed) {
|
| 73 |
+
return isZoomed ? `${baseName}_zoomed` : baseName;
|
| 74 |
+
}
|
src/aspara/dashboard/static/js/chart/export.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildExportFileName, calculateExportDimensions, generateCSVContent, getExportFileName } from './export-utils.js';
|
| 2 |
+
|
| 3 |
+
export class ChartExport {
|
| 4 |
+
constructor(chart) {
|
| 5 |
+
this.chart = chart;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
downloadData(format) {
|
| 9 |
+
if (!this.chart.data || !this.chart.data.series || this.chart.data.series.length === 0) {
|
| 10 |
+
return;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
switch (format) {
|
| 14 |
+
case 'CSV':
|
| 15 |
+
this.downloadCSV();
|
| 16 |
+
break;
|
| 17 |
+
case 'SVG':
|
| 18 |
+
this.downloadSVG();
|
| 19 |
+
break;
|
| 20 |
+
case 'PNG':
|
| 21 |
+
this.downloadPNG();
|
| 22 |
+
break;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
downloadCSV() {
|
| 27 |
+
const csvContent = generateCSVContent(this.chart.data.series);
|
| 28 |
+
|
| 29 |
+
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
| 30 |
+
const url = URL.createObjectURL(blob);
|
| 31 |
+
const link = document.createElement('a');
|
| 32 |
+
|
| 33 |
+
const fileName = getExportFileName(this.chart.data);
|
| 34 |
+
|
| 35 |
+
link.setAttribute('href', url);
|
| 36 |
+
link.setAttribute('download', `${fileName}.csv`);
|
| 37 |
+
link.style.display = 'none';
|
| 38 |
+
|
| 39 |
+
document.body.appendChild(link);
|
| 40 |
+
link.click();
|
| 41 |
+
document.body.removeChild(link);
|
| 42 |
+
URL.revokeObjectURL(url);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
downloadSVG() {
|
| 46 |
+
const svgNamespace = 'http://www.w3.org/2000/svg';
|
| 47 |
+
const svg = document.createElementNS(svgNamespace, 'svg');
|
| 48 |
+
|
| 49 |
+
const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
|
| 50 |
+
|
| 51 |
+
if (useZoomedArea) {
|
| 52 |
+
svg.setAttribute('width', plotWidth);
|
| 53 |
+
svg.setAttribute('height', plotHeight);
|
| 54 |
+
svg.setAttribute('viewBox', `0 0 ${plotWidth} ${plotHeight}`);
|
| 55 |
+
|
| 56 |
+
const background = document.createElementNS(svgNamespace, 'rect');
|
| 57 |
+
background.setAttribute('width', plotWidth);
|
| 58 |
+
background.setAttribute('height', plotHeight);
|
| 59 |
+
background.setAttribute('fill', 'white');
|
| 60 |
+
svg.appendChild(background);
|
| 61 |
+
|
| 62 |
+
const tempCanvas = document.createElement('canvas');
|
| 63 |
+
tempCanvas.width = plotWidth;
|
| 64 |
+
tempCanvas.height = plotHeight;
|
| 65 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 66 |
+
|
| 67 |
+
tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
|
| 68 |
+
|
| 69 |
+
const canvasImage = document.createElementNS(svgNamespace, 'image');
|
| 70 |
+
canvasImage.setAttribute('width', plotWidth);
|
| 71 |
+
canvasImage.setAttribute('height', plotHeight);
|
| 72 |
+
canvasImage.setAttribute('href', tempCanvas.toDataURL('image/png'));
|
| 73 |
+
svg.appendChild(canvasImage);
|
| 74 |
+
} else {
|
| 75 |
+
svg.setAttribute('width', this.chart.width);
|
| 76 |
+
svg.setAttribute('height', this.chart.height);
|
| 77 |
+
svg.setAttribute('viewBox', `0 0 ${this.chart.width} ${this.chart.height}`);
|
| 78 |
+
|
| 79 |
+
const background = document.createElementNS(svgNamespace, 'rect');
|
| 80 |
+
background.setAttribute('width', this.chart.width);
|
| 81 |
+
background.setAttribute('height', this.chart.height);
|
| 82 |
+
background.setAttribute('fill', 'white');
|
| 83 |
+
svg.appendChild(background);
|
| 84 |
+
|
| 85 |
+
const canvasImage = document.createElementNS(svgNamespace, 'image');
|
| 86 |
+
canvasImage.setAttribute('width', this.chart.width);
|
| 87 |
+
canvasImage.setAttribute('height', this.chart.height);
|
| 88 |
+
canvasImage.setAttribute('href', this.chart.canvas.toDataURL('image/png'));
|
| 89 |
+
svg.appendChild(canvasImage);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const serializer = new XMLSerializer();
|
| 93 |
+
const svgString = serializer.serializeToString(svg);
|
| 94 |
+
|
| 95 |
+
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
| 96 |
+
const url = URL.createObjectURL(blob);
|
| 97 |
+
const link = document.createElement('a');
|
| 98 |
+
|
| 99 |
+
const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
|
| 100 |
+
|
| 101 |
+
link.setAttribute('href', url);
|
| 102 |
+
link.setAttribute('download', `${fileName}.svg`);
|
| 103 |
+
link.style.display = 'none';
|
| 104 |
+
|
| 105 |
+
document.body.appendChild(link);
|
| 106 |
+
link.click();
|
| 107 |
+
document.body.removeChild(link);
|
| 108 |
+
URL.revokeObjectURL(url);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
downloadPNG() {
|
| 112 |
+
const { useZoomedArea, margin, plotWidth, plotHeight } = calculateExportDimensions(this.chart);
|
| 113 |
+
|
| 114 |
+
let dataURL;
|
| 115 |
+
|
| 116 |
+
if (useZoomedArea) {
|
| 117 |
+
const tempCanvas = document.createElement('canvas');
|
| 118 |
+
tempCanvas.width = plotWidth;
|
| 119 |
+
tempCanvas.height = plotHeight;
|
| 120 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 121 |
+
|
| 122 |
+
tempCtx.drawImage(this.chart.canvas, margin, margin, plotWidth, plotHeight, 0, 0, plotWidth, plotHeight);
|
| 123 |
+
|
| 124 |
+
dataURL = tempCanvas.toDataURL('image/png');
|
| 125 |
+
} else {
|
| 126 |
+
dataURL = this.chart.canvas.toDataURL('image/png');
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const fileName = buildExportFileName(getExportFileName(this.chart.data), useZoomedArea);
|
| 130 |
+
|
| 131 |
+
const link = document.createElement('a');
|
| 132 |
+
link.setAttribute('href', dataURL);
|
| 133 |
+
link.setAttribute('download', `${fileName}.png`);
|
| 134 |
+
link.style.display = 'none';
|
| 135 |
+
|
| 136 |
+
document.body.appendChild(link);
|
| 137 |
+
link.click();
|
| 138 |
+
document.body.removeChild(link);
|
| 139 |
+
}
|
| 140 |
+
}
|
src/aspara/dashboard/static/js/chart/interaction-utils.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Pure utility functions for chart interaction
|
| 3 |
+
* These functions have no side effects and are easy to test
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Calculate data ranges from series data (SoA format)
|
| 8 |
+
* @param {Array} series - Array of series objects with data in SoA format { steps: [], values: [] }
|
| 9 |
+
* @returns {Object|null} Object with xMin, xMax, yMin, yMax or null if no data
|
| 10 |
+
*/
|
| 11 |
+
export function calculateDataRanges(series) {
|
| 12 |
+
let xMin = Number.POSITIVE_INFINITY;
|
| 13 |
+
let xMax = Number.NEGATIVE_INFINITY;
|
| 14 |
+
let yMin = Number.POSITIVE_INFINITY;
|
| 15 |
+
let yMax = Number.NEGATIVE_INFINITY;
|
| 16 |
+
|
| 17 |
+
for (const s of series) {
|
| 18 |
+
if (!s.data?.steps?.length) continue;
|
| 19 |
+
const { steps, values } = s.data;
|
| 20 |
+
|
| 21 |
+
// steps are sorted, so O(1) for min/max
|
| 22 |
+
xMin = Math.min(xMin, steps[0]);
|
| 23 |
+
xMax = Math.max(xMax, steps[steps.length - 1]);
|
| 24 |
+
|
| 25 |
+
// values min/max
|
| 26 |
+
for (let i = 0; i < values.length; i++) {
|
| 27 |
+
if (values[i] < yMin) yMin = values[i];
|
| 28 |
+
if (values[i] > yMax) yMax = values[i];
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return xMin === Number.POSITIVE_INFINITY ? null : { xMin, xMax, yMin, yMax };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Binary search to find the nearest step in sorted steps array (SoA format)
|
| 37 |
+
* @param {Array} steps - Sorted array of step values
|
| 38 |
+
* @param {number} targetStep - Target step value
|
| 39 |
+
* @returns {Object|null} Object with { index, step } or null if empty
|
| 40 |
+
*/
|
| 41 |
+
export function binarySearchNearestStep(steps, targetStep) {
|
| 42 |
+
if (!steps?.length) return null;
|
| 43 |
+
if (steps.length === 1) return { index: 0, step: steps[0] };
|
| 44 |
+
|
| 45 |
+
let left = 0;
|
| 46 |
+
let right = steps.length - 1;
|
| 47 |
+
|
| 48 |
+
// Handle edge cases: target is outside data range
|
| 49 |
+
if (targetStep <= steps[left]) return { index: left, step: steps[left] };
|
| 50 |
+
if (targetStep >= steps[right]) return { index: right, step: steps[right] };
|
| 51 |
+
|
| 52 |
+
// Binary search to find the two closest points
|
| 53 |
+
while (left < right - 1) {
|
| 54 |
+
const mid = (left + right) >> 1;
|
| 55 |
+
if (steps[mid] <= targetStep) {
|
| 56 |
+
left = mid;
|
| 57 |
+
} else {
|
| 58 |
+
right = mid;
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Compare left and right to find nearest
|
| 63 |
+
const leftDist = Math.abs(steps[left] - targetStep);
|
| 64 |
+
const rightDist = Math.abs(steps[right] - targetStep);
|
| 65 |
+
return leftDist <= rightDist ? { index: left, step: steps[left] } : { index: right, step: steps[right] };
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Binary search to find a point by step value (SoA format)
|
| 70 |
+
* @param {Array} steps - Sorted array of step values
|
| 71 |
+
* @param {Array} values - Array of values corresponding to steps
|
| 72 |
+
* @param {number} step - Step value to find
|
| 73 |
+
* @returns {Object|null} Object with { index, step, value } or null if not found
|
| 74 |
+
*/
|
| 75 |
+
export function binarySearchByStep(steps, values, step) {
|
| 76 |
+
if (!steps?.length) return null;
|
| 77 |
+
|
| 78 |
+
let left = 0;
|
| 79 |
+
let right = steps.length - 1;
|
| 80 |
+
|
| 81 |
+
while (left <= right) {
|
| 82 |
+
const mid = (left + right) >> 1;
|
| 83 |
+
if (steps[mid] === step) {
|
| 84 |
+
return { index: mid, step: steps[mid], value: values[mid] };
|
| 85 |
+
}
|
| 86 |
+
if (steps[mid] < step) {
|
| 87 |
+
left = mid + 1;
|
| 88 |
+
} else {
|
| 89 |
+
right = mid - 1;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return null;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Find the nearest step using binary search (SoA format, optimized version)
|
| 98 |
+
* Handles LTTB downsampling where each series may have different steps.
|
| 99 |
+
* @param {number} mouseX - Mouse X coordinate
|
| 100 |
+
* @param {Array} series - Series data in SoA format (may have different steps per series due to LTTB)
|
| 101 |
+
* @param {number} margin - Chart margin
|
| 102 |
+
* @param {number} plotWidth - Plot width
|
| 103 |
+
* @param {number} xMin - X axis minimum
|
| 104 |
+
* @param {number} xMax - X axis maximum
|
| 105 |
+
* @returns {number|null} Nearest step value or null
|
| 106 |
+
*/
|
| 107 |
+
export function findNearestStepBinary(mouseX, series, margin, plotWidth, xMin, xMax) {
|
| 108 |
+
// Convert mouse X to target step value
|
| 109 |
+
const targetStep = xMin + ((mouseX - margin) / plotWidth) * (xMax - xMin);
|
| 110 |
+
|
| 111 |
+
let nearestStep = null;
|
| 112 |
+
let minDistance = Number.POSITIVE_INFINITY;
|
| 113 |
+
|
| 114 |
+
// Search each series for the nearest step (handles LTTB where series have different steps)
|
| 115 |
+
// Complexity: O(N × log M) where N = series count, M = points per series
|
| 116 |
+
for (const s of series) {
|
| 117 |
+
if (!s.data?.steps?.length) continue;
|
| 118 |
+
|
| 119 |
+
// Binary search to find nearest step in this series
|
| 120 |
+
const result = binarySearchNearestStep(s.data.steps, targetStep);
|
| 121 |
+
if (result === null) continue;
|
| 122 |
+
|
| 123 |
+
const distance = Math.abs(result.step - targetStep);
|
| 124 |
+
if (distance < minDistance) {
|
| 125 |
+
minDistance = distance;
|
| 126 |
+
nearestStep = result.step;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return nearestStep;
|
| 131 |
+
}
|