Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- trackio/CHANGELOG.md +400 -0
- trackio/__init__.py +1216 -0
- trackio/_vendor/__init__.py +0 -0
- trackio/_vendor/gradio_exceptions.py +6 -0
- trackio/_vendor/networking.py +81 -0
- trackio/_vendor/tunneling.py +191 -0
- trackio/alerts.py +184 -0
- trackio/api.py +99 -0
- trackio/apple_gpu.py +206 -0
- trackio/artifact.py +552 -0
- trackio/asgi_app.py +531 -0
- trackio/assets/badge.png +3 -0
- trackio/assets/trackio_logo_dark.png +0 -0
- trackio/assets/trackio_logo_light.png +0 -0
- trackio/assets/trackio_logo_old.png +3 -0
- trackio/assets/trackio_logo_type_dark.png +0 -0
- trackio/assets/trackio_logo_type_dark_transparent.png +0 -0
- trackio/assets/trackio_logo_type_light.png +0 -0
- trackio/assets/trackio_logo_type_light_transparent.png +0 -0
- trackio/bucket_storage.py +208 -0
- trackio/cas.py +214 -0
- trackio/cli.py +2307 -0
- trackio/cli_helpers.py +268 -0
- trackio/commit_scheduler.py +310 -0
- trackio/compression.py +169 -0
- trackio/context_vars.py +21 -0
- trackio/cpu.py +303 -0
- trackio/deploy.py +1313 -0
- trackio/dummy_commit_scheduler.py +19 -0
- trackio/exceptions.py +2 -0
- trackio/fragments.py +368 -0
- trackio/frontend/dist/assets/index-m3jgYXoS.css +1 -0
- trackio/frontend/dist/assets/index-nILgi7uv.js +0 -0
- trackio/frontend/dist/index.html +14 -0
- trackio/frontend/eslint.config.js +42 -0
- trackio/frontend/index.html +13 -0
- trackio/frontend_config.py +175 -0
- trackio/frontend_server.py +175 -0
- trackio/frontend_templates/logbook/bucket-icon.svg +5 -0
- trackio/frontend_templates/logbook/index.html +54 -0
- trackio/frontend_templates/logbook/logbook.css +1602 -0
- trackio/frontend_templates/logbook/logbook.js +2275 -0
- trackio/frontend_templates/logbook/trackio-logo-light.png +0 -0
- trackio/frontend_templates/logbook/trackio-logo.png +0 -0
- trackio/frontend_templates/logbook/trackio-wordmark-dark.png +0 -0
- trackio/frontend_templates/starter/app.js +555 -0
- trackio/frontend_templates/starter/index.html +135 -0
- trackio/frontend_templates/starter/styles.css +467 -0
- trackio/gpu.py +381 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
trackio/assets/badge.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
trackio/assets/trackio_logo_old.png filter=lfs diff=lfs merge=lfs -text
|
trackio/CHANGELOG.md
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# trackio
|
| 2 |
+
|
| 3 |
+
## 0.31.4
|
| 4 |
+
|
| 5 |
+
### Features
|
| 6 |
+
|
| 7 |
+
- [#628](https://github.com/gradio-app/trackio/pull/628) [`3eeaa99`](https://github.com/gradio-app/trackio/commit/3eeaa99bd05bef69053b9a1b5c7af3abf56c360c) - let `logbook cell figure` embed image files directly. Thanks @abidlabs!/n `trackio logbook cell figure` now accepts an image path via a new `--image`/n flag, and `--html <file>` transparently embeds the file when it points at an/n image. Previously the only way to add a PNG/JPG figure was to hand-encode it/n into an `<img>` data-URI, and passing an image path to `--html` crashed with a/n `UnicodeDecodeError` (the binary file was read as UTF-8 text). Images are/n embedded as responsive base64 data URIs. The Python API `add_figure_cell` gains/n a matching `image=` parameter.
|
| 8 |
+
|
| 9 |
+
## 0.31.3
|
| 10 |
+
|
| 11 |
+
### Features
|
| 12 |
+
|
| 13 |
+
- [#626](https://github.com/gradio-app/trackio/pull/626) [`174aecf`](https://github.com/gradio-app/trackio/commit/174aecff33e6d1cc8d71965a73538c0510abed46) - Fix logbook Markdown escaping and figure hotspot navigation. Thanks @abidlabs!
|
| 14 |
+
|
| 15 |
+
## 0.31.2
|
| 16 |
+
|
| 17 |
+
### Features
|
| 18 |
+
|
| 19 |
+
- [#624](https://github.com/gradio-app/trackio/pull/624) [`a0918ed`](https://github.com/gradio-app/trackio/commit/a0918edc7594bb211f9e6871546b9d36939f0658) - Fix poster hotspot navigation after iframe load. Thanks @abidlabs!
|
| 20 |
+
|
| 21 |
+
## 0.31.1
|
| 22 |
+
|
| 23 |
+
### Features
|
| 24 |
+
|
| 25 |
+
- [#621](https://github.com/gradio-app/trackio/pull/621) [`a40f1a4`](https://github.com/gradio-app/trackio/commit/a40f1a464c51652ecdeab04d999659d71b76047e) - add native fullscreen mode for logbook figures. Thanks @abidlabs!
|
| 26 |
+
- [#623](https://github.com/gradio-app/trackio/pull/623) [`31fd4d7`](https://github.com/gradio-app/trackio/commit/31fd4d7b4004f0c511484f089e6a550da1b4d71e) - let interactive logbook figures navigate to logbook pages. Thanks @abidlabs!
|
| 27 |
+
|
| 28 |
+
## 0.31.0
|
| 29 |
+
|
| 30 |
+
### Features
|
| 31 |
+
|
| 32 |
+
- [#601](https://github.com/gradio-app/trackio/pull/601) [`8c49140`](https://github.com/gradio-app/trackio/commit/8c491406c54685369d43f3a9c4d4a0e99108bc49) - browse artifacts from the UI. Thanks @Saba9!
|
| 33 |
+
- [#602](https://github.com/gradio-app/trackio/pull/602) [`1ae1558`](https://github.com/gradio-app/trackio/commit/1ae155803b5450b181a47767255421499074c70b) - feat: add Artifact.add_reference to reference external objects without copy. Thanks @adrien-grl!
|
| 34 |
+
|
| 35 |
+
## 0.30.4
|
| 36 |
+
|
| 37 |
+
### Features
|
| 38 |
+
|
| 39 |
+
- [#618](https://github.com/gradio-app/trackio/pull/618) [`64c18f6`](https://github.com/gradio-app/trackio/commit/64c18f65bf98d6f54040395a1ca0473347ba6a2b) - Logbook: share button on figures + `logbook pin` CLI. Thanks @abidlabs!
|
| 40 |
+
|
| 41 |
+
## 0.30.3
|
| 42 |
+
|
| 43 |
+
### Features
|
| 44 |
+
|
| 45 |
+
- [#616](https://github.com/gradio-app/trackio/pull/616) [`645498b`](https://github.com/gradio-app/trackio/commit/645498be37bafab28f884a36e80c5bc0eab06cd9) - Logbook single page. Thanks @abidlabs!
|
| 46 |
+
|
| 47 |
+
## 0.30.2
|
| 48 |
+
|
| 49 |
+
### Features
|
| 50 |
+
|
| 51 |
+
- [#614](https://github.com/gradio-app/trackio/pull/614) [`eba80c7`](https://github.com/gradio-app/trackio/commit/eba80c7464af250519a90b8ac3179ff6652b651a) - Render logbooks as single-page documents with section navigation and contextual resources. Thanks @abidlabs!
|
| 52 |
+
- [#612](https://github.com/gradio-app/trackio/pull/612) [`2c1b943`](https://github.com/gradio-app/trackio/commit/2c1b9436b093e9f9335c99c10fe4b6afa622e314) - Warn that logbook open is experimental. Thanks @abidlabs!
|
| 53 |
+
- [#611](https://github.com/gradio-app/trackio/pull/611) [`96b1d94`](https://github.com/gradio-app/trackio/commit/96b1d94def7596aa1302864cbfc57942dd63822b) - Logbook: auto-capture output artifacts and live-embedded dashboard cells. Thanks @abidlabs!
|
| 54 |
+
|
| 55 |
+
## 0.30.1
|
| 56 |
+
|
| 57 |
+
### Features
|
| 58 |
+
|
| 59 |
+
- [#609](https://github.com/gradio-app/trackio/pull/609) [`2ed1295`](https://github.com/gradio-app/trackio/commit/2ed12959c73aa9c04ca1096cf7a8315ce2886ed1) - Serve live logbook preview from open. Thanks @abidlabs!
|
| 60 |
+
|
| 61 |
+
## 0.30.0
|
| 62 |
+
|
| 63 |
+
### Features
|
| 64 |
+
|
| 65 |
+
- [#603](https://github.com/gradio-app/trackio/pull/603) [`a793fd8`](https://github.com/gradio-app/trackio/commit/a793fd88827482134bce295c6228e117de64566a) - Move default artifact downloads under `./.trackio/artifact-downloads/` so materialized files stay out of the project root. Thanks @abidlabs!
|
| 66 |
+
- [#604](https://github.com/gradio-app/trackio/pull/604) [`dd3b8ee`](https://github.com/gradio-app/trackio/commit/dd3b8eeef4e0da8bf0df6238617e20ae81f3e564) - docs: clarify artifact types. Thanks @abidlabs!
|
| 67 |
+
- [#586](https://github.com/gradio-app/trackio/pull/586) [`7db4c3b`](https://github.com/gradio-app/trackio/commit/7db4c3b6de6ff4903daf7dabed8842f212c8b7fd) - artifact tracking API — `log_artifact`, `use_artifact`, and an `Artifact` class for versioned, named file collections with aliases, content-addressed deduplication, and producer/consumer run lineage; works offline and syncs blobs to Hugging Face Spaces/Datasets. Thanks @Saba9!
|
| 68 |
+
- [#597](https://github.com/gradio-app/trackio/pull/597) [`5ba561e`](https://github.com/gradio-app/trackio/commit/5ba561e1b4023abdf63624f4fb396367ed6e9e76) - Add experiment logbooks: shareable static-Space lab notebooks. Thanks @abidlabs!
|
| 69 |
+
|
| 70 |
+
## 0.29.0
|
| 71 |
+
|
| 72 |
+
### Features
|
| 73 |
+
|
| 74 |
+
- [#593](https://github.com/gradio-app/trackio/pull/593) [`ca4722c`](https://github.com/gradio-app/trackio/commit/ca4722c19d2219a3a78b5977149873b9dff73f34) - Add CLI command to list Trackio Spaces. Thanks @abidlabs!
|
| 75 |
+
|
| 76 |
+
## 0.28.1
|
| 77 |
+
|
| 78 |
+
### Features
|
| 79 |
+
|
| 80 |
+
- [#589](https://github.com/gradio-app/trackio/pull/589) [`5e9905d`](https://github.com/gradio-app/trackio/commit/5e9905d7815bb063f2d9bb38aa76177405c5e2b4) - Reduce Metrics page log payloads by requesting scalar-only metrics and add URL x-axis initialization/fallback handling. Thanks @evalstate!
|
| 81 |
+
- [#590](https://github.com/gradio-app/trackio/pull/590) [`56493a6`](https://github.com/gradio-app/trackio/commit/56493a6e9a0114b8655ab44b8c3dec7bae2a5df5) - Use TRACKIO_WRITE_TOKEN for server write token. Thanks @abidlabs!
|
| 82 |
+
|
| 83 |
+
## 0.28.0
|
| 84 |
+
|
| 85 |
+
### Features
|
| 86 |
+
|
| 87 |
+
- [#582](https://github.com/gradio-app/trackio/pull/582) [`d9dabb5`](https://github.com/gradio-app/trackio/commit/d9dabb5bda8147ad7c9203646358bd0880fa6521) - Add JSONL inbox fragments: durable logging when the Space is unreachable and on network filesystems. Thanks @abidlabs!
|
| 88 |
+
|
| 89 |
+
## 0.27.0
|
| 90 |
+
|
| 91 |
+
### Features
|
| 92 |
+
|
| 93 |
+
- [#567](https://github.com/gradio-app/trackio/pull/567) [`8c0ce21`](https://github.com/gradio-app/trackio/commit/8c0ce21ff9746d7c6a250684b1b5f0f3f04e0520) - Add CPU, RAM, disk, network and sensor metrics monitoring for non-Apple devices. Thanks @yannsadowski!
|
| 94 |
+
- [#571](https://github.com/gradio-app/trackio/pull/571) [`57bc7df`](https://github.com/gradio-app/trackio/commit/57bc7df27ecd7b3ff543dbd2bff5c94182bfb69e) - Fix IndexError when importing CSV with no numeric metric columns. Thanks @Ckal!
|
| 95 |
+
- [#577](https://github.com/gradio-app/trackio/pull/577) [`db0e730`](https://github.com/gradio-app/trackio/commit/db0e730463103142688c1c0efef9ff68a52e281d) - Sort and paginate media items. Thanks @abidlabs!
|
| 96 |
+
- [#576](https://github.com/gradio-app/trackio/pull/576) [`6f635e2`](https://github.com/gradio-app/trackio/commit/6f635e22553ce2deaece6a0d6289475ab8683040) - Render plot/metric titles as searchable DOM text. Thanks @abidlabs!
|
| 97 |
+
- [#579](https://github.com/gradio-app/trackio/pull/579) [`cf7759e`](https://github.com/gradio-app/trackio/commit/cf7759efaab73ce1e98a8a2355dc1f65d54bc775) - Merge image viewer with current media page, credit to @catwell. Thanks @abidlabs!
|
| 98 |
+
|
| 99 |
+
## 0.26.0
|
| 100 |
+
|
| 101 |
+
### Features
|
| 102 |
+
|
| 103 |
+
- [#553](https://github.com/gradio-app/trackio/pull/553) [`06011ac`](https://github.com/gradio-app/trackio/commit/06011acc9c73341fd234f9cd8eaf96d5a34ad8ce) - fix: serve static-Space assets at /static/trackio. Thanks @abidlabs!
|
| 104 |
+
- [#563](https://github.com/gradio-app/trackio/pull/563) [`551569c`](https://github.com/gradio-app/trackio/commit/551569c16fb56ec63249ebdc28348d326ccf7126) - Fix Traces UI a bit. Thanks @abidlabs!
|
| 105 |
+
- [#550](https://github.com/gradio-app/trackio/pull/550) [`5690acd`](https://github.com/gradio-app/trackio/commit/5690acda5da303c63ad332451afeab3e9750fd1a) - fix: keep sparse metrics sparse through smoothing. Thanks @abidlabs!
|
| 106 |
+
- [#551](https://github.com/gradio-app/trackio/pull/551) [`0ef7266`](https://github.com/gradio-app/trackio/commit/0ef72660695cf932f3906ddbf33d41d087280a22) - add "group by" dropdown to sidebar. Thanks @Saba9!
|
| 107 |
+
- [#538](https://github.com/gradio-app/trackio/pull/538) [`a15c1a8`](https://github.com/gradio-app/trackio/commit/a15c1a8877c07514e0596630bb7c7299299994a9) - Subdue empty dashboard tabs. Thanks @abidlabs!
|
| 108 |
+
- [#559](https://github.com/gradio-app/trackio/pull/559) [`0b53a41`](https://github.com/gradio-app/trackio/commit/0b53a413909598f92138b6b6395a91c2d5034faf) - Store traces separately from metrics. Thanks @abidlabs!
|
| 109 |
+
- [#556](https://github.com/gradio-app/trackio/pull/556) [`d110001`](https://github.com/gradio-app/trackio/commit/d110001dbd9f6b262dfe41f2b702e3a71aa0cfc9) - fix: keep selected x-axis option in dropdown and dismiss dropdown on re-click. Thanks @Saba9!
|
| 110 |
+
- [#560](https://github.com/gradio-app/trackio/pull/560) [`aee2923`](https://github.com/gradio-app/trackio/commit/aee2923d3ada4f74d62d065c16f1f6a56a295e48) - Paginate Traces tab with step filter. Thanks @abidlabs!
|
| 111 |
+
|
| 112 |
+
### Fixes
|
| 113 |
+
|
| 114 |
+
- [#540](https://github.com/gradio-app/trackio/pull/540) [`0b674ac`](https://github.com/gradio-app/trackio/commit/0b674ac6438738de89bc5b3fb700ccfd8a39368c) - raise default metrics sampling cap from 1500 to 3000 so client-side smoothing on the Metrics tab runs over higher-resolution data. Thanks @edbeeching!
|
| 115 |
+
|
| 116 |
+
## 0.25.1
|
| 117 |
+
|
| 118 |
+
### Features
|
| 119 |
+
|
| 120 |
+
- [#535](https://github.com/gradio-app/trackio/pull/535) [`d7f1b27`](https://github.com/gradio-app/trackio/commit/d7f1b27a98f185d2d97ef54975d5865e0b5243c9) - Avoid HF token leaks in static snapshots. Thanks @abidlabs!
|
| 121 |
+
|
| 122 |
+
## 0.25.0
|
| 123 |
+
|
| 124 |
+
### Features
|
| 125 |
+
|
| 126 |
+
- [#533](https://github.com/gradio-app/trackio/pull/533) [`08bc5eb`](https://github.com/gradio-app/trackio/commit/08bc5eb090525d3ff5f7fa4233c30c42162aa74c) - Fix Windows-only emoji mojibake when uploading Space README. Thanks @tomaarsen!
|
| 127 |
+
- [#518](https://github.com/gradio-app/trackio/pull/518) [`e7ed176`](https://github.com/gradio-app/trackio/commit/e7ed176da53d8b49290fddd890b3d18c0b9b958f) - Traces in Trackio. Thanks @abidlabs!
|
| 128 |
+
- [#531](https://github.com/gradio-app/trackio/pull/531) [`27a50a3`](https://github.com/gradio-app/trackio/commit/27a50a37362020304b774344b4c774ff548985b6) - Add configurable custom frontends for Trackio. Thanks @abidlabs!
|
| 129 |
+
|
| 130 |
+
## 0.24.2
|
| 131 |
+
|
| 132 |
+
### Features
|
| 133 |
+
|
| 134 |
+
- [#527](https://github.com/gradio-app/trackio/pull/527) [`7d1c0b9`](https://github.com/gradio-app/trackio/commit/7d1c0b9c37ce9a9845e6bbe6c083da9d36084caf) - Fix dashboard UX issues: smoothing in share URL, run selection, and run filtering. Thanks @abidlabs!
|
| 135 |
+
- [#526](https://github.com/gradio-app/trackio/pull/526) [`643878a`](https://github.com/gradio-app/trackio/commit/643878a82985fab9e6675f769ff0107cb46e042a) - Add emoji to README and deploy README content. Thanks @qgallouedec!
|
| 136 |
+
- [#529](https://github.com/gradio-app/trackio/pull/529) [`a77972b`](https://github.com/gradio-app/trackio/commit/a77972b68541ebe9e056824e69c9bbca3979ece4) - Remove pydub dependency. Thanks @abidlabs!
|
| 137 |
+
|
| 138 |
+
## 0.24.1
|
| 139 |
+
|
| 140 |
+
### Features
|
| 141 |
+
|
| 142 |
+
- [#524](https://github.com/gradio-app/trackio/pull/524) [`65a6897`](https://github.com/gradio-app/trackio/commit/65a6897561b465fc8f05550562f8da1ba3c99060) - Fix `trackio skills add`. Thanks @abidlabs!
|
| 143 |
+
- [#522](https://github.com/gradio-app/trackio/pull/522) [`05aaca7`](https://github.com/gradio-app/trackio/commit/05aaca7f166bf7667b60b40656d677532a4bdd6e) - relax `starlette` dependency and fix import style. Thanks @abidlabs!
|
| 144 |
+
- [#525](https://github.com/gradio-app/trackio/pull/525) [`32c05c5`](https://github.com/gradio-app/trackio/commit/32c05c5d5e3aa84ca7099a6fa08a9093ccd4b95f) - Restore sidebar share and embed snippets, and fix query parameter regression. Thanks @abidlabs!
|
| 145 |
+
|
| 146 |
+
## 0.24.0
|
| 147 |
+
|
| 148 |
+
### Features
|
| 149 |
+
|
| 150 |
+
- [#502](https://github.com/gradio-app/trackio/pull/502) [`3b397df`](https://github.com/gradio-app/trackio/commit/3b397dfbaff9de137b088f3cad528117e14faab1) - Add docs on SQL & Parquet schema / format, as well as a new CLI command: `trackio query project --project PROJECT --sql SQL_QUERY`. Thanks @abidlabs!
|
| 151 |
+
- [#506](https://github.com/gradio-app/trackio/pull/506) [`498bbc4`](https://github.com/gradio-app/trackio/commit/498bbc47f66cc90cc5776f363d001a5571941c00) - Scope bucket sync to trackio/ subtree to avoid walking the HF cache. Thanks @abidlabs!
|
| 152 |
+
- [#505](https://github.com/gradio-app/trackio/pull/505) [`8e26ab9`](https://github.com/gradio-app/trackio/commit/8e26ab93b5d9caa2f81334f6fff42fb9cefbb232) - Add an `id` field to `Run` which is used internally, allowing users to have multiple runs with the same run name. Thanks @abidlabs!
|
| 153 |
+
- [#517](https://github.com/gradio-app/trackio/pull/517) [`29e1034`](https://github.com/gradio-app/trackio/commit/29e1034b795567ec5ed6d19c5a946915a6498e2a) - Fix static exports, Space bucket handling, and other misc issues. Thanks @abidlabs!
|
| 154 |
+
- [#489](https://github.com/gradio-app/trackio/pull/489) [`1b96db3`](https://github.com/gradio-app/trackio/commit/1b96db39c8fd4326e621ee2336b0fca4f263a18a) - Remove `gradio` dependency in `trackio` -- only `gradio_client` is needed locally anymore. Also lazily import `pandas` and remove it as a dependency. Thanks @abidlabs!
|
| 155 |
+
- [#513](https://github.com/gradio-app/trackio/pull/513) [`d54d290`](https://github.com/gradio-app/trackio/commit/d54d290fcb1bb08358b558a43a962f78abe990ea) - Reduce HF Spaces 429s: polling tuning and batched metric logs API. Thanks @abidlabs!
|
| 156 |
+
- [#516](https://github.com/gradio-app/trackio/pull/516) [`afe2959`](https://github.com/gradio-app/trackio/commit/afe295988928a3ea3ded38bdb5bb05cca85d3c74) - Fix run list order and legend overflow. Thanks @abidlabs!
|
| 157 |
+
- [#515](https://github.com/gradio-app/trackio/pull/515) [`0a242b8`](https://github.com/gradio-app/trackio/commit/0a242b85127b02f532b24c7fd2bb046580cc7641) - Add Gradio-compatible /gradio_api routes on Spaces. Thanks @abidlabs!
|
| 158 |
+
- [#510](https://github.com/gradio-app/trackio/pull/510) [`60bbc86`](https://github.com/gradio-app/trackio/commit/60bbc86b4e7f880de72075e5bf31b093709bb5a4) - Add server_url and TRACKIO_SERVER_URL for self-hosted servers; space_id and TRACKIO_SPACE_ID take precedence when both are set. Thanks @abidlabs!
|
| 159 |
+
- [#509](https://github.com/gradio-app/trackio/pull/509) [`21c099a`](https://github.com/gradio-app/trackio/commit/21c099aa830a278973fab4c7c58a0139f417caa4) - Fix: Open browser with write_token so trackio show allows mutations. Thanks @abidlabs!
|
| 160 |
+
|
| 161 |
+
## 0.23.0
|
| 162 |
+
|
| 163 |
+
### Features
|
| 164 |
+
|
| 165 |
+
- [#494](https://github.com/gradio-app/trackio/pull/494) [`e8a897d`](https://github.com/gradio-app/trackio/commit/e8a897d2266d9b2558f72d768b0b21f4d0a8781b) - Add a settings/CLI page to Trackio. Thanks @abidlabs!
|
| 166 |
+
- [#481](https://github.com/gradio-app/trackio/pull/481) [`882647e`](https://github.com/gradio-app/trackio/commit/882647ec1599cf04500d03b5ca75ddc2733682e2) - Add multi-GPU system metrics support. Thanks @Saba9!
|
| 167 |
+
- [#485](https://github.com/gradio-app/trackio/pull/485) [`46a3cc3`](https://github.com/gradio-app/trackio/commit/46a3cc3758719e171417612efee102a487e71ebd) - Fix/remove flaky E2E space tests. Thanks @abidlabs!
|
| 168 |
+
- [#501](https://github.com/gradio-app/trackio/pull/501) [`06ea885`](https://github.com/gradio-app/trackio/commit/06ea8852f5e40ab3f1cf629a0a01af5c17f847a1) - Fix SQLite corruption on bucket-mounted Spaces. Thanks @abidlabs!
|
| 169 |
+
- [#496](https://github.com/gradio-app/trackio/pull/496) [`af23d74`](https://github.com/gradio-app/trackio/commit/af23d74438b146c4a3512ace15ea984656e943ed) - Prevent trackio errors from crashing the user's training loop. Thanks @abidlabs!
|
| 170 |
+
|
| 171 |
+
## 0.22.0
|
| 172 |
+
|
| 173 |
+
### Features
|
| 174 |
+
|
| 175 |
+
- [#484](https://github.com/gradio-app/trackio/pull/484) [`cc05ada`](https://github.com/gradio-app/trackio/commit/cc05ada8e89773f3a894af99b801ef680f64418f) - Fix duplicate columns in parquet export. Thanks @abidlabs!
|
| 176 |
+
- [#487](https://github.com/gradio-app/trackio/pull/487) [`853f764`](https://github.com/gradio-app/trackio/commit/853f7646a70d12633afaa4f69db86425aa665413) - Relax `PIL` dependency and remove `plotly` as it's no longer used. Thanks @abidlabs!
|
| 177 |
+
|
| 178 |
+
## 0.21.2
|
| 179 |
+
|
| 180 |
+
### Features
|
| 181 |
+
|
| 182 |
+
- [#482](https://github.com/gradio-app/trackio/pull/482) [`f62180a`](https://github.com/gradio-app/trackio/commit/f62180a0218bc99a259d5ca110a0384a6cae11c8) - Use server-side bucket copy when freezing Spaces. Thanks @abidlabs!
|
| 183 |
+
|
| 184 |
+
## 0.21.1
|
| 185 |
+
|
| 186 |
+
### Features
|
| 187 |
+
|
| 188 |
+
- [#475](https://github.com/gradio-app/trackio/pull/475) [`fcb476c`](https://github.com/gradio-app/trackio/commit/fcb476cd37a40923e9679aaf966f41d582a878a8) - Tweaks. Thanks @abidlabs!
|
| 189 |
+
- [#477](https://github.com/gradio-app/trackio/pull/477) [`7d52dfd`](https://github.com/gradio-app/trackio/commit/7d52dfdce5b6eff6a34501a6d5a620220663cf09) - Fix `.sync()` and add `.freeze()` as a separate methods. Thanks @abidlabs!
|
| 190 |
+
|
| 191 |
+
## 0.21.0
|
| 192 |
+
|
| 193 |
+
### Features
|
| 194 |
+
|
| 195 |
+
- [#467](https://github.com/gradio-app/trackio/pull/467) [`f357deb`](https://github.com/gradio-app/trackio/commit/f357debf78957e4c1f2b901bee4f77cf397298b4) - Allow logged metrics as x-axis choices. Thanks @abidlabs!
|
| 196 |
+
- [#474](https://github.com/gradio-app/trackio/pull/474) [`655673d`](https://github.com/gradio-app/trackio/commit/655673d4c6b7c8b7ee8f87f2589f2dbbc3d2ef91) - Fix file descriptor leak from `sqlite3.connect`. Thanks @abidlabs!
|
| 197 |
+
- [#470](https://github.com/gradio-app/trackio/pull/470) [`bea8c9d`](https://github.com/gradio-app/trackio/commit/bea8c9dcae0b59d071b6c779c97ee525c9bbf6e7) - Restores tooltips to line plots and fixes the call to uses TTL instead of OAuth. Thanks @abidlabs!
|
| 198 |
+
- [#471](https://github.com/gradio-app/trackio/pull/471) [`246fce0`](https://github.com/gradio-app/trackio/commit/246fce0a01619e1c2c538c67b3e460883334d500) - Deprecate dataset backend in favor of buckets. Thanks @abidlabs!
|
| 199 |
+
- [#465](https://github.com/gradio-app/trackio/pull/465) [`3e11174`](https://github.com/gradio-app/trackio/commit/3e1117438bb8168b802245a33059affa558ae519) - Use HF buckets as backend. Thanks @abidlabs!
|
| 200 |
+
- [#469](https://github.com/gradio-app/trackio/pull/469) [`915d170`](https://github.com/gradio-app/trackio/commit/915d17045133172b59195acfdcc70709229668aa) - Make static Spaces work with Buckets and also allow conversion from Gradio SDK to Static Spaces. Thanks @abidlabs!
|
| 201 |
+
|
| 202 |
+
## 0.20.2
|
| 203 |
+
|
| 204 |
+
### Features
|
| 205 |
+
|
| 206 |
+
- [#464](https://github.com/gradio-app/trackio/pull/464) [`c89ebb3`](https://github.com/gradio-app/trackio/commit/c89ebb3b50f695bc7f16cbc6f46dce86f79a01e9) - Improve rendering of curves. Thanks @abidlabs!
|
| 207 |
+
- [#462](https://github.com/gradio-app/trackio/pull/462) [`9160b78`](https://github.com/gradio-app/trackio/commit/9160b78ff6f258f0b87a4f34a24e7d0b5dfbf2fb) - Refactor plot title to display only the metric name without the path. Thanks @qgallouedec!
|
| 208 |
+
|
| 209 |
+
## 0.20.1
|
| 210 |
+
|
| 211 |
+
### Features
|
| 212 |
+
|
| 213 |
+
- [#454](https://github.com/gradio-app/trackio/pull/454) [`22881db`](https://github.com/gradio-app/trackio/commit/22881dbbbb6b81197a00a19853771007093d61e4) - Bar chart single point. Thanks @abidlabs!
|
| 214 |
+
- [#455](https://github.com/gradio-app/trackio/pull/455) [`f8db51a`](https://github.com/gradio-app/trackio/commit/f8db51a20ca61ef703f3f2c2ee1ebd9c4f239cf2) - Adds a static Trackio mode via `trackio.sync(sdk="static")` and support for the `TRACKIO_SPACE_ID` environment variable. Thanks @abidlabs!
|
| 215 |
+
|
| 216 |
+
## 0.20.0
|
| 217 |
+
|
| 218 |
+
### Features
|
| 219 |
+
|
| 220 |
+
- [#450](https://github.com/gradio-app/trackio/pull/450) [`b0571ef`](https://github.com/gradio-app/trackio/commit/b0571ef6207a1ce346696f858ad2b7b584dd194f) - Use Svelte source for Gradio components directly in Trackio dashboard. Thanks @abidlabs!
|
| 221 |
+
|
| 222 |
+
## 0.19.0
|
| 223 |
+
|
| 224 |
+
### Features
|
| 225 |
+
|
| 226 |
+
- [#445](https://github.com/gradio-app/trackio/pull/445) [`cef4a58`](https://github.com/gradio-app/trackio/commit/cef4a583cb76f4091fc6c0e5783124ee84f8e243) - Add remote HF Space support to CLI. Thanks @abidlabs!
|
| 227 |
+
- [#444](https://github.com/gradio-app/trackio/pull/444) [`358f2a9`](https://github.com/gradio-app/trackio/commit/358f2a9ca238ee8b90b5a8c96220da287e0698fb) - Fix alerts placeholder flashing on reports page. Thanks @abidlabs!
|
| 228 |
+
|
| 229 |
+
## 0.18.0
|
| 230 |
+
|
| 231 |
+
### Features
|
| 232 |
+
|
| 233 |
+
- [#435](https://github.com/gradio-app/trackio/pull/435) [`4a47112`](https://github.com/gradio-app/trackio/commit/4a471128e18a39e45fad48a67fd711c5ae9e4aed) - feat: allow hiding section header accordions. Thanks @Saba9!
|
| 234 |
+
- [#439](https://github.com/gradio-app/trackio/pull/439) [`18e9650`](https://github.com/gradio-app/trackio/commit/18e96503d5a3a7cf926e92782d457e23c19942bd) - Add alerts with webhooks, CLI, and documentation. Thanks @abidlabs!
|
| 235 |
+
- [#438](https://github.com/gradio-app/trackio/pull/438) [`0875ccd`](https://github.com/gradio-app/trackio/commit/0875ccd3d8a41b1376f64030f21cfe8cdcc73b05) - Add "share this view" functionality. Thanks @qgallouedec!
|
| 236 |
+
- [#409](https://github.com/gradio-app/trackio/pull/409) [`9282403`](https://github.com/gradio-app/trackio/commit/9282403d8896d48679b0f888208a7ba5bdd4271a) - Add Apple Silicon GPU and system monitoring support. Thanks @znation!
|
| 237 |
+
- [#434](https://github.com/gradio-app/trackio/pull/434) [`4193223`](https://github.com/gradio-app/trackio/commit/41932230a3a2e1c16405dba08ecba5a42f11d1a8) - fix: table slider crash. Thanks @Saba9!
|
| 238 |
+
|
| 239 |
+
### Fixes
|
| 240 |
+
|
| 241 |
+
- [#441](https://github.com/gradio-app/trackio/pull/441) [`3a2d11d`](https://github.com/gradio-app/trackio/commit/3a2d11dab0b4b37c925abc30ef84b0e2910321ee) - preserve x-axis step when toggling run checkboxes. Thanks @Saba9!
|
| 242 |
+
|
| 243 |
+
## 0.17.0
|
| 244 |
+
|
| 245 |
+
### Features
|
| 246 |
+
|
| 247 |
+
- [#428](https://github.com/gradio-app/trackio/pull/428) [`f7dd1ce`](https://github.com/gradio-app/trackio/commit/f7dd1ce2dc8a1936f9983467fcbcf93bfef01e09) - feat: add ability to rename runs. Thanks @Saba9!
|
| 248 |
+
- [#437](https://github.com/gradio-app/trackio/pull/437) [`2727c0b`](https://github.com/gradio-app/trackio/commit/2727c0b0755f48f7f186162ea45185c98f6b5516) - Add markdown reports across Trackio. Thanks @abidlabs!
|
| 249 |
+
- [#427](https://github.com/gradio-app/trackio/pull/427) [`5aeb9ed`](https://github.com/gradio-app/trackio/commit/5aeb9edcfd2068d309d9d64f172dcbcc327be1ab) - Make Trackio logging much more robust. Thanks @abidlabs!
|
| 250 |
+
|
| 251 |
+
## 0.16.1
|
| 252 |
+
|
| 253 |
+
### Features
|
| 254 |
+
|
| 255 |
+
- [#431](https://github.com/gradio-app/trackio/pull/431) [`c7ce55b`](https://github.com/gradio-app/trackio/commit/c7ce55b14dd5eb0c2165fb15df17dd60721c9325) - Lazy load the UI when trackio is imported. Thanks @abidlabs!
|
| 256 |
+
|
| 257 |
+
## 0.16.0
|
| 258 |
+
|
| 259 |
+
### Features
|
| 260 |
+
|
| 261 |
+
- [#426](https://github.com/gradio-app/trackio/pull/426) [`ead4dc8`](https://github.com/gradio-app/trackio/commit/ead4dc8e74ee2d8e47d61bca0a7668456acf49be) - Fix redundant double rendering of group checkboxes. Thanks @abidlabs!
|
| 262 |
+
- [#413](https://github.com/gradio-app/trackio/pull/413) [`39c4750`](https://github.com/gradio-app/trackio/commit/39c4750951d554ba6eb4d58847c6bb444b2891a8) - Check `dist-packages` when checking for source installation. Thanks @sergiopaniego!
|
| 263 |
+
- [#423](https://github.com/gradio-app/trackio/pull/423) [`2e52ab3`](https://github.com/gradio-app/trackio/commit/2e52ab303e3041718a6a56fbf84d0848aca9ad67) - Fix legend outline visibility issue. Thanks @Raghunath-Balaji!
|
| 264 |
+
- [#407](https://github.com/gradio-app/trackio/pull/407) [`c8a384d`](https://github.com/gradio-app/trackio/commit/c8a384ddfe5a295cecf862a26178d40e48acb424) - Fix pytests that were failling locally on MacOS. Thanks @abidlabs!
|
| 265 |
+
- [#405](https://github.com/gradio-app/trackio/pull/405) [`35aae4e`](https://github.com/gradio-app/trackio/commit/35aae4e3aa3e2b2888887528478b9dc6a9808bda) - Add conditional padding for HF Space dashboard when not in iframe. Thanks @znation!
|
| 266 |
+
|
| 267 |
+
## 0.15.0
|
| 268 |
+
|
| 269 |
+
### Features
|
| 270 |
+
|
| 271 |
+
- [#397](https://github.com/gradio-app/trackio/pull/397) [`6b38ad0`](https://github.com/gradio-app/trackio/commit/6b38ad02e5d73a0df49c4eede7e91331282ece04) - Adds `--host` cli option support. Thanks @abidlabs!
|
| 272 |
+
- [#396](https://github.com/gradio-app/trackio/pull/396) [`4a4d1ab`](https://github.com/gradio-app/trackio/commit/4a4d1ab85e63d923132a3fa7afa5d90e16431bec) - Fix run selection issue. Thanks @abidlabs!
|
| 273 |
+
- [#394](https://github.com/gradio-app/trackio/pull/394) [`c47a3a3`](https://github.com/gradio-app/trackio/commit/c47a3a31f8c4b83bce1aa7fc22eeba3d9021ad3d) - Add wandb-compatible API for trackio. Thanks @abidlabs!
|
| 274 |
+
- [#378](https://github.com/gradio-app/trackio/pull/378) [`b02046a`](https://github.com/gradio-app/trackio/commit/b02046a5b0dad7c9854e099a87f884afba4aecb2) - Add JSON export button for line plots and upgrade gradio dependency. Thanks @JamshedAli18!
|
| 275 |
+
|
| 276 |
+
## 0.14.2
|
| 277 |
+
|
| 278 |
+
### Features
|
| 279 |
+
|
| 280 |
+
- [#386](https://github.com/gradio-app/trackio/pull/386) [`f9452cd`](https://github.com/gradio-app/trackio/commit/f9452cdb8f0819368f3610f7ac0ed08957305275) - Fixing some issues related to deployed Trackio Spaces. Thanks @abidlabs!
|
| 281 |
+
|
| 282 |
+
## 0.14.1
|
| 283 |
+
|
| 284 |
+
### Features
|
| 285 |
+
|
| 286 |
+
- [#382](https://github.com/gradio-app/trackio/pull/382) [`44fe9bb`](https://github.com/gradio-app/trackio/commit/44fe9bb264fb2aafb0ec302ff15227c045819a2c) - Fix app file path when Trackio is not installed from source. Thanks @abidlabs!
|
| 287 |
+
- [#380](https://github.com/gradio-app/trackio/pull/380) [`c3f4cff`](https://github.com/gradio-app/trackio/commit/c3f4cff74bc5676e812773d8571454894fcdc7cc) - Add CLI commands for querying projects, runs, and metrics. Thanks @abidlabs!
|
| 288 |
+
|
| 289 |
+
## 0.14.0
|
| 290 |
+
|
| 291 |
+
### Features
|
| 292 |
+
|
| 293 |
+
- [#377](https://github.com/gradio-app/trackio/pull/377) [`5c5015b`](https://github.com/gradio-app/trackio/commit/5c5015b68c85c5de51111dad983f735c27b9a05f) - fixed wrapping issue in Runs table. Thanks @gaganchapa!
|
| 294 |
+
- [#374](https://github.com/gradio-app/trackio/pull/374) [`388e26b`](https://github.com/gradio-app/trackio/commit/388e26b9e9f24cd7ad203affe9b709be885b3d24) - Save Optimized Parquet files. Thanks @lhoestq!
|
| 295 |
+
- [#371](https://github.com/gradio-app/trackio/pull/371) [`fbace9c`](https://github.com/gradio-app/trackio/commit/fbace9cd7732c166f34d268f54b05bb06846cc5d) - Add GPU metrics logging. Thanks @kashif!
|
| 296 |
+
- [#367](https://github.com/gradio-app/trackio/pull/367) [`862840c`](https://github.com/gradio-app/trackio/commit/862840c13e30fc960cbee5b9eac4d3c25beba9de) - Add option to only show latest run, and fix the double logo issue. Thanks @abidlabs!
|
| 297 |
+
|
| 298 |
+
## 0.13.1
|
| 299 |
+
|
| 300 |
+
### Features
|
| 301 |
+
|
| 302 |
+
- [#369](https://github.com/gradio-app/trackio/pull/369) [`767e9fe`](https://github.com/gradio-app/trackio/commit/767e9fe095d7c6ed102016caf927c1517fb8618c) - tiny pr removing unnecessary code. Thanks @abidlabs!
|
| 303 |
+
|
| 304 |
+
## 0.13.0
|
| 305 |
+
|
| 306 |
+
### Features
|
| 307 |
+
|
| 308 |
+
- [#358](https://github.com/gradio-app/trackio/pull/358) [`073715d`](https://github.com/gradio-app/trackio/commit/073715d1caf8282f68890117f09c3ac301205312) - Improvements to `trackio.sync()`. Thanks @abidlabs!
|
| 309 |
+
|
| 310 |
+
## 0.12.0
|
| 311 |
+
|
| 312 |
+
### Features
|
| 313 |
+
|
| 314 |
+
- [#357](https://github.com/gradio-app/trackio/pull/357) [`02ba815`](https://github.com/gradio-app/trackio/commit/02ba815358060f1966052de051a5bdb09702920e) - Redesign media and tables to show up on separate page. Thanks @abidlabs!
|
| 315 |
+
- [#359](https://github.com/gradio-app/trackio/pull/359) [`08fe9c9`](https://github.com/gradio-app/trackio/commit/08fe9c9ddd7fe99ee811555fdfb62df9ab88e939) - docs: Improve docstrings. Thanks @qgallouedec!
|
| 316 |
+
|
| 317 |
+
## 0.11.0
|
| 318 |
+
|
| 319 |
+
### Features
|
| 320 |
+
|
| 321 |
+
- [#355](https://github.com/gradio-app/trackio/pull/355) [`ea51f49`](https://github.com/gradio-app/trackio/commit/ea51f4954922f21be76ef828700420fe9a912c4b) - Color code run checkboxes and match with plot lines. Thanks @abidlabs!
|
| 322 |
+
- [#353](https://github.com/gradio-app/trackio/pull/353) [`8abe691`](https://github.com/gradio-app/trackio/commit/8abe6919aeefe21fc7a23af814883efbb037c21f) - Remove show_api from demo.launch. Thanks @sergiopaniego!
|
| 323 |
+
- [#351](https://github.com/gradio-app/trackio/pull/351) [`8a8957e`](https://github.com/gradio-app/trackio/commit/8a8957e530dd7908d1fef7f2df030303f808101f) - Add `trackio.save()`. Thanks @abidlabs!
|
| 324 |
+
|
| 325 |
+
## 0.10.0
|
| 326 |
+
|
| 327 |
+
### Features
|
| 328 |
+
|
| 329 |
+
- [#305](https://github.com/gradio-app/trackio/pull/305) [`e64883a`](https://github.com/gradio-app/trackio/commit/e64883a51f7b8b93f7d48b8afe55acdb62238b71) - bump to gradio 6.0, make `trackio` compatible, and fix related issues. Thanks @abidlabs!
|
| 330 |
+
|
| 331 |
+
## 0.9.1
|
| 332 |
+
|
| 333 |
+
### Features
|
| 334 |
+
|
| 335 |
+
- [#344](https://github.com/gradio-app/trackio/pull/344) [`7e01024`](https://github.com/gradio-app/trackio/commit/7e010241d9a34794e0ce0dc19c1a6f0cf94ba856) - Avoid redundant calls to /whoami-v2. Thanks @Wauplin!
|
| 336 |
+
|
| 337 |
+
## 0.9.0
|
| 338 |
+
|
| 339 |
+
### Features
|
| 340 |
+
|
| 341 |
+
- [#343](https://github.com/gradio-app/trackio/pull/343) [`51bea30`](https://github.com/gradio-app/trackio/commit/51bea30f2877adff8e6497466d3a799400a0a049) - Sync offline projects to Hugging Face spaces. Thanks @candemircan!
|
| 342 |
+
- [#341](https://github.com/gradio-app/trackio/pull/341) [`4fd841f`](https://github.com/gradio-app/trackio/commit/4fd841fa190e15071b02f6fba7683ef4f393a654) - Adds a basic UI test to `trackio`. Thanks @abidlabs!
|
| 343 |
+
- [#339](https://github.com/gradio-app/trackio/pull/339) [`011d91b`](https://github.com/gradio-app/trackio/commit/011d91bb6ae266516fd250a349285670a8049d05) - Allow customzing the trackio color palette. Thanks @abidlabs!
|
| 344 |
+
|
| 345 |
+
## 0.8.1
|
| 346 |
+
|
| 347 |
+
### Features
|
| 348 |
+
|
| 349 |
+
- [#336](https://github.com/gradio-app/trackio/pull/336) [`5f9f51d`](https://github.com/gradio-app/trackio/commit/5f9f51dac8677f240d7c42c3e3b2660a22aee138) - Support a list of `Trackio.Image` in a `trackio.Table` cell. Thanks @abidlabs!
|
| 350 |
+
|
| 351 |
+
## 0.8.0
|
| 352 |
+
|
| 353 |
+
### Features
|
| 354 |
+
|
| 355 |
+
- [#331](https://github.com/gradio-app/trackio/pull/331) [`2c02d0f`](https://github.com/gradio-app/trackio/commit/2c02d0fd0a5824160528782402bb0dd4083396d5) - Truncate table string values that are greater than 250 characters (configuirable via env variable). Thanks @abidlabs!
|
| 356 |
+
- [#324](https://github.com/gradio-app/trackio/pull/324) [`50b2122`](https://github.com/gradio-app/trackio/commit/50b2122e7965ac82a72e6cb3b7d048bc10a2a6b1) - Add log y-axis functionality to UI. Thanks @abidlabs!
|
| 357 |
+
- [#326](https://github.com/gradio-app/trackio/pull/326) [`61dc1f4`](https://github.com/gradio-app/trackio/commit/61dc1f40af2f545f8e70395ddf0dbb8aee6b60d5) - Fix: improve table rendering for metrics in Trackio Dashboard. Thanks @vigneshwaran!
|
| 358 |
+
- [#328](https://github.com/gradio-app/trackio/pull/328) [`6857cbb`](https://github.com/gradio-app/trackio/commit/6857cbbe557a59a4642f210ec42566d108294e63) - Support trackio.Table with trackio.Image columns. Thanks @abidlabs!
|
| 359 |
+
- [#323](https://github.com/gradio-app/trackio/pull/323) [`6857cbb`](https://github.com/gradio-app/trackio/commit/6857cbbe557a59a4642f210ec42566d108294e63) - add Trackio client implementations in Go, Rust, and JS. Thanks @vaibhav-research!
|
| 360 |
+
|
| 361 |
+
## 0.7.0
|
| 362 |
+
|
| 363 |
+
### Features
|
| 364 |
+
|
| 365 |
+
- [#277](https://github.com/gradio-app/trackio/pull/277) [`db35601`](https://github.com/gradio-app/trackio/commit/db35601b9c023423c4654c9909b8ab73e58737de) - fix: make grouped runs view reflect live updates. Thanks @Saba9!
|
| 366 |
+
- [#320](https://github.com/gradio-app/trackio/pull/320) [`24ae739`](https://github.com/gradio-app/trackio/commit/24ae73969b09fb3126acd2f91647cdfbf8cf72a1) - Add additional query parms for xmin, xmax, and smoothing. Thanks @abidlabs!
|
| 367 |
+
- [#270](https://github.com/gradio-app/trackio/pull/270) [`cd1dfc3`](https://github.com/gradio-app/trackio/commit/cd1dfc3dc641b4499ac6d4a1b066fa8e2b52c57b) - feature: add support for logging audio. Thanks @Saba9!
|
| 368 |
+
|
| 369 |
+
## 0.6.0
|
| 370 |
+
|
| 371 |
+
### Features
|
| 372 |
+
|
| 373 |
+
- [#309](https://github.com/gradio-app/trackio/pull/309) [`1df2353`](https://github.com/gradio-app/trackio/commit/1df23534d6c01938c8db9c0f584ffa23e8d6021d) - Add histogram support with wandb-compatible API. Thanks @abidlabs!
|
| 374 |
+
- [#315](https://github.com/gradio-app/trackio/pull/315) [`76ba060`](https://github.com/gradio-app/trackio/commit/76ba06055dc43ca8f03b79f3e72d761949bd19a8) - Add guards to avoid silent fails. Thanks @Xmaster6y!
|
| 375 |
+
- [#313](https://github.com/gradio-app/trackio/pull/313) [`a606b3e`](https://github.com/gradio-app/trackio/commit/a606b3e1c5edf3d4cf9f31bd50605226a5a1c5d0) - No longer prevent certain keys from being used. Instead, dunderify them to prevent collisions with internal usage. Thanks @abidlabs!
|
| 376 |
+
- [#317](https://github.com/gradio-app/trackio/pull/317) [`27370a5`](https://github.com/gradio-app/trackio/commit/27370a595d0dbdf7eebbe7159d2ba778f039da44) - quick fixes for trackio.histogram. Thanks @abidlabs!
|
| 377 |
+
- [#312](https://github.com/gradio-app/trackio/pull/312) [`aa0f3bf`](https://github.com/gradio-app/trackio/commit/aa0f3bf372e7a0dd592a38af699c998363830eeb) - Fix video logging by adding TRACKIO_DIR to allowed_paths. Thanks @abidlabs!
|
| 378 |
+
|
| 379 |
+
## 0.5.3
|
| 380 |
+
|
| 381 |
+
### Features
|
| 382 |
+
|
| 383 |
+
- [#300](https://github.com/gradio-app/trackio/pull/300) [`5e4cacf`](https://github.com/gradio-app/trackio/commit/5e4cacf2e7ce527b4ce60de3a5bc05d2c02c77fb) - Adds more environment variables to allow customization of Trackio dashboard. Thanks @abidlabs!
|
| 384 |
+
|
| 385 |
+
## 0.5.2
|
| 386 |
+
|
| 387 |
+
### Features
|
| 388 |
+
|
| 389 |
+
- [#293](https://github.com/gradio-app/trackio/pull/293) [`64afc28`](https://github.com/gradio-app/trackio/commit/64afc28d3ea1dfd821472dc6bf0b8ed35a9b74be) - Ensures that the TRACKIO_DIR environment variable is respected. Thanks @abidlabs!
|
| 390 |
+
- [#287](https://github.com/gradio-app/trackio/pull/287) [`cd3e929`](https://github.com/gradio-app/trackio/commit/cd3e9294320949e6b8b829239069a43d5d7ff4c1) - fix(sqlite): unify .sqlite extension, allow export when DBs exist, clean WAL sidecars on import. Thanks @vaibhav-research!
|
| 391 |
+
|
| 392 |
+
### Fixes
|
| 393 |
+
|
| 394 |
+
- [#291](https://github.com/gradio-app/trackio/pull/291) [`3b5adc3`](https://github.com/gradio-app/trackio/commit/3b5adc3d1f452dbab7a714d235f4974782f93730) - Fix the wheel build. Thanks @pngwn!
|
| 395 |
+
|
| 396 |
+
## 0.5.1
|
| 397 |
+
|
| 398 |
+
### Fixes
|
| 399 |
+
|
| 400 |
+
- [#278](https://github.com/gradio-app/trackio/pull/278) [`314c054`](https://github.com/gradio-app/trackio/commit/314c05438007ddfea3383e06fd19143e27468e2d) - Fix row orientation of metrics plots. Thanks @abidlabs!
|
trackio/__init__.py
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import atexit
|
| 2 |
+
import glob
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import warnings
|
| 8 |
+
import webbrowser
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import huggingface_hub
|
| 13 |
+
from gradio_client import handle_file
|
| 14 |
+
from huggingface_hub import SpaceStorage
|
| 15 |
+
from huggingface_hub.errors import LocalTokenNotFoundError
|
| 16 |
+
|
| 17 |
+
from trackio import context_vars, deploy, utils
|
| 18 |
+
from trackio.alerts import AlertLevel
|
| 19 |
+
from trackio.api import Api
|
| 20 |
+
from trackio.apple_gpu import apple_gpu_available
|
| 21 |
+
from trackio.apple_gpu import log_apple_gpu as _log_apple_gpu
|
| 22 |
+
from trackio.artifact import Artifact
|
| 23 |
+
from trackio.cpu import cpu_available
|
| 24 |
+
from trackio.cpu import log_cpu as _log_cpu
|
| 25 |
+
from trackio.deploy import freeze, sync
|
| 26 |
+
from trackio.frontend_config import resolve_frontend_dir
|
| 27 |
+
from trackio.gpu import gpu_available
|
| 28 |
+
from trackio.gpu import log_gpu as _log_nvidia_gpu
|
| 29 |
+
from trackio.histogram import Histogram
|
| 30 |
+
from trackio.imports import import_csv, import_tf_events
|
| 31 |
+
from trackio.launch import launch_trackio_dashboard
|
| 32 |
+
from trackio.markdown import Markdown
|
| 33 |
+
from trackio.media import (
|
| 34 |
+
TrackioAudio,
|
| 35 |
+
TrackioImage,
|
| 36 |
+
TrackioVideo,
|
| 37 |
+
get_project_media_path,
|
| 38 |
+
)
|
| 39 |
+
from trackio.references import (
|
| 40 |
+
ReferenceHandler,
|
| 41 |
+
ResolvedReference,
|
| 42 |
+
register_reference_handler,
|
| 43 |
+
)
|
| 44 |
+
from trackio.remote_client import RemoteClient
|
| 45 |
+
from trackio.run import Run
|
| 46 |
+
from trackio.server import TrackioDashboardApp, build_starlette_app_only
|
| 47 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 48 |
+
from trackio.table import Table
|
| 49 |
+
from trackio.trace import Trace
|
| 50 |
+
from trackio.typehints import UploadEntry
|
| 51 |
+
from trackio.utils import TRACKIO_DIR, TRACKIO_LOGO_DIR, _emit_nonfatal_warning
|
| 52 |
+
|
| 53 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 54 |
+
|
| 55 |
+
__version__ = json.loads(Path(__file__).parent.joinpath("package.json").read_text())[
|
| 56 |
+
"version"
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class _TupleNoPrint(tuple):
|
| 61 |
+
def __repr__(self) -> str:
|
| 62 |
+
return ""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
__all__ = [
|
| 66 |
+
"init",
|
| 67 |
+
"log",
|
| 68 |
+
"log_system",
|
| 69 |
+
"log_gpu",
|
| 70 |
+
"log_artifact",
|
| 71 |
+
"use_artifact",
|
| 72 |
+
"log_cpu",
|
| 73 |
+
"finish",
|
| 74 |
+
"alert",
|
| 75 |
+
"AlertLevel",
|
| 76 |
+
"show",
|
| 77 |
+
"sync",
|
| 78 |
+
"freeze",
|
| 79 |
+
"delete_project",
|
| 80 |
+
"import_csv",
|
| 81 |
+
"import_tf_events",
|
| 82 |
+
"save",
|
| 83 |
+
"Artifact",
|
| 84 |
+
"ReferenceHandler",
|
| 85 |
+
"ResolvedReference",
|
| 86 |
+
"register_reference_handler",
|
| 87 |
+
"Image",
|
| 88 |
+
"Video",
|
| 89 |
+
"Audio",
|
| 90 |
+
"Table",
|
| 91 |
+
"Trace",
|
| 92 |
+
"Histogram",
|
| 93 |
+
"Markdown",
|
| 94 |
+
"Api",
|
| 95 |
+
"TRACKIO_LOGO_DIR",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
Audio = TrackioAudio
|
| 99 |
+
Image = TrackioImage
|
| 100 |
+
Video = TrackioVideo
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
config = {}
|
| 104 |
+
|
| 105 |
+
_atexit_registered = False
|
| 106 |
+
_spaces_created_this_session: set[str] = set()
|
| 107 |
+
_projects_notified_auto_log_hw: set[str] = set()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _cleanup_current_run():
|
| 111 |
+
run = context_vars.current_run.get()
|
| 112 |
+
if run is not None:
|
| 113 |
+
try:
|
| 114 |
+
run.finish()
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _safe_get_runs_for_init(
|
| 120 |
+
project: str,
|
| 121 |
+
space_id: str | None,
|
| 122 |
+
server_base_url: str | None,
|
| 123 |
+
write_token: str | None,
|
| 124 |
+
resume: str,
|
| 125 |
+
remote_client: RemoteClient | None = None,
|
| 126 |
+
check_existing_for_never: bool = False,
|
| 127 |
+
) -> list[str]:
|
| 128 |
+
if space_id is not None or server_base_url is not None:
|
| 129 |
+
if resume == "never" and not check_existing_for_never:
|
| 130 |
+
return []
|
| 131 |
+
if remote_client is not None:
|
| 132 |
+
source = space_id or server_base_url
|
| 133 |
+
try:
|
| 134 |
+
runs = remote_client.predict(
|
| 135 |
+
project=project, api_name="/get_runs_for_project"
|
| 136 |
+
)
|
| 137 |
+
return runs if isinstance(runs, list) else []
|
| 138 |
+
except Exception as e:
|
| 139 |
+
_emit_nonfatal_warning(
|
| 140 |
+
f"trackio.init() could not inspect existing runs for project '{project}' on '{source}': {e}. Continuing without resume metadata."
|
| 141 |
+
)
|
| 142 |
+
return []
|
| 143 |
+
try:
|
| 144 |
+
return SQLiteStorage.get_runs(project)
|
| 145 |
+
except Exception as e:
|
| 146 |
+
_emit_nonfatal_warning(
|
| 147 |
+
f"trackio.init() could not inspect existing runs for project '{project}': {e}. Continuing without resume metadata."
|
| 148 |
+
)
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _safe_get_latest_run_for_init(
|
| 153 |
+
project: str,
|
| 154 |
+
name: str,
|
| 155 |
+
space_id: str | None = None,
|
| 156 |
+
server_base_url: str | None = None,
|
| 157 |
+
write_token: str | None = None,
|
| 158 |
+
remote_client: RemoteClient | None = None,
|
| 159 |
+
) -> dict | None:
|
| 160 |
+
if (
|
| 161 |
+
space_id is not None or server_base_url is not None
|
| 162 |
+
) and remote_client is not None:
|
| 163 |
+
source = space_id or server_base_url
|
| 164 |
+
try:
|
| 165 |
+
runs = remote_client.predict(
|
| 166 |
+
project=project, api_name="/get_runs_for_project"
|
| 167 |
+
)
|
| 168 |
+
if not isinstance(runs, list):
|
| 169 |
+
return None
|
| 170 |
+
matches = [r for r in runs if isinstance(r, dict) and r.get("name") == name]
|
| 171 |
+
if not matches:
|
| 172 |
+
return None
|
| 173 |
+
matches.sort(key=lambda r: r.get("created_at") or "", reverse=True)
|
| 174 |
+
return matches[0]
|
| 175 |
+
except Exception as e:
|
| 176 |
+
_emit_nonfatal_warning(
|
| 177 |
+
f"trackio.init() could not inspect existing runs for project '{project}' on '{source}': {e}. Continuing without resume metadata."
|
| 178 |
+
)
|
| 179 |
+
return None
|
| 180 |
+
try:
|
| 181 |
+
return SQLiteStorage.get_latest_run_record_by_name(project, name)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
_emit_nonfatal_warning(
|
| 184 |
+
f"trackio.init() could not inspect existing runs for project '{project}': {e}. Continuing without resume metadata."
|
| 185 |
+
)
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _safe_get_last_step_for_init(
|
| 190 |
+
project: str,
|
| 191 |
+
run_name: str,
|
| 192 |
+
space_id: str | None,
|
| 193 |
+
server_base_url: str | None,
|
| 194 |
+
write_token: str | None,
|
| 195 |
+
resumed: bool,
|
| 196 |
+
run_id: str | None = None,
|
| 197 |
+
remote_client: RemoteClient | None = None,
|
| 198 |
+
) -> int | None:
|
| 199 |
+
if not resumed:
|
| 200 |
+
return None
|
| 201 |
+
if (
|
| 202 |
+
space_id is not None or server_base_url is not None
|
| 203 |
+
) and remote_client is not None:
|
| 204 |
+
source = space_id or server_base_url
|
| 205 |
+
try:
|
| 206 |
+
summary_kwargs: dict[str, Any] = {
|
| 207 |
+
"project": project,
|
| 208 |
+
"api_name": "/get_run_summary",
|
| 209 |
+
}
|
| 210 |
+
if run_id is not None:
|
| 211 |
+
summary_kwargs["run_id"] = run_id
|
| 212 |
+
else:
|
| 213 |
+
summary_kwargs["run"] = run_name
|
| 214 |
+
summary = remote_client.predict(**summary_kwargs)
|
| 215 |
+
if isinstance(summary, dict):
|
| 216 |
+
last_step = summary.get("last_step")
|
| 217 |
+
return last_step if isinstance(last_step, int) else None
|
| 218 |
+
return None
|
| 219 |
+
except Exception as e:
|
| 220 |
+
_emit_nonfatal_warning(
|
| 221 |
+
f"trackio.init() could not recover the previous step for run '{run_name}' on '{source}': {e}. Continuing from step 0."
|
| 222 |
+
)
|
| 223 |
+
return None
|
| 224 |
+
try:
|
| 225 |
+
return SQLiteStorage.get_max_step_for_run(project, run_name, run_id=run_id)
|
| 226 |
+
except Exception as e:
|
| 227 |
+
_emit_nonfatal_warning(
|
| 228 |
+
f"trackio.init() could not recover the previous step for run '{run_name}': {e}. Continuing from step 0."
|
| 229 |
+
)
|
| 230 |
+
return None
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def init(
|
| 234 |
+
project: str,
|
| 235 |
+
name: str | None = None,
|
| 236 |
+
group: str | None = None,
|
| 237 |
+
space_id: str | None = None,
|
| 238 |
+
server_url: str | None = None,
|
| 239 |
+
space_storage: SpaceStorage | None = None,
|
| 240 |
+
dataset_id: str | None = None,
|
| 241 |
+
bucket_id: str | None = None,
|
| 242 |
+
config: dict | None = None,
|
| 243 |
+
resume: str = "never",
|
| 244 |
+
settings: Any = None,
|
| 245 |
+
private: bool | None = None,
|
| 246 |
+
embed: bool = True,
|
| 247 |
+
auto_log_gpu: bool | None = None,
|
| 248 |
+
gpu_log_interval: float = 10.0,
|
| 249 |
+
auto_log_cpu: bool | None = None,
|
| 250 |
+
cpu_log_interval: float = 10.0,
|
| 251 |
+
webhook_url: str | None = None,
|
| 252 |
+
webhook_min_level: AlertLevel | str | None = None,
|
| 253 |
+
) -> Run:
|
| 254 |
+
"""
|
| 255 |
+
Creates a new Trackio project and returns a [`Run`] object.
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
project (`str`):
|
| 259 |
+
The name of the project (can be an existing project to continue tracking or
|
| 260 |
+
a new project to start tracking from scratch).
|
| 261 |
+
name (`str`, *optional*):
|
| 262 |
+
The name of the run (if not provided, a default name will be generated).
|
| 263 |
+
group (`str`, *optional*):
|
| 264 |
+
The name of the group which this run belongs to in order to help organize
|
| 265 |
+
related runs together. You can toggle the entire group's visibility in the
|
| 266 |
+
dashboard.
|
| 267 |
+
space_id (`str`, *optional*):
|
| 268 |
+
If provided, the project will be logged to a Hugging Face Space instead of
|
| 269 |
+
a local directory. Should be a complete Space name like
|
| 270 |
+
`"username/reponame"` or `"orgname/reponame"`, or just `"reponame"` in which
|
| 271 |
+
case the Space will be created in the currently-logged-in Hugging Face
|
| 272 |
+
user's namespace. If the Space does not exist, it will be created. If the
|
| 273 |
+
Space already exists, the project will be logged to it. Can also be set
|
| 274 |
+
via the `TRACKIO_SPACE_ID` environment variable. You cannot log to a
|
| 275 |
+
Space that has been **frozen** (converted to the static SDK); use
|
| 276 |
+
``trackio.sync(..., sdk="static")`` only after you are done logging.
|
| 277 |
+
Takes precedence over `server_url` and `TRACKIO_SERVER_URL` when more than
|
| 278 |
+
one is set.
|
| 279 |
+
server_url (`str`, *optional*):
|
| 280 |
+
Base URL of a self-hosted Trackio server (``http://`` or ``https://``), or the
|
| 281 |
+
write-access URL from ``trackio.show()`` which may include a ``write_token`` query
|
| 282 |
+
parameter. The client sends that token on each request (``X-Trackio-Write-Token``);
|
| 283 |
+
you can also set ``TRACKIO_WRITE_TOKEN`` instead of embedding the token in the URL.
|
| 284 |
+
When set, metrics are sent to that server over HTTP instead of creating or syncing
|
| 285 |
+
to a Hugging Face Space. Can also be set via the ``TRACKIO_SERVER_URL`` environment
|
| 286 |
+
variable. Ignored when ``space_id`` or ``TRACKIO_SPACE_ID`` is set.
|
| 287 |
+
space_storage ([`~huggingface_hub.SpaceStorage`], *optional*):
|
| 288 |
+
Choice of persistent storage tier.
|
| 289 |
+
dataset_id (`str`, *optional*):
|
| 290 |
+
Deprecated. Use `bucket_id` instead.
|
| 291 |
+
bucket_id (`str`, *optional*):
|
| 292 |
+
The ID of the Hugging Face Bucket to use for metric persistence. By default,
|
| 293 |
+
when a `space_id` is provided and `bucket_id` is not explicitly set, a
|
| 294 |
+
bucket is auto-generated from the space_id. Buckets provide
|
| 295 |
+
S3-like storage without git overhead - the SQLite database is stored directly
|
| 296 |
+
via `hf-mount` in the Space. Specify a Bucket with name like
|
| 297 |
+
`"username/bucketname"` or just `"bucketname"`.
|
| 298 |
+
config (`dict`, *optional*):
|
| 299 |
+
A dictionary of configuration options. Provided for compatibility with
|
| 300 |
+
`wandb.init()`.
|
| 301 |
+
resume (`str`, *optional*, defaults to `"never"`):
|
| 302 |
+
Controls how to handle resuming a run. Can be one of:
|
| 303 |
+
|
| 304 |
+
- `"must"`: Must resume the run with the given name, raises error if run
|
| 305 |
+
doesn't exist
|
| 306 |
+
- `"allow"`: Resume the run if it exists, otherwise create a new run
|
| 307 |
+
- `"never"`: Never resume a run, always create a new one
|
| 308 |
+
private (`bool`, *optional*):
|
| 309 |
+
Whether to make the Space private. If None (default), the repo will be
|
| 310 |
+
public unless the organization's default is private. This value is ignored
|
| 311 |
+
if the repo already exists.
|
| 312 |
+
settings (`Any`, *optional*):
|
| 313 |
+
Not used. Provided for compatibility with `wandb.init()`.
|
| 314 |
+
embed (`bool`, *optional*, defaults to `True`):
|
| 315 |
+
If running inside a Jupyter/Colab notebook, whether the dashboard should
|
| 316 |
+
automatically be embedded in the cell when trackio.init() is called. For
|
| 317 |
+
local runs, this launches a local Trackio dashboard and embeds it. For Space runs,
|
| 318 |
+
this embeds the Space URL. In Colab, the local dashboard will be accessible
|
| 319 |
+
via a public share URL when `share=True`.
|
| 320 |
+
auto_log_gpu (`bool` or `None`, *optional*, defaults to `None`):
|
| 321 |
+
Controls automatic GPU metrics logging. If `None` (default), GPU logging
|
| 322 |
+
is automatically enabled when `nvidia-ml-py` is installed and an NVIDIA
|
| 323 |
+
GPU or Apple M series is detected. Set to `True` to force enable or
|
| 324 |
+
`False` to disable.
|
| 325 |
+
gpu_log_interval (`float`, *optional*, defaults to `10.0`):
|
| 326 |
+
The interval in seconds between automatic GPU metric logs.
|
| 327 |
+
Only used when `auto_log_gpu=True`.
|
| 328 |
+
auto_log_cpu (`bool` or `None`, *optional*, defaults to `None`):
|
| 329 |
+
Controls automatic CPU and RAM metrics logging (utilization, memory,
|
| 330 |
+
disk I/O, network I/O, and sensors). If `None` (default), CPU logging
|
| 331 |
+
is automatically enabled when `psutil` is installed. Set to `True` to
|
| 332 |
+
force enable or `False` to disable.
|
| 333 |
+
cpu_log_interval (`float`, *optional*, defaults to `10.0`):
|
| 334 |
+
The interval in seconds between automatic CPU metric logs.
|
| 335 |
+
Only used when CPU auto-logging is enabled.
|
| 336 |
+
webhook_url (`str`, *optional*):
|
| 337 |
+
A webhook URL to POST alert payloads to when `trackio.alert()` is
|
| 338 |
+
called. Supports Slack and Discord webhook URLs natively (payloads
|
| 339 |
+
are formatted automatically). Can also be set via the
|
| 340 |
+
`TRACKIO_WEBHOOK_URL` environment variable. Individual alerts can
|
| 341 |
+
override this URL by passing `webhook_url` to `trackio.alert()`.
|
| 342 |
+
webhook_min_level (`AlertLevel` or `str`, *optional*):
|
| 343 |
+
Minimum alert level that should trigger webhook delivery.
|
| 344 |
+
For example, `AlertLevel.WARN` sends only `WARN` and `ERROR`
|
| 345 |
+
alerts to the webhook destination. Can also be set via
|
| 346 |
+
`TRACKIO_WEBHOOK_MIN_LEVEL`.
|
| 347 |
+
Returns:
|
| 348 |
+
`Run`: A [`Run`] object that can be used to log metrics and finish the run.
|
| 349 |
+
"""
|
| 350 |
+
SQLiteStorage.validate_project_name(project)
|
| 351 |
+
|
| 352 |
+
if settings is not None:
|
| 353 |
+
_emit_nonfatal_warning(
|
| 354 |
+
"* Warning: settings is not used. Provided for compatibility with wandb.init(). Please create an issue at: https://github.com/gradio-app/trackio/issues if you need a specific feature implemented."
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
previous_run = context_vars.current_run.get()
|
| 358 |
+
if previous_run is not None:
|
| 359 |
+
try:
|
| 360 |
+
previous_run.finish()
|
| 361 |
+
except Exception as e:
|
| 362 |
+
_emit_nonfatal_warning(
|
| 363 |
+
f"trackio.init() could not finish the previous run '{previous_run.name}': {e}. Continuing with new run."
|
| 364 |
+
)
|
| 365 |
+
context_vars.current_run.set(None)
|
| 366 |
+
|
| 367 |
+
bucket_id_was_explicit = bucket_id is not None
|
| 368 |
+
space_id, server_url = utils.resolve_space_id_and_server_url(space_id, server_url)
|
| 369 |
+
if bucket_id is None and utils.on_spaces():
|
| 370 |
+
bucket_id = os.environ.get("TRACKIO_BUCKET_ID")
|
| 371 |
+
if server_url is not None and not server_url.startswith(("http://", "https://")):
|
| 372 |
+
raise ValueError(
|
| 373 |
+
f"`server_url` must be a full URL starting with http:// or https://, got: {server_url!r}"
|
| 374 |
+
)
|
| 375 |
+
server_base_url: str | None = None
|
| 376 |
+
write_token_resolved: str | None = None
|
| 377 |
+
if server_url is not None:
|
| 378 |
+
server_base_url, tok = utils.parse_trackio_server_url(server_url)
|
| 379 |
+
write_token_resolved = tok or os.environ.get("TRACKIO_WRITE_TOKEN")
|
| 380 |
+
if not write_token_resolved:
|
| 381 |
+
raise ValueError(
|
| 382 |
+
"Self-hosted logging requires a write token: add write_token to the server URL, "
|
| 383 |
+
"or set the TRACKIO_WRITE_TOKEN environment variable."
|
| 384 |
+
)
|
| 385 |
+
if server_url is not None and (dataset_id is not None or bucket_id is not None):
|
| 386 |
+
raise ValueError(
|
| 387 |
+
"`dataset_id` and `bucket_id` are Hugging Face Spaces concepts and are not "
|
| 388 |
+
"compatible with `server_url`. Configure storage on the self-hosted server."
|
| 389 |
+
)
|
| 390 |
+
if space_id is None and dataset_id is not None:
|
| 391 |
+
raise ValueError("Must provide a `space_id` when `dataset_id` is provided.")
|
| 392 |
+
if dataset_id is not None and bucket_id is not None:
|
| 393 |
+
raise ValueError("Cannot provide both `dataset_id` and `bucket_id`.")
|
| 394 |
+
try:
|
| 395 |
+
space_id, dataset_id, bucket_id = utils.preprocess_space_and_dataset_ids(
|
| 396 |
+
space_id, dataset_id, bucket_id
|
| 397 |
+
)
|
| 398 |
+
if (
|
| 399 |
+
space_id is not None
|
| 400 |
+
and dataset_id is None
|
| 401 |
+
and bucket_id is not None
|
| 402 |
+
and not bucket_id_was_explicit
|
| 403 |
+
and not utils.on_spaces()
|
| 404 |
+
):
|
| 405 |
+
bucket_id = deploy.resolve_auto_bucket_id(space_id, bucket_id)
|
| 406 |
+
except LocalTokenNotFoundError as e:
|
| 407 |
+
raise LocalTokenNotFoundError(
|
| 408 |
+
f"You must be logged in to Hugging Face locally when `space_id` is provided to deploy to a Space. {e}"
|
| 409 |
+
) from e
|
| 410 |
+
|
| 411 |
+
if space_id is None and bucket_id is not None:
|
| 412 |
+
_emit_nonfatal_warning(
|
| 413 |
+
"trackio.init() has `bucket_id` set but `space_id` is None: metrics will be logged "
|
| 414 |
+
"locally only. Pass `space_id` to create or use a Hugging Face Space, which will be "
|
| 415 |
+
"attached to the Hugging Face Bucket.",
|
| 416 |
+
UserWarning,
|
| 417 |
+
stacklevel=2,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
if space_id is not None:
|
| 421 |
+
deploy.raise_if_space_is_frozen_for_logging(space_id)
|
| 422 |
+
|
| 423 |
+
remote_source = space_id or server_base_url
|
| 424 |
+
|
| 425 |
+
if remote_source is not None:
|
| 426 |
+
url = remote_source
|
| 427 |
+
context_vars.current_server.set(url)
|
| 428 |
+
if space_id is not None:
|
| 429 |
+
context_vars.current_space_id.set(space_id)
|
| 430 |
+
context_vars.current_server_write_token.set(None)
|
| 431 |
+
else:
|
| 432 |
+
context_vars.current_space_id.set(None)
|
| 433 |
+
context_vars.current_server_write_token.set(write_token_resolved)
|
| 434 |
+
else:
|
| 435 |
+
url = None
|
| 436 |
+
context_vars.current_server.set(None)
|
| 437 |
+
context_vars.current_space_id.set(None)
|
| 438 |
+
context_vars.current_server_write_token.set(None)
|
| 439 |
+
|
| 440 |
+
_should_embed_local = False
|
| 441 |
+
|
| 442 |
+
newly_created_space = False
|
| 443 |
+
if space_id is not None and space_id in _spaces_created_this_session:
|
| 444 |
+
if deploy.space_is_running(space_id):
|
| 445 |
+
_spaces_created_this_session.discard(space_id)
|
| 446 |
+
else:
|
| 447 |
+
newly_created_space = True
|
| 448 |
+
|
| 449 |
+
if (
|
| 450 |
+
context_vars.current_project.get() is None
|
| 451 |
+
or context_vars.current_project.get() != project
|
| 452 |
+
):
|
| 453 |
+
print(f"* Trackio project initialized: {project}")
|
| 454 |
+
|
| 455 |
+
if bucket_id is not None:
|
| 456 |
+
if utils.on_spaces():
|
| 457 |
+
os.environ["TRACKIO_BUCKET_ID"] = bucket_id
|
| 458 |
+
bucket_url = f"https://huggingface.co/buckets/{bucket_id}"
|
| 459 |
+
print(
|
| 460 |
+
f"* Trackio metrics will be synced to Hugging Face Bucket: {bucket_url}"
|
| 461 |
+
)
|
| 462 |
+
elif dataset_id is not None:
|
| 463 |
+
if utils.on_spaces():
|
| 464 |
+
os.environ["TRACKIO_DATASET_ID"] = dataset_id
|
| 465 |
+
print(
|
| 466 |
+
f"* Trackio metrics will be synced to Hugging Face Dataset: {dataset_id}"
|
| 467 |
+
)
|
| 468 |
+
if remote_source is None:
|
| 469 |
+
print(f"* Trackio metrics logged to: {TRACKIO_DIR}")
|
| 470 |
+
_should_embed_local = embed and utils.is_in_notebook()
|
| 471 |
+
if not _should_embed_local:
|
| 472 |
+
utils.print_dashboard_instructions(project)
|
| 473 |
+
elif server_base_url is not None:
|
| 474 |
+
print(
|
| 475 |
+
f"* Trackio metrics will be sent to self-hosted server: {server_base_url}"
|
| 476 |
+
)
|
| 477 |
+
if utils.is_in_notebook() and embed:
|
| 478 |
+
utils.embed_url_in_notebook(server_base_url)
|
| 479 |
+
else:
|
| 480 |
+
try:
|
| 481 |
+
if deploy.create_space_if_not_exists(
|
| 482 |
+
space_id,
|
| 483 |
+
space_storage,
|
| 484 |
+
dataset_id,
|
| 485 |
+
bucket_id,
|
| 486 |
+
private,
|
| 487 |
+
):
|
| 488 |
+
_spaces_created_this_session.add(space_id)
|
| 489 |
+
newly_created_space = True
|
| 490 |
+
user_name, space_name = space_id.split("/")
|
| 491 |
+
space_url = deploy.SPACE_HOST_URL.format(
|
| 492 |
+
user_name=user_name, space_name=space_name
|
| 493 |
+
)
|
| 494 |
+
if utils.is_in_notebook() and embed:
|
| 495 |
+
utils.embed_url_in_notebook(space_url)
|
| 496 |
+
except Exception as e:
|
| 497 |
+
_emit_nonfatal_warning(
|
| 498 |
+
f"trackio.init() could not prepare Space '{space_id}': {e}. Logging will continue in local fallback mode until the Space is reachable."
|
| 499 |
+
)
|
| 500 |
+
context_vars.current_project.set(project)
|
| 501 |
+
|
| 502 |
+
remote_client = None
|
| 503 |
+
if space_id is not None and not newly_created_space:
|
| 504 |
+
try:
|
| 505 |
+
remote_client = RemoteClient(
|
| 506 |
+
space_id,
|
| 507 |
+
hf_token=huggingface_hub.utils.get_token(),
|
| 508 |
+
verbose=False,
|
| 509 |
+
)
|
| 510 |
+
except Exception as e:
|
| 511 |
+
_emit_nonfatal_warning(
|
| 512 |
+
f"trackio.init() could not create a remote client for Space '{space_id}': {e}. Continuing with local fallback metadata lookups."
|
| 513 |
+
)
|
| 514 |
+
elif server_base_url is not None:
|
| 515 |
+
try:
|
| 516 |
+
remote_client = RemoteClient(
|
| 517 |
+
server_base_url,
|
| 518 |
+
hf_token=None,
|
| 519 |
+
write_token=write_token_resolved,
|
| 520 |
+
verbose=False,
|
| 521 |
+
)
|
| 522 |
+
except Exception as e:
|
| 523 |
+
_emit_nonfatal_warning(
|
| 524 |
+
f"trackio.init() could not create a remote client for '{server_base_url}': {e}. Continuing with local fallback metadata lookups."
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
existing_run_records = _safe_get_runs_for_init(
|
| 528 |
+
project,
|
| 529 |
+
space_id,
|
| 530 |
+
server_base_url,
|
| 531 |
+
write_token_resolved,
|
| 532 |
+
resume,
|
| 533 |
+
remote_client=remote_client,
|
| 534 |
+
check_existing_for_never=name is not None,
|
| 535 |
+
)
|
| 536 |
+
existing_runs = [
|
| 537 |
+
r["name"] if isinstance(r, dict) else r for r in existing_run_records
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
existing_run = (
|
| 541 |
+
_safe_get_latest_run_for_init(
|
| 542 |
+
project,
|
| 543 |
+
name,
|
| 544 |
+
space_id=space_id,
|
| 545 |
+
server_base_url=server_base_url,
|
| 546 |
+
write_token=write_token_resolved,
|
| 547 |
+
remote_client=remote_client,
|
| 548 |
+
)
|
| 549 |
+
if name is not None
|
| 550 |
+
else None
|
| 551 |
+
)
|
| 552 |
+
resolved_run_id = None
|
| 553 |
+
|
| 554 |
+
if resume == "must":
|
| 555 |
+
if name is None:
|
| 556 |
+
raise ValueError("Must provide a run name when resume='must'")
|
| 557 |
+
if existing_run is None:
|
| 558 |
+
raise ValueError(f"Run '{name}' does not exist in project '{project}'")
|
| 559 |
+
resumed = True
|
| 560 |
+
resolved_run_id = existing_run["id"]
|
| 561 |
+
elif resume == "allow":
|
| 562 |
+
resumed = existing_run is not None
|
| 563 |
+
if resumed:
|
| 564 |
+
resolved_run_id = existing_run["id"]
|
| 565 |
+
elif resume == "never":
|
| 566 |
+
resumed = False
|
| 567 |
+
else:
|
| 568 |
+
raise ValueError("resume must be one of: 'must', 'allow', or 'never'")
|
| 569 |
+
|
| 570 |
+
initial_last_step = (
|
| 571 |
+
_safe_get_last_step_for_init(
|
| 572 |
+
project,
|
| 573 |
+
name,
|
| 574 |
+
space_id,
|
| 575 |
+
server_base_url,
|
| 576 |
+
write_token_resolved,
|
| 577 |
+
resumed,
|
| 578 |
+
run_id=resolved_run_id,
|
| 579 |
+
remote_client=remote_client,
|
| 580 |
+
)
|
| 581 |
+
if name is not None
|
| 582 |
+
else None
|
| 583 |
+
)
|
| 584 |
+
|
| 585 |
+
auto_log_cpu_detected = False
|
| 586 |
+
if auto_log_cpu is None:
|
| 587 |
+
auto_log_cpu_detected = cpu_available()
|
| 588 |
+
auto_log_cpu = auto_log_cpu_detected
|
| 589 |
+
|
| 590 |
+
auto_log_gpu_detected = False
|
| 591 |
+
nvidia_available = False
|
| 592 |
+
apple_available = False
|
| 593 |
+
if auto_log_gpu is None:
|
| 594 |
+
nvidia_available = gpu_available()
|
| 595 |
+
apple_available = apple_gpu_available()
|
| 596 |
+
auto_log_gpu_detected = nvidia_available or apple_available
|
| 597 |
+
auto_log_gpu = auto_log_gpu_detected
|
| 598 |
+
|
| 599 |
+
if project not in _projects_notified_auto_log_hw:
|
| 600 |
+
if nvidia_available:
|
| 601 |
+
print("* NVIDIA GPU detected, enabling automatic GPU metrics logging")
|
| 602 |
+
elif apple_available:
|
| 603 |
+
print(
|
| 604 |
+
"* Apple Silicon detected, enabling automatic GPU/system metrics logging"
|
| 605 |
+
)
|
| 606 |
+
if auto_log_cpu_detected:
|
| 607 |
+
print("* psutil detected, enabling automatic CPU/system metrics logging")
|
| 608 |
+
if auto_log_gpu_detected or auto_log_cpu_detected:
|
| 609 |
+
_projects_notified_auto_log_hw.add(project)
|
| 610 |
+
|
| 611 |
+
run = Run(
|
| 612 |
+
url=url,
|
| 613 |
+
project=project,
|
| 614 |
+
client=None,
|
| 615 |
+
name=name,
|
| 616 |
+
run_id=resolved_run_id,
|
| 617 |
+
group=group,
|
| 618 |
+
config=config,
|
| 619 |
+
space_id=space_id,
|
| 620 |
+
bucket_id=bucket_id,
|
| 621 |
+
server_base_url=server_base_url,
|
| 622 |
+
write_token=write_token_resolved,
|
| 623 |
+
existing_runs=existing_runs,
|
| 624 |
+
initial_last_step=initial_last_step,
|
| 625 |
+
auto_log_gpu=auto_log_gpu,
|
| 626 |
+
gpu_log_interval=gpu_log_interval,
|
| 627 |
+
auto_log_cpu=auto_log_cpu,
|
| 628 |
+
cpu_log_interval=cpu_log_interval,
|
| 629 |
+
webhook_url=webhook_url,
|
| 630 |
+
webhook_min_level=webhook_min_level,
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
if space_id is not None:
|
| 634 |
+
try:
|
| 635 |
+
SQLiteStorage.set_project_metadata(project, "space_id", space_id)
|
| 636 |
+
except Exception as e:
|
| 637 |
+
_emit_nonfatal_warning(
|
| 638 |
+
f"trackio.init() could not persist Space metadata for project '{project}': {e}. Logging will continue."
|
| 639 |
+
)
|
| 640 |
+
try:
|
| 641 |
+
if SQLiteStorage.has_pending_data(project):
|
| 642 |
+
run._has_local_buffer = True
|
| 643 |
+
except Exception as e:
|
| 644 |
+
_emit_nonfatal_warning(
|
| 645 |
+
f"trackio.init() could not inspect pending buffered data for project '{project}': {e}. Logging will continue."
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
global _atexit_registered
|
| 649 |
+
if not _atexit_registered:
|
| 650 |
+
atexit.register(_cleanup_current_run)
|
| 651 |
+
_atexit_registered = True
|
| 652 |
+
|
| 653 |
+
if resumed:
|
| 654 |
+
print(f"* Resumed existing run: {run.name}")
|
| 655 |
+
else:
|
| 656 |
+
print(f"* Created new run: {run.name}")
|
| 657 |
+
|
| 658 |
+
context_vars.current_run.set(run)
|
| 659 |
+
globals()["config"] = run.config
|
| 660 |
+
|
| 661 |
+
if space_id is not None or server_url is None:
|
| 662 |
+
try:
|
| 663 |
+
from trackio import logbook as _logbook # noqa: PLC0415
|
| 664 |
+
|
| 665 |
+
_logbook.auto_note_dashboard(project, space_id=space_id)
|
| 666 |
+
except Exception:
|
| 667 |
+
pass
|
| 668 |
+
|
| 669 |
+
if _should_embed_local:
|
| 670 |
+
try:
|
| 671 |
+
show(project=project, open_browser=False, block_thread=False)
|
| 672 |
+
except Exception as e:
|
| 673 |
+
_emit_nonfatal_warning(
|
| 674 |
+
f"trackio.init() could not auto-launch the dashboard: {e}. Logging will continue."
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
return run
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def log(metrics: dict, step: int | None = None) -> None:
|
| 681 |
+
"""
|
| 682 |
+
Logs metrics to the current run.
|
| 683 |
+
|
| 684 |
+
Args:
|
| 685 |
+
metrics (`dict`):
|
| 686 |
+
A dictionary of metrics to log.
|
| 687 |
+
step (`int`, *optional*):
|
| 688 |
+
The step number. If not provided, the step will be incremented
|
| 689 |
+
automatically.
|
| 690 |
+
"""
|
| 691 |
+
run = context_vars.current_run.get()
|
| 692 |
+
if run is None:
|
| 693 |
+
raise RuntimeError("Call trackio.init() before trackio.log().")
|
| 694 |
+
run.log(
|
| 695 |
+
metrics=metrics,
|
| 696 |
+
step=step,
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def log_system(metrics: dict) -> None:
|
| 701 |
+
"""
|
| 702 |
+
Logs system metrics (GPU, etc.) to the current run using timestamps instead of steps.
|
| 703 |
+
|
| 704 |
+
Args:
|
| 705 |
+
metrics (`dict`):
|
| 706 |
+
A dictionary of system metrics to log.
|
| 707 |
+
"""
|
| 708 |
+
run = context_vars.current_run.get()
|
| 709 |
+
if run is None:
|
| 710 |
+
raise RuntimeError("Call trackio.init() before trackio.log_system().")
|
| 711 |
+
run.log_system(metrics=metrics)
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def log_artifact(
|
| 715 |
+
artifact_or_path: Artifact | str | Path,
|
| 716 |
+
name: str | None = None,
|
| 717 |
+
type: str | None = None,
|
| 718 |
+
aliases: list[str] | None = None,
|
| 719 |
+
) -> Artifact:
|
| 720 |
+
"""
|
| 721 |
+
Logs an artifact as an output of the current run.
|
| 722 |
+
|
| 723 |
+
Args:
|
| 724 |
+
artifact_or_path (`Artifact`, `str`, or `Path`):
|
| 725 |
+
The artifact to log (must have at least one file added via
|
| 726 |
+
`add_file` or `add_dir`), or a path to a file or directory to
|
| 727 |
+
log as a new artifact.
|
| 728 |
+
name (`str`, *optional*):
|
| 729 |
+
Artifact name when logging a path. Defaults to
|
| 730 |
+
`run-<run_id>-<basename>`. Must not be passed with an `Artifact`.
|
| 731 |
+
type (`str`, *optional*):
|
| 732 |
+
Artifact type when logging a path (e.g. `"model"`, `"dataset"`).
|
| 733 |
+
Defaults to `"unspecified"`. Must not be passed with an
|
| 734 |
+
`Artifact`.
|
| 735 |
+
aliases (`list[str]`, *optional*):
|
| 736 |
+
Aliases to rotate onto the resulting version, alongside `latest`
|
| 737 |
+
(assigned automatically whenever a new version is created). Your
|
| 738 |
+
aliases rotate onto the version even when identical content is
|
| 739 |
+
de-duplicated.
|
| 740 |
+
|
| 741 |
+
Returns:
|
| 742 |
+
The logged `Artifact` instance, hydrated with `version`, `aliases`,
|
| 743 |
+
`size`, `manifest`, and `project` set.
|
| 744 |
+
"""
|
| 745 |
+
run = context_vars.current_run.get()
|
| 746 |
+
if run is None:
|
| 747 |
+
raise RuntimeError("Call trackio.init() before trackio.log_artifact().")
|
| 748 |
+
return run.log_artifact(artifact_or_path, name=name, type=type, aliases=aliases)
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def use_artifact(
|
| 752 |
+
artifact_or_name: Artifact | str,
|
| 753 |
+
type: str | None = None,
|
| 754 |
+
) -> Artifact:
|
| 755 |
+
"""
|
| 756 |
+
Fetches an artifact and records it as an input to the current run.
|
| 757 |
+
|
| 758 |
+
Args:
|
| 759 |
+
artifact_or_name (`Artifact` or `str`):
|
| 760 |
+
An already-logged `Artifact`, or an artifact name. A bare name
|
| 761 |
+
(`"my-model"`) resolves to `:latest`; you can also pin a version
|
| 762 |
+
(`"my-model:v3"`) or resolve an alias (`"my-model:prod"`).
|
| 763 |
+
type (`str`, *optional*):
|
| 764 |
+
If given, checked against the stored artifact type, raising if it
|
| 765 |
+
does not match.
|
| 766 |
+
|
| 767 |
+
Returns:
|
| 768 |
+
The fetched `Artifact`, hydrated and ready to `download()`.
|
| 769 |
+
"""
|
| 770 |
+
run = context_vars.current_run.get()
|
| 771 |
+
if run is None:
|
| 772 |
+
raise RuntimeError("Call trackio.init() before trackio.use_artifact().")
|
| 773 |
+
return run.use_artifact(artifact_or_name, type=type)
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def log_gpu(run: Run | None = None, device: int | None = None) -> dict:
|
| 777 |
+
"""
|
| 778 |
+
Log GPU metrics to the current or specified run as system metrics.
|
| 779 |
+
Automatically detects whether an NVIDIA or Apple GPU is available and calls
|
| 780 |
+
the appropriate logging method.
|
| 781 |
+
|
| 782 |
+
Args:
|
| 783 |
+
run: Optional Run instance. If None, uses current run from context.
|
| 784 |
+
device: CUDA device index to collect metrics from (NVIDIA GPUs only).
|
| 785 |
+
If None, collects from all GPUs visible to this process.
|
| 786 |
+
This parameter is ignored for Apple GPUs.
|
| 787 |
+
|
| 788 |
+
Returns:
|
| 789 |
+
dict: The GPU metrics that were logged.
|
| 790 |
+
|
| 791 |
+
Example:
|
| 792 |
+
```python
|
| 793 |
+
import trackio
|
| 794 |
+
|
| 795 |
+
run = trackio.init(project="my-project")
|
| 796 |
+
trackio.log({"loss": 0.5})
|
| 797 |
+
trackio.log_gpu()
|
| 798 |
+
trackio.log_gpu(device=0)
|
| 799 |
+
```
|
| 800 |
+
"""
|
| 801 |
+
if run is None:
|
| 802 |
+
run = context_vars.current_run.get()
|
| 803 |
+
if run is None:
|
| 804 |
+
raise RuntimeError("Call trackio.init() before trackio.log_gpu().")
|
| 805 |
+
|
| 806 |
+
if gpu_available():
|
| 807 |
+
return _log_nvidia_gpu(run=run, device=device)
|
| 808 |
+
elif apple_gpu_available():
|
| 809 |
+
return _log_apple_gpu(run=run)
|
| 810 |
+
else:
|
| 811 |
+
_emit_nonfatal_warning(
|
| 812 |
+
"No GPU detected. Install nvidia-ml-py for NVIDIA GPU support "
|
| 813 |
+
"or psutil for Apple Silicon support."
|
| 814 |
+
)
|
| 815 |
+
return {}
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
def log_cpu(run: Run | None = None) -> dict:
|
| 819 |
+
"""
|
| 820 |
+
Log CPU, RAM, disk, network, and sensor metrics to the current or specified run
|
| 821 |
+
as system metrics.
|
| 822 |
+
|
| 823 |
+
Args:
|
| 824 |
+
run: Optional Run instance. If None, uses current run from context.
|
| 825 |
+
|
| 826 |
+
Returns:
|
| 827 |
+
dict: The CPU and system metrics that were logged.
|
| 828 |
+
|
| 829 |
+
Example:
|
| 830 |
+
```python
|
| 831 |
+
import trackio
|
| 832 |
+
|
| 833 |
+
run = trackio.init(project="my-project")
|
| 834 |
+
trackio.log({"loss": 0.5})
|
| 835 |
+
trackio.log_cpu()
|
| 836 |
+
```
|
| 837 |
+
"""
|
| 838 |
+
if run is None:
|
| 839 |
+
run = context_vars.current_run.get()
|
| 840 |
+
if run is None:
|
| 841 |
+
raise RuntimeError("Call trackio.init() before trackio.log_cpu().")
|
| 842 |
+
|
| 843 |
+
return _log_cpu(run=run)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def finish():
|
| 847 |
+
"""
|
| 848 |
+
Finishes the current run.
|
| 849 |
+
"""
|
| 850 |
+
run = context_vars.current_run.get()
|
| 851 |
+
if run is None:
|
| 852 |
+
raise RuntimeError("Call trackio.init() before trackio.finish().")
|
| 853 |
+
try:
|
| 854 |
+
run.finish()
|
| 855 |
+
finally:
|
| 856 |
+
context_vars.current_run.set(None)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
def alert(
|
| 860 |
+
title: str,
|
| 861 |
+
text: str | None = None,
|
| 862 |
+
level: AlertLevel = AlertLevel.WARN,
|
| 863 |
+
webhook_url: str | None = None,
|
| 864 |
+
) -> None:
|
| 865 |
+
"""
|
| 866 |
+
Fires an alert immediately on the current run. The alert is printed to the
|
| 867 |
+
terminal, stored in the database, and displayed in the dashboard. If a
|
| 868 |
+
webhook URL is configured (via `trackio.init()`, the `TRACKIO_WEBHOOK_URL`
|
| 869 |
+
environment variable, or the `webhook_url` parameter here), the alert is
|
| 870 |
+
also POSTed to that URL.
|
| 871 |
+
|
| 872 |
+
Args:
|
| 873 |
+
title (`str`):
|
| 874 |
+
A short title for the alert.
|
| 875 |
+
text (`str`, *optional*):
|
| 876 |
+
A longer description with details about the alert.
|
| 877 |
+
level (`AlertLevel`, *optional*, defaults to `AlertLevel.WARN`):
|
| 878 |
+
The severity level. One of `AlertLevel.INFO`, `AlertLevel.WARN`,
|
| 879 |
+
or `AlertLevel.ERROR`.
|
| 880 |
+
webhook_url (`str`, *optional*):
|
| 881 |
+
A webhook URL to send this specific alert to. Overrides any
|
| 882 |
+
URL set in `trackio.init()` or the `TRACKIO_WEBHOOK_URL`
|
| 883 |
+
environment variable. Supports Slack and Discord webhook
|
| 884 |
+
URLs natively.
|
| 885 |
+
"""
|
| 886 |
+
run = context_vars.current_run.get()
|
| 887 |
+
if run is None:
|
| 888 |
+
raise RuntimeError("Call trackio.init() before trackio.alert().")
|
| 889 |
+
run.alert(title=title, text=text, level=level, webhook_url=webhook_url)
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
def delete_project(project: str, force: bool = False) -> bool:
|
| 893 |
+
"""
|
| 894 |
+
Deletes a project by removing its local SQLite database.
|
| 895 |
+
|
| 896 |
+
Args:
|
| 897 |
+
project (`str`):
|
| 898 |
+
The name of the project to delete.
|
| 899 |
+
force (`bool`, *optional*, defaults to `False`):
|
| 900 |
+
If `True`, deletes the project without prompting for confirmation.
|
| 901 |
+
If `False`, prompts the user to confirm before deleting.
|
| 902 |
+
|
| 903 |
+
Returns:
|
| 904 |
+
`bool`: `True` if the project was deleted, `False` otherwise.
|
| 905 |
+
"""
|
| 906 |
+
db_path = SQLiteStorage.get_project_db_path(project)
|
| 907 |
+
|
| 908 |
+
if not db_path.exists():
|
| 909 |
+
print(f"* Project '{project}' does not exist.")
|
| 910 |
+
return False
|
| 911 |
+
|
| 912 |
+
if not force:
|
| 913 |
+
response = input(
|
| 914 |
+
f"Are you sure you want to delete project '{project}'? "
|
| 915 |
+
f"This will permanently delete all runs and metrics. (y/N): "
|
| 916 |
+
)
|
| 917 |
+
if response.lower() not in ["y", "yes"]:
|
| 918 |
+
print("* Deletion cancelled.")
|
| 919 |
+
return False
|
| 920 |
+
|
| 921 |
+
try:
|
| 922 |
+
db_path.unlink()
|
| 923 |
+
|
| 924 |
+
for suffix in ("-wal", "-shm"):
|
| 925 |
+
sidecar = Path(str(db_path) + suffix)
|
| 926 |
+
if sidecar.exists():
|
| 927 |
+
sidecar.unlink()
|
| 928 |
+
|
| 929 |
+
for parquet_path in SQLiteStorage._project_parquet_paths(db_path):
|
| 930 |
+
if parquet_path.exists():
|
| 931 |
+
parquet_path.unlink()
|
| 932 |
+
|
| 933 |
+
for asset_dir in (
|
| 934 |
+
utils.project_artifacts_dir(project),
|
| 935 |
+
utils.project_media_dir(project),
|
| 936 |
+
):
|
| 937 |
+
if asset_dir.exists():
|
| 938 |
+
shutil.rmtree(asset_dir, ignore_errors=True)
|
| 939 |
+
|
| 940 |
+
print(f"* Project '{project}' has been deleted.")
|
| 941 |
+
return True
|
| 942 |
+
except Exception as e:
|
| 943 |
+
print(f"* Error deleting project '{project}': {e}")
|
| 944 |
+
return False
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
def save(
|
| 948 |
+
glob_str: str | Path,
|
| 949 |
+
project: str | None = None,
|
| 950 |
+
) -> str:
|
| 951 |
+
"""
|
| 952 |
+
Saves files to a project (not linked to a specific run). If Trackio is running
|
| 953 |
+
locally, the file(s) will be copied to the project's files directory. If Trackio is
|
| 954 |
+
running in a Space, the file(s) will be uploaded to the Space's files directory.
|
| 955 |
+
|
| 956 |
+
Args:
|
| 957 |
+
glob_str (`str` or `Path`):
|
| 958 |
+
The file path or glob pattern to save. Can be a single file or a pattern
|
| 959 |
+
matching multiple files (e.g., `"*.py"`, `"models/**/*.pth"`).
|
| 960 |
+
project (`str`, *optional*):
|
| 961 |
+
The name of the project to save files to. If not provided, uses the current
|
| 962 |
+
project from `trackio.init()`. If no project is initialized, raises an
|
| 963 |
+
error.
|
| 964 |
+
|
| 965 |
+
Returns:
|
| 966 |
+
`str`: The path where the file(s) were saved (project's files directory).
|
| 967 |
+
|
| 968 |
+
Example:
|
| 969 |
+
```python
|
| 970 |
+
import trackio
|
| 971 |
+
|
| 972 |
+
trackio.init(project="my-project")
|
| 973 |
+
trackio.save("config.yaml")
|
| 974 |
+
trackio.save("models/*.pth")
|
| 975 |
+
```
|
| 976 |
+
"""
|
| 977 |
+
if project is None:
|
| 978 |
+
project = context_vars.current_project.get()
|
| 979 |
+
if project is None:
|
| 980 |
+
raise RuntimeError(
|
| 981 |
+
"No project specified. Either call trackio.init() first or provide a "
|
| 982 |
+
"project parameter to trackio.save()."
|
| 983 |
+
)
|
| 984 |
+
|
| 985 |
+
glob_str = Path(glob_str)
|
| 986 |
+
base_path = Path.cwd().resolve()
|
| 987 |
+
|
| 988 |
+
matched_files = []
|
| 989 |
+
if glob_str.is_file():
|
| 990 |
+
matched_files = [glob_str.resolve()]
|
| 991 |
+
else:
|
| 992 |
+
pattern = str(glob_str)
|
| 993 |
+
if not glob_str.is_absolute():
|
| 994 |
+
pattern = str((Path.cwd() / glob_str).resolve())
|
| 995 |
+
matched_files = [
|
| 996 |
+
Path(f).resolve()
|
| 997 |
+
for f in glob.glob(pattern, recursive=True)
|
| 998 |
+
if Path(f).is_file()
|
| 999 |
+
]
|
| 1000 |
+
|
| 1001 |
+
if not matched_files:
|
| 1002 |
+
raise ValueError(f"No files found matching pattern: {glob_str}")
|
| 1003 |
+
|
| 1004 |
+
current_run = context_vars.current_run.get()
|
| 1005 |
+
is_local = (
|
| 1006 |
+
current_run._is_local
|
| 1007 |
+
if current_run is not None
|
| 1008 |
+
else (
|
| 1009 |
+
context_vars.current_space_id.get() is None
|
| 1010 |
+
and context_vars.current_server.get() is None
|
| 1011 |
+
)
|
| 1012 |
+
)
|
| 1013 |
+
|
| 1014 |
+
if is_local:
|
| 1015 |
+
for file_path in matched_files:
|
| 1016 |
+
try:
|
| 1017 |
+
relative_to_base = file_path.relative_to(base_path)
|
| 1018 |
+
except ValueError:
|
| 1019 |
+
relative_to_base = Path(file_path.name)
|
| 1020 |
+
|
| 1021 |
+
if current_run is not None:
|
| 1022 |
+
current_run._queue_upload(
|
| 1023 |
+
file_path,
|
| 1024 |
+
step=None,
|
| 1025 |
+
relative_path=str(relative_to_base.parent),
|
| 1026 |
+
use_run_name=False,
|
| 1027 |
+
)
|
| 1028 |
+
else:
|
| 1029 |
+
media_path = get_project_media_path(
|
| 1030 |
+
project=project,
|
| 1031 |
+
run=None,
|
| 1032 |
+
step=None,
|
| 1033 |
+
relative_path=str(relative_to_base),
|
| 1034 |
+
)
|
| 1035 |
+
shutil.copy(str(file_path), str(media_path))
|
| 1036 |
+
else:
|
| 1037 |
+
url = context_vars.current_server.get()
|
| 1038 |
+
|
| 1039 |
+
upload_entries = []
|
| 1040 |
+
for file_path in matched_files:
|
| 1041 |
+
try:
|
| 1042 |
+
relative_to_base = file_path.relative_to(base_path)
|
| 1043 |
+
except ValueError:
|
| 1044 |
+
relative_to_base = Path(file_path.name)
|
| 1045 |
+
|
| 1046 |
+
if current_run is not None:
|
| 1047 |
+
current_run._queue_upload(
|
| 1048 |
+
file_path,
|
| 1049 |
+
step=None,
|
| 1050 |
+
relative_path=str(relative_to_base.parent),
|
| 1051 |
+
use_run_name=False,
|
| 1052 |
+
)
|
| 1053 |
+
else:
|
| 1054 |
+
upload_entry: UploadEntry = {
|
| 1055 |
+
"project": project,
|
| 1056 |
+
"run": None,
|
| 1057 |
+
"step": None,
|
| 1058 |
+
"relative_path": str(relative_to_base),
|
| 1059 |
+
"uploaded_file": handle_file(file_path),
|
| 1060 |
+
}
|
| 1061 |
+
upload_entries.append(upload_entry)
|
| 1062 |
+
|
| 1063 |
+
if upload_entries:
|
| 1064 |
+
if url is None:
|
| 1065 |
+
raise RuntimeError(
|
| 1066 |
+
"No server available. Call trackio.init() before trackio.save() to start the server."
|
| 1067 |
+
)
|
| 1068 |
+
|
| 1069 |
+
try:
|
| 1070 |
+
wt = context_vars.current_server_write_token.get()
|
| 1071 |
+
if wt is not None:
|
| 1072 |
+
client = RemoteClient(
|
| 1073 |
+
url,
|
| 1074 |
+
hf_token=None,
|
| 1075 |
+
write_token=wt,
|
| 1076 |
+
httpx_kwargs={"timeout": 90},
|
| 1077 |
+
)
|
| 1078 |
+
else:
|
| 1079 |
+
client = RemoteClient(
|
| 1080 |
+
url,
|
| 1081 |
+
hf_token=huggingface_hub.utils.get_token(),
|
| 1082 |
+
httpx_kwargs={"timeout": 90},
|
| 1083 |
+
)
|
| 1084 |
+
client.predict(
|
| 1085 |
+
api_name="/bulk_upload_media",
|
| 1086 |
+
uploads=upload_entries,
|
| 1087 |
+
hf_token=huggingface_hub.utils.get_token() if wt is None else None,
|
| 1088 |
+
)
|
| 1089 |
+
except Exception as e:
|
| 1090 |
+
_emit_nonfatal_warning(
|
| 1091 |
+
f"Failed to upload files: {e}. "
|
| 1092 |
+
"Files may not be available in the dashboard."
|
| 1093 |
+
)
|
| 1094 |
+
|
| 1095 |
+
return str(utils.project_media_dir(project) / "files")
|
| 1096 |
+
|
| 1097 |
+
|
| 1098 |
+
def show(
|
| 1099 |
+
project: str | None = None,
|
| 1100 |
+
*,
|
| 1101 |
+
theme: Any = None,
|
| 1102 |
+
mcp_server: bool | None = None,
|
| 1103 |
+
footer: bool = True,
|
| 1104 |
+
color_palette: list[str] | None = None,
|
| 1105 |
+
open_browser: bool = True,
|
| 1106 |
+
block_thread: bool | None = None,
|
| 1107 |
+
host: str | None = None,
|
| 1108 |
+
share: bool | None = None,
|
| 1109 |
+
server_port: int | None = None,
|
| 1110 |
+
frontend_dir: str | Path | None = None,
|
| 1111 |
+
):
|
| 1112 |
+
"""
|
| 1113 |
+
Launches the Trackio dashboard.
|
| 1114 |
+
|
| 1115 |
+
Args:
|
| 1116 |
+
project (`str`, *optional*):
|
| 1117 |
+
The name of the project whose runs to show. If not provided, all projects
|
| 1118 |
+
will be shown and the user can select one.
|
| 1119 |
+
theme (`Any`, *optional*):
|
| 1120 |
+
Ignored. Kept for backward compatibility; Trackio no longer uses Gradio themes.
|
| 1121 |
+
mcp_server (`bool`, *optional*):
|
| 1122 |
+
If `True`, the dashboard exposes an MCP server at `/mcp` when the optional
|
| 1123 |
+
`trackio[mcp]` dependency is installed. If `None` (default), the
|
| 1124 |
+
`GRADIO_MCP_SERVER` environment variable is used (e.g. on Spaces).
|
| 1125 |
+
footer (`bool`, *optional*, defaults to `True`):
|
| 1126 |
+
Whether to include `footer=false` in the write-token URL when `False`.
|
| 1127 |
+
This can also be controlled via the `footer` query parameter in the URL.
|
| 1128 |
+
color_palette (`list[str]`, *optional*):
|
| 1129 |
+
A list of hex color codes to use for plot lines. If not provided, the
|
| 1130 |
+
`TRACKIO_COLOR_PALETTE` environment variable will be used (comma-separated
|
| 1131 |
+
hex codes), or if that is not set, the default color palette will be used.
|
| 1132 |
+
Example: `['#FF0000', '#00FF00', '#0000FF']`
|
| 1133 |
+
open_browser (`bool`, *optional*, defaults to `True`):
|
| 1134 |
+
If `True` and not in a notebook, a new browser tab will be opened with the
|
| 1135 |
+
dashboard. If `False`, the browser will not be opened.
|
| 1136 |
+
block_thread (`bool`, *optional*):
|
| 1137 |
+
If `True`, the main thread will be blocked until the dashboard is closed.
|
| 1138 |
+
If `None` (default behavior), then the main thread will not be blocked if the
|
| 1139 |
+
dashboard is launched in a notebook, otherwise the main thread will be blocked.
|
| 1140 |
+
host (`str`, *optional*):
|
| 1141 |
+
The host to bind the server to. If not provided, defaults to `'127.0.0.1'`
|
| 1142 |
+
(localhost only). Set to `'0.0.0.0'` to allow remote access.
|
| 1143 |
+
share (`bool`, *optional*):
|
| 1144 |
+
If `True`, creates a temporary public URL (Gradio-compatible tunnel). On Colab
|
| 1145 |
+
or hosted notebooks, defaults to `True` unless overridden.
|
| 1146 |
+
server_port (`int`, *optional*):
|
| 1147 |
+
Port to bind. If not set, scans from `GRADIO_SERVER_PORT` (default 7860).
|
| 1148 |
+
frontend_dir (`str | Path`, *optional*):
|
| 1149 |
+
Directory containing a custom static frontend. Must contain `index.html`.
|
| 1150 |
+
If not provided, Trackio checks `TRACKIO_FRONTEND_DIR`, then the persistent
|
| 1151 |
+
Trackio config, then the bundled frontend. If an explicit `frontend_dir`
|
| 1152 |
+
points to a missing or empty directory, Trackio copies in the starter
|
| 1153 |
+
template and serves that directory.
|
| 1154 |
+
|
| 1155 |
+
Returns:
|
| 1156 |
+
`app`: The dashboard handle (`.close()` stops the server).
|
| 1157 |
+
`url`: The local URL of the dashboard.
|
| 1158 |
+
`share_url`: The public share URL, if any.
|
| 1159 |
+
`full_url`: The full URL including the write token (share URL when sharing, else local).
|
| 1160 |
+
"""
|
| 1161 |
+
if theme is not None and theme != "default":
|
| 1162 |
+
warnings.warn(
|
| 1163 |
+
"The theme argument is ignored; Trackio no longer depends on Gradio themes.",
|
| 1164 |
+
UserWarning,
|
| 1165 |
+
stacklevel=2,
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
if color_palette is not None:
|
| 1169 |
+
os.environ["TRACKIO_COLOR_PALETTE"] = ",".join(color_palette)
|
| 1170 |
+
|
| 1171 |
+
_mcp_server = (
|
| 1172 |
+
mcp_server
|
| 1173 |
+
if mcp_server is not None
|
| 1174 |
+
else os.environ.get("GRADIO_MCP_SERVER", "False") == "True"
|
| 1175 |
+
)
|
| 1176 |
+
|
| 1177 |
+
resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
|
| 1178 |
+
starlette_app, wt = build_starlette_app_only(
|
| 1179 |
+
mcp_server=_mcp_server,
|
| 1180 |
+
frontend_dir=str(resolved_frontend.path),
|
| 1181 |
+
)
|
| 1182 |
+
local_url, share_url, _local_api_url, uv_server = launch_trackio_dashboard(
|
| 1183 |
+
starlette_app,
|
| 1184 |
+
server_name=host,
|
| 1185 |
+
server_port=server_port,
|
| 1186 |
+
share=share,
|
| 1187 |
+
mcp_server=_mcp_server,
|
| 1188 |
+
quiet=True,
|
| 1189 |
+
)
|
| 1190 |
+
server = TrackioDashboardApp(starlette_app, uv_server, wt)
|
| 1191 |
+
|
| 1192 |
+
base_root = (share_url or local_url).rstrip("/")
|
| 1193 |
+
base_url = base_root + "/"
|
| 1194 |
+
dashboard_url = base_url
|
| 1195 |
+
if project:
|
| 1196 |
+
dashboard_url += f"?project={project}"
|
| 1197 |
+
full_url = utils.get_full_url(
|
| 1198 |
+
base_root,
|
| 1199 |
+
project=project,
|
| 1200 |
+
write_token=wt,
|
| 1201 |
+
footer=footer,
|
| 1202 |
+
)
|
| 1203 |
+
|
| 1204 |
+
if not utils.is_in_notebook():
|
| 1205 |
+
print(f"\033[1m\033[38;5;208m* Trackio UI launched at: {dashboard_url}\033[0m")
|
| 1206 |
+
utils.print_write_token_instructions(full_url)
|
| 1207 |
+
if open_browser:
|
| 1208 |
+
webbrowser.open(full_url)
|
| 1209 |
+
block_thread = block_thread if block_thread is not None else True
|
| 1210 |
+
else:
|
| 1211 |
+
utils.embed_url_in_notebook(full_url)
|
| 1212 |
+
block_thread = block_thread if block_thread is not None else False
|
| 1213 |
+
|
| 1214 |
+
if block_thread:
|
| 1215 |
+
utils.block_main_thread_until_keyboard_interrupt()
|
| 1216 |
+
return _TupleNoPrint((server, local_url, share_url, full_url))
|
trackio/_vendor/__init__.py
ADDED
|
File without changes
|
trackio/_vendor/gradio_exceptions.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class ChecksumMismatchError(Exception):
|
| 2 |
+
pass
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ShareCertificateWriteError(Exception):
|
| 6 |
+
pass
|
trackio/_vendor/networking.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import warnings
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from urllib.parse import urlparse, urlunparse
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
from trackio._vendor.gradio_exceptions import ShareCertificateWriteError
|
| 12 |
+
from trackio._vendor.tunneling import CERTIFICATE_PATH, Tunnel
|
| 13 |
+
|
| 14 |
+
GRADIO_API_SERVER = "https://api.gradio.app/v3/tunnel-request"
|
| 15 |
+
GRADIO_SHARE_SERVER_ADDRESS = os.getenv("GRADIO_SHARE_SERVER_ADDRESS")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def setup_tunnel(
|
| 19 |
+
local_host: str,
|
| 20 |
+
local_port: int,
|
| 21 |
+
share_token: str,
|
| 22 |
+
share_server_address: str | None,
|
| 23 |
+
share_server_tls_certificate: str | None,
|
| 24 |
+
) -> str:
|
| 25 |
+
share_server_address = (
|
| 26 |
+
GRADIO_SHARE_SERVER_ADDRESS
|
| 27 |
+
if share_server_address is None
|
| 28 |
+
else share_server_address
|
| 29 |
+
)
|
| 30 |
+
if share_server_address is None:
|
| 31 |
+
try:
|
| 32 |
+
response = httpx.get(GRADIO_API_SERVER, timeout=30)
|
| 33 |
+
payload = response.json()[0]
|
| 34 |
+
remote_host, remote_port = payload["host"], int(payload["port"])
|
| 35 |
+
certificate = payload["root_ca"]
|
| 36 |
+
except Exception as e:
|
| 37 |
+
raise RuntimeError(
|
| 38 |
+
"Could not get share link from Gradio API Server."
|
| 39 |
+
) from e
|
| 40 |
+
try:
|
| 41 |
+
Path(CERTIFICATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
with open(CERTIFICATE_PATH, "w") as f:
|
| 43 |
+
f.write(certificate)
|
| 44 |
+
except Exception as e:
|
| 45 |
+
raise ShareCertificateWriteError(
|
| 46 |
+
f"{e}. This can happen if the current working directory is read-only."
|
| 47 |
+
) from e
|
| 48 |
+
share_server_tls_certificate = CERTIFICATE_PATH
|
| 49 |
+
|
| 50 |
+
else:
|
| 51 |
+
remote_host, remote_port = share_server_address.split(":")
|
| 52 |
+
remote_port = int(remote_port)
|
| 53 |
+
tunnel = Tunnel(
|
| 54 |
+
remote_host,
|
| 55 |
+
remote_port,
|
| 56 |
+
local_host,
|
| 57 |
+
local_port,
|
| 58 |
+
share_token,
|
| 59 |
+
share_server_tls_certificate,
|
| 60 |
+
)
|
| 61 |
+
address = tunnel.start_tunnel()
|
| 62 |
+
return address
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def url_ok(url: str) -> bool:
|
| 66 |
+
try:
|
| 67 |
+
for _ in range(5):
|
| 68 |
+
with warnings.catch_warnings():
|
| 69 |
+
warnings.filterwarnings("ignore")
|
| 70 |
+
r = httpx.head(url, timeout=3, verify=False)
|
| 71 |
+
if r.status_code in (200, 401, 302, 303, 307):
|
| 72 |
+
return True
|
| 73 |
+
time.sleep(0.500)
|
| 74 |
+
except (ConnectionError, httpx.ConnectError, httpx.TimeoutException):
|
| 75 |
+
return False
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def normalize_share_url(share_url: str, share_server_protocol: str) -> str:
|
| 80 |
+
parsed_url = urlparse(share_url)
|
| 81 |
+
return urlunparse((share_server_protocol,) + parsed_url[1:])
|
trackio/_vendor/tunneling.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import atexit
|
| 2 |
+
import hashlib
|
| 3 |
+
import os
|
| 4 |
+
import platform
|
| 5 |
+
import re
|
| 6 |
+
import stat
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from huggingface_hub.constants import HF_HOME
|
| 14 |
+
|
| 15 |
+
from trackio._vendor.gradio_exceptions import ChecksumMismatchError
|
| 16 |
+
|
| 17 |
+
VERSION = "0.3"
|
| 18 |
+
CURRENT_TUNNELS: list["Tunnel"] = []
|
| 19 |
+
|
| 20 |
+
machine = platform.machine()
|
| 21 |
+
if machine == "x86_64":
|
| 22 |
+
machine = "amd64"
|
| 23 |
+
elif machine == "aarch64":
|
| 24 |
+
machine = "arm64"
|
| 25 |
+
|
| 26 |
+
BINARY_REMOTE_NAME = f"frpc_{platform.system().lower()}_{machine.lower()}"
|
| 27 |
+
EXTENSION = ".exe" if os.name == "nt" else ""
|
| 28 |
+
BINARY_URL = f"https://cdn-media.huggingface.co/frpc-gradio-{VERSION}/{BINARY_REMOTE_NAME}{EXTENSION}"
|
| 29 |
+
|
| 30 |
+
CHECKSUMS = {
|
| 31 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_windows_amd64.exe": "14bc0ea470be5d67d79a07412bd21de8a0a179c6ac1116d7764f68e942dc9ceb",
|
| 32 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_amd64": "c791d1f047b41ff5885772fc4bf20b797c6059bbd82abb9e31de15e55d6a57c4",
|
| 33 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_arm64": "823ced25104de6dc3c9f4798dbb43f20e681207279e6ab89c40e2176ccbf70cd",
|
| 34 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_darwin_amd64": "930f8face3365810ce16689da81b7d1941fda4466225a7bbcbced9a2916a6e15",
|
| 35 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_darwin_arm64": "dfac50c690aca459ed5158fad8bfbe99f9282baf4166cf7c410a6673fbc1f327",
|
| 36 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_arm": "4b563beb2e36c448cc688174e20b53af38dc1ff2b5e362d4ddd1401f2affbfb7",
|
| 37 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_freebsd_386": "cb0a56c764ecf96dd54ed601d240c564f060ee4e58202d65ffca17c1a51ce19c",
|
| 38 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_freebsd_amd64": "516d9e6903513869a011ddcd1ec206167ad1eb5dd6640d21057acc258edecbbb",
|
| 39 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_386": "4c2f2a48cd71571498c0ac8a4d42a055f22cb7f14b4b5a2b0d584220fd60a283",
|
| 40 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips": "b309ecd594d4f0f7f33e556a80d4b67aef9319c00a8334648a618e56b23cb9e0",
|
| 41 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips64": "0372ef5505baa6f3b64c6295a86541b24b7b0dbe4ef28b344992e21f47624b7b",
|
| 42 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_riscv64": "1658eed7e8c14ea76e1d95749d58441ce24147c3d559381832c725c29cfc3df3",
|
| 43 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mipsle": "a2aaba16961d3372b79bd7a28976fcd0f0bbaebc2b50d5a7a71af2240747960f",
|
| 44 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_windows_386.exe": "721b90550195a83e15f2176d8f85a48d5a25822757cb872e9723d4bccc4e5bb6",
|
| 45 |
+
"https://cdn-media.huggingface.co/frpc-gradio-0.3/frpc_linux_mips64le": "796481edd609f31962b45cc0ab4c9798d040205ae3bf354ed1b72fb432d796b8",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
CHUNK_SIZE = 128
|
| 49 |
+
|
| 50 |
+
BINARY_FILENAME = f"{BINARY_REMOTE_NAME}_v{VERSION}"
|
| 51 |
+
BINARY_FOLDER = Path(HF_HOME) / "trackio" / "frpc"
|
| 52 |
+
BINARY_PATH = str(BINARY_FOLDER / BINARY_FILENAME)
|
| 53 |
+
|
| 54 |
+
TUNNEL_TIMEOUT_SECONDS = 30
|
| 55 |
+
TUNNEL_ERROR_MESSAGE = (
|
| 56 |
+
"Could not create share URL. "
|
| 57 |
+
"Please check the appended log from frpc for more information:"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
CERTIFICATE_PATH = ".trackio/certificate.pem"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Tunnel:
|
| 64 |
+
def __init__(
|
| 65 |
+
self,
|
| 66 |
+
remote_host: str,
|
| 67 |
+
remote_port: int,
|
| 68 |
+
local_host: str,
|
| 69 |
+
local_port: int,
|
| 70 |
+
share_token: str,
|
| 71 |
+
share_server_tls_certificate: str | None,
|
| 72 |
+
):
|
| 73 |
+
self.proc = None
|
| 74 |
+
self.url = None
|
| 75 |
+
self.remote_host = remote_host
|
| 76 |
+
self.remote_port = remote_port
|
| 77 |
+
self.local_host = local_host
|
| 78 |
+
self.local_port = local_port
|
| 79 |
+
self.share_token = share_token
|
| 80 |
+
self.share_server_tls_certificate = share_server_tls_certificate
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def download_binary():
|
| 84 |
+
if not Path(BINARY_PATH).exists():
|
| 85 |
+
Path(BINARY_FOLDER).mkdir(parents=True, exist_ok=True)
|
| 86 |
+
resp = httpx.get(BINARY_URL, timeout=30)
|
| 87 |
+
|
| 88 |
+
if resp.status_code == 403:
|
| 89 |
+
raise OSError(
|
| 90 |
+
f"Cannot set up a share link as this platform is incompatible. Please "
|
| 91 |
+
f"create a GitHub issue with information about your platform: {platform.uname()}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
resp.raise_for_status()
|
| 95 |
+
|
| 96 |
+
with open(BINARY_PATH, "wb") as file:
|
| 97 |
+
file.write(resp.content)
|
| 98 |
+
st = os.stat(BINARY_PATH)
|
| 99 |
+
os.chmod(BINARY_PATH, st.st_mode | stat.S_IEXEC)
|
| 100 |
+
|
| 101 |
+
if BINARY_URL in CHECKSUMS:
|
| 102 |
+
sha = hashlib.sha256()
|
| 103 |
+
with open(BINARY_PATH, "rb") as f:
|
| 104 |
+
for chunk in iter(lambda: f.read(CHUNK_SIZE * sha.block_size), b""):
|
| 105 |
+
sha.update(chunk)
|
| 106 |
+
calculated_hash = sha.hexdigest()
|
| 107 |
+
|
| 108 |
+
if calculated_hash != CHECKSUMS[BINARY_URL]:
|
| 109 |
+
raise ChecksumMismatchError()
|
| 110 |
+
|
| 111 |
+
def start_tunnel(self) -> str:
|
| 112 |
+
self.download_binary()
|
| 113 |
+
self.url = self._start_tunnel(BINARY_PATH)
|
| 114 |
+
return self.url
|
| 115 |
+
|
| 116 |
+
def kill(self):
|
| 117 |
+
if self.proc is not None:
|
| 118 |
+
print(f"Killing tunnel {self.local_host}:{self.local_port} <> {self.url}")
|
| 119 |
+
self.proc.terminate()
|
| 120 |
+
self.proc = None
|
| 121 |
+
|
| 122 |
+
def _start_tunnel(self, binary: str) -> str:
|
| 123 |
+
CURRENT_TUNNELS.append(self)
|
| 124 |
+
command = [
|
| 125 |
+
binary,
|
| 126 |
+
"http",
|
| 127 |
+
"-n",
|
| 128 |
+
self.share_token,
|
| 129 |
+
"-l",
|
| 130 |
+
str(self.local_port),
|
| 131 |
+
"-i",
|
| 132 |
+
self.local_host,
|
| 133 |
+
"--uc",
|
| 134 |
+
"--sd",
|
| 135 |
+
"random",
|
| 136 |
+
"--ue",
|
| 137 |
+
"--server_addr",
|
| 138 |
+
f"{self.remote_host}:{self.remote_port}",
|
| 139 |
+
"--disable_log_color",
|
| 140 |
+
]
|
| 141 |
+
if self.share_server_tls_certificate is not None:
|
| 142 |
+
command.extend(
|
| 143 |
+
[
|
| 144 |
+
"--tls_enable",
|
| 145 |
+
"--tls_trusted_ca_file",
|
| 146 |
+
self.share_server_tls_certificate,
|
| 147 |
+
]
|
| 148 |
+
)
|
| 149 |
+
self.proc = subprocess.Popen(
|
| 150 |
+
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
| 151 |
+
)
|
| 152 |
+
atexit.register(self.kill)
|
| 153 |
+
return self._read_url_from_tunnel_stream()
|
| 154 |
+
|
| 155 |
+
def _read_url_from_tunnel_stream(self) -> str:
|
| 156 |
+
start_timestamp = time.time()
|
| 157 |
+
|
| 158 |
+
log = []
|
| 159 |
+
url = ""
|
| 160 |
+
|
| 161 |
+
def _raise_tunnel_error():
|
| 162 |
+
log_text = "\n".join(log)
|
| 163 |
+
print(log_text, file=sys.stderr)
|
| 164 |
+
raise ValueError(f"{TUNNEL_ERROR_MESSAGE}\n{log_text}")
|
| 165 |
+
|
| 166 |
+
while url == "":
|
| 167 |
+
if time.time() - start_timestamp >= TUNNEL_TIMEOUT_SECONDS:
|
| 168 |
+
_raise_tunnel_error()
|
| 169 |
+
|
| 170 |
+
assert self.proc is not None # noqa: S101
|
| 171 |
+
if self.proc.stdout is None:
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
line = self.proc.stdout.readline()
|
| 175 |
+
line = line.decode("utf-8")
|
| 176 |
+
|
| 177 |
+
if line == "":
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
log.append(line.strip())
|
| 181 |
+
|
| 182 |
+
if "start proxy success" in line:
|
| 183 |
+
result = re.search("start proxy success: (.+)\n", line)
|
| 184 |
+
if result is None:
|
| 185 |
+
_raise_tunnel_error()
|
| 186 |
+
else:
|
| 187 |
+
url = result.group(1)
|
| 188 |
+
elif "login to server failed" in line:
|
| 189 |
+
_raise_tunnel_error()
|
| 190 |
+
|
| 191 |
+
return url
|
trackio/alerts.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import ssl
|
| 4 |
+
import urllib.request
|
| 5 |
+
from enum import Enum
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
import certifi
|
| 9 |
+
|
| 10 |
+
_SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
|
| 11 |
+
except ImportError:
|
| 12 |
+
_SSL_CONTEXT = None
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AlertLevel(str, Enum):
|
| 18 |
+
INFO = "info"
|
| 19 |
+
WARN = "warn"
|
| 20 |
+
ERROR = "error"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
ALERT_LEVEL_ORDER = {
|
| 24 |
+
AlertLevel.INFO: 0,
|
| 25 |
+
AlertLevel.WARN: 1,
|
| 26 |
+
AlertLevel.ERROR: 2,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
ALERT_COLORS = {
|
| 30 |
+
AlertLevel.INFO: "\033[94m",
|
| 31 |
+
AlertLevel.WARN: "\033[93m",
|
| 32 |
+
AlertLevel.ERROR: "\033[91m",
|
| 33 |
+
}
|
| 34 |
+
RESET_COLOR = "\033[0m"
|
| 35 |
+
|
| 36 |
+
LEVEL_EMOJI = {
|
| 37 |
+
AlertLevel.INFO: "ℹ️",
|
| 38 |
+
AlertLevel.WARN: "⚠️",
|
| 39 |
+
AlertLevel.ERROR: "🚨",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def format_alert_terminal(
|
| 44 |
+
level: AlertLevel, title: str, text: str | None, step: int | None
|
| 45 |
+
) -> str:
|
| 46 |
+
color = ALERT_COLORS.get(level, "")
|
| 47 |
+
step_str = f" (step {step})" if step is not None else ""
|
| 48 |
+
if text:
|
| 49 |
+
return f"{color}[TRACKIO {level.value.upper()}]{RESET_COLOR} {title}: {text}{step_str}"
|
| 50 |
+
return f"{color}[TRACKIO {level.value.upper()}]{RESET_COLOR} {title}{step_str}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _is_slack_url(url: str) -> bool:
|
| 54 |
+
return "hooks.slack.com" in url
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _is_discord_url(url: str) -> bool:
|
| 58 |
+
return "discord.com/api/webhooks" in url or "discordapp.com/api/webhooks" in url
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _build_slack_payload(
|
| 62 |
+
level: AlertLevel,
|
| 63 |
+
title: str,
|
| 64 |
+
text: str | None,
|
| 65 |
+
project: str,
|
| 66 |
+
run: str,
|
| 67 |
+
step: int | None,
|
| 68 |
+
) -> dict:
|
| 69 |
+
emoji = LEVEL_EMOJI.get(level, "")
|
| 70 |
+
step_str = f" • Step {step}" if step is not None else ""
|
| 71 |
+
header = f"{emoji} *[{level.value.upper()}] {title}*"
|
| 72 |
+
context = f"Project: {project} • Run: {run}{step_str}"
|
| 73 |
+
blocks = [
|
| 74 |
+
{"type": "section", "text": {"type": "mrkdwn", "text": header}},
|
| 75 |
+
]
|
| 76 |
+
if text:
|
| 77 |
+
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
|
| 78 |
+
blocks.append(
|
| 79 |
+
{"type": "context", "elements": [{"type": "mrkdwn", "text": context}]}
|
| 80 |
+
)
|
| 81 |
+
return {"blocks": blocks}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _build_discord_payload(
|
| 85 |
+
level: AlertLevel,
|
| 86 |
+
title: str,
|
| 87 |
+
text: str | None,
|
| 88 |
+
project: str,
|
| 89 |
+
run: str,
|
| 90 |
+
step: int | None,
|
| 91 |
+
) -> dict:
|
| 92 |
+
color_map = {
|
| 93 |
+
AlertLevel.INFO: 3447003,
|
| 94 |
+
AlertLevel.WARN: 16776960,
|
| 95 |
+
AlertLevel.ERROR: 15158332,
|
| 96 |
+
}
|
| 97 |
+
emoji = LEVEL_EMOJI.get(level, "")
|
| 98 |
+
step_str = f" • Step {step}" if step is not None else ""
|
| 99 |
+
embed = {
|
| 100 |
+
"title": f"{emoji} [{level.value.upper()}] {title}",
|
| 101 |
+
"color": color_map.get(level, 0),
|
| 102 |
+
"footer": {"text": f"Project: {project} • Run: {run}{step_str}"},
|
| 103 |
+
}
|
| 104 |
+
if text:
|
| 105 |
+
embed["description"] = text
|
| 106 |
+
return {"embeds": [embed]}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _build_generic_payload(
|
| 110 |
+
level: AlertLevel,
|
| 111 |
+
title: str,
|
| 112 |
+
text: str | None,
|
| 113 |
+
project: str,
|
| 114 |
+
run: str,
|
| 115 |
+
step: int | None,
|
| 116 |
+
timestamp: str | None,
|
| 117 |
+
) -> dict:
|
| 118 |
+
return {
|
| 119 |
+
"level": level.value,
|
| 120 |
+
"title": title,
|
| 121 |
+
"text": text,
|
| 122 |
+
"project": project,
|
| 123 |
+
"run": run,
|
| 124 |
+
"step": step,
|
| 125 |
+
"timestamp": timestamp,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def parse_alert_level(level: AlertLevel | str) -> AlertLevel:
|
| 130 |
+
if isinstance(level, AlertLevel):
|
| 131 |
+
return level
|
| 132 |
+
normalized = level.lower().strip()
|
| 133 |
+
try:
|
| 134 |
+
return AlertLevel(normalized)
|
| 135 |
+
except ValueError as e:
|
| 136 |
+
allowed = ", ".join(lvl.value for lvl in AlertLevel)
|
| 137 |
+
raise ValueError(
|
| 138 |
+
f"Invalid alert level '{level}'. Expected one of: {allowed}."
|
| 139 |
+
) from e
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def resolve_webhook_min_level(
|
| 143 |
+
webhook_min_level: AlertLevel | str | None,
|
| 144 |
+
) -> AlertLevel | None:
|
| 145 |
+
if webhook_min_level is None:
|
| 146 |
+
return None
|
| 147 |
+
return parse_alert_level(webhook_min_level)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def should_send_webhook(
|
| 151 |
+
level: AlertLevel, webhook_min_level: AlertLevel | None
|
| 152 |
+
) -> bool:
|
| 153 |
+
if webhook_min_level is None:
|
| 154 |
+
return True
|
| 155 |
+
return ALERT_LEVEL_ORDER[level] >= ALERT_LEVEL_ORDER[webhook_min_level]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def send_webhook(
|
| 159 |
+
url: str,
|
| 160 |
+
level: AlertLevel,
|
| 161 |
+
title: str,
|
| 162 |
+
text: str | None,
|
| 163 |
+
project: str,
|
| 164 |
+
run: str,
|
| 165 |
+
step: int | None,
|
| 166 |
+
timestamp: str | None = None,
|
| 167 |
+
) -> None:
|
| 168 |
+
if _is_slack_url(url):
|
| 169 |
+
payload = _build_slack_payload(level, title, text, project, run, step)
|
| 170 |
+
elif _is_discord_url(url):
|
| 171 |
+
payload = _build_discord_payload(level, title, text, project, run, step)
|
| 172 |
+
else:
|
| 173 |
+
payload = _build_generic_payload(
|
| 174 |
+
level, title, text, project, run, step, timestamp
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
data = json.dumps(payload).encode("utf-8")
|
| 178 |
+
req = urllib.request.Request(
|
| 179 |
+
url, data=data, headers={"Content-Type": "application/json"}
|
| 180 |
+
)
|
| 181 |
+
try:
|
| 182 |
+
urllib.request.urlopen(req, timeout=10, context=_SSL_CONTEXT)
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.warning(f"Failed to send webhook to {url}: {e}")
|
trackio/api.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterator
|
| 2 |
+
|
| 3 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Run:
|
| 7 |
+
def __init__(self, project: str, name: str, run_id: str | None = None):
|
| 8 |
+
self.project = project
|
| 9 |
+
self.name = name
|
| 10 |
+
self._id = run_id or name
|
| 11 |
+
self._config = None
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def id(self) -> str:
|
| 15 |
+
return self._id
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def config(self) -> dict | None:
|
| 19 |
+
if self._config is None:
|
| 20 |
+
self._config = SQLiteStorage.get_run_config(
|
| 21 |
+
self.project, self.name, run_id=self.id
|
| 22 |
+
)
|
| 23 |
+
return self._config
|
| 24 |
+
|
| 25 |
+
def alerts(self, level: str | None = None, since: str | None = None) -> list[dict]:
|
| 26 |
+
return SQLiteStorage.get_alerts(
|
| 27 |
+
self.project, run_name=self.name, run_id=self.id, level=level, since=since
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
def delete(self) -> bool:
|
| 31 |
+
return SQLiteStorage.delete_run(self.project, self.name, run_id=self.id)
|
| 32 |
+
|
| 33 |
+
def move(self, new_project: str) -> bool:
|
| 34 |
+
success = SQLiteStorage.move_run(
|
| 35 |
+
self.project, self.name, new_project, run_id=self.id
|
| 36 |
+
)
|
| 37 |
+
if success:
|
| 38 |
+
self.project = new_project
|
| 39 |
+
return success
|
| 40 |
+
|
| 41 |
+
def rename(self, new_name: str) -> "Run":
|
| 42 |
+
SQLiteStorage.rename_run(self.project, self.name, new_name, run_id=self.id)
|
| 43 |
+
self.name = new_name
|
| 44 |
+
return self
|
| 45 |
+
|
| 46 |
+
def __repr__(self) -> str:
|
| 47 |
+
return f"<Run {self.name} in project {self.project}>"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class Runs:
|
| 51 |
+
def __init__(self, project: str):
|
| 52 |
+
self.project = project
|
| 53 |
+
self._runs = None
|
| 54 |
+
|
| 55 |
+
def _load_runs(self):
|
| 56 |
+
if self._runs is None:
|
| 57 |
+
records = SQLiteStorage.get_run_records(self.project)
|
| 58 |
+
self._runs = [
|
| 59 |
+
Run(
|
| 60 |
+
self.project,
|
| 61 |
+
str(record["name"]),
|
| 62 |
+
run_id=str(record["id"]) if record["id"] is not None else None,
|
| 63 |
+
)
|
| 64 |
+
for record in records
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
def __iter__(self) -> Iterator[Run]:
|
| 68 |
+
self._load_runs()
|
| 69 |
+
return iter(self._runs)
|
| 70 |
+
|
| 71 |
+
def __getitem__(self, index: int) -> Run:
|
| 72 |
+
self._load_runs()
|
| 73 |
+
return self._runs[index]
|
| 74 |
+
|
| 75 |
+
def __len__(self) -> int:
|
| 76 |
+
self._load_runs()
|
| 77 |
+
return len(self._runs)
|
| 78 |
+
|
| 79 |
+
def __repr__(self) -> str:
|
| 80 |
+
self._load_runs()
|
| 81 |
+
return f"<Runs project={self.project} count={len(self._runs)}>"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class Api:
|
| 85 |
+
def runs(self, project: str) -> Runs:
|
| 86 |
+
if not SQLiteStorage.get_project_db_path(project).exists():
|
| 87 |
+
raise ValueError(f"Project '{project}' does not exist")
|
| 88 |
+
return Runs(project)
|
| 89 |
+
|
| 90 |
+
def alerts(
|
| 91 |
+
self,
|
| 92 |
+
project: str,
|
| 93 |
+
run: str | None = None,
|
| 94 |
+
level: str | None = None,
|
| 95 |
+
since: str | None = None,
|
| 96 |
+
) -> list[dict]:
|
| 97 |
+
if not SQLiteStorage.get_project_db_path(project).exists():
|
| 98 |
+
raise ValueError(f"Project '{project}' does not exist")
|
| 99 |
+
return SQLiteStorage.get_alerts(project, run_name=run, level=level, since=since)
|
trackio/apple_gpu.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import platform
|
| 2 |
+
import subprocess
|
| 3 |
+
import sys
|
| 4 |
+
import threading
|
| 5 |
+
import warnings
|
| 6 |
+
from typing import TYPE_CHECKING, Any
|
| 7 |
+
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
from trackio.run import Run
|
| 10 |
+
|
| 11 |
+
psutil: Any = None
|
| 12 |
+
PSUTIL_AVAILABLE = False
|
| 13 |
+
_monitor_lock = threading.Lock()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _ensure_psutil():
|
| 17 |
+
global PSUTIL_AVAILABLE, psutil
|
| 18 |
+
if PSUTIL_AVAILABLE:
|
| 19 |
+
return psutil
|
| 20 |
+
try:
|
| 21 |
+
import psutil as _psutil
|
| 22 |
+
|
| 23 |
+
psutil = _psutil
|
| 24 |
+
PSUTIL_AVAILABLE = True
|
| 25 |
+
return psutil
|
| 26 |
+
except ImportError:
|
| 27 |
+
raise ImportError(
|
| 28 |
+
"psutil is required for Apple Silicon monitoring. "
|
| 29 |
+
"Install it with: pip install psutil"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def is_apple_silicon() -> bool:
|
| 34 |
+
"""Check if running on Apple Silicon (M1/M2/M3/M4)."""
|
| 35 |
+
if platform.system() != "Darwin":
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
result = subprocess.run(
|
| 40 |
+
["sysctl", "-n", "machdep.cpu.brand_string"],
|
| 41 |
+
capture_output=True,
|
| 42 |
+
text=True,
|
| 43 |
+
timeout=1,
|
| 44 |
+
)
|
| 45 |
+
cpu_brand = result.stdout.strip()
|
| 46 |
+
return "Apple" in cpu_brand
|
| 47 |
+
except Exception:
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_gpu_info() -> dict[str, Any]:
|
| 52 |
+
"""Get Apple GPU information using ioreg."""
|
| 53 |
+
try:
|
| 54 |
+
result = subprocess.run(
|
| 55 |
+
["ioreg", "-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"],
|
| 56 |
+
capture_output=True,
|
| 57 |
+
text=True,
|
| 58 |
+
timeout=2,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
if result.returncode == 0 and result.stdout:
|
| 62 |
+
lines = result.stdout.strip().split("\n")
|
| 63 |
+
for line in lines:
|
| 64 |
+
if "IOAccelerator" in line and "class" in line:
|
| 65 |
+
return {"detected": True, "type": "Apple GPU"}
|
| 66 |
+
else:
|
| 67 |
+
print("Error collecting Apple GPU info. ioreg stdout was:", file=sys.stderr)
|
| 68 |
+
print(result.stdout, file=sys.stderr)
|
| 69 |
+
print("ioreg stderr was:", file=sys.stderr)
|
| 70 |
+
print(result.stderr, file=sys.stderr)
|
| 71 |
+
|
| 72 |
+
result = subprocess.run(
|
| 73 |
+
["system_profiler", "SPDisplaysDataType"],
|
| 74 |
+
capture_output=True,
|
| 75 |
+
text=True,
|
| 76 |
+
timeout=3,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
if result.returncode == 0 and "Apple" in result.stdout:
|
| 80 |
+
for line in result.stdout.split("\n"):
|
| 81 |
+
if "Chipset Model:" in line:
|
| 82 |
+
model = line.split(":")[-1].strip()
|
| 83 |
+
return {"detected": True, "type": model}
|
| 84 |
+
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
|
| 88 |
+
return {"detected": False}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def apple_gpu_available() -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Check if Apple GPU monitoring is available.
|
| 94 |
+
|
| 95 |
+
Returns True if running on Apple Silicon (M-series chips) and psutil is installed.
|
| 96 |
+
"""
|
| 97 |
+
try:
|
| 98 |
+
_ensure_psutil()
|
| 99 |
+
return is_apple_silicon()
|
| 100 |
+
except ImportError:
|
| 101 |
+
return False
|
| 102 |
+
except Exception:
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def collect_apple_metrics(include_cpu_metrics: bool = True) -> dict:
|
| 107 |
+
"""
|
| 108 |
+
Collect system metrics for Apple Silicon.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Dictionary of system metrics including CPU, memory, and GPU info.
|
| 112 |
+
"""
|
| 113 |
+
metrics = {}
|
| 114 |
+
|
| 115 |
+
if include_cpu_metrics:
|
| 116 |
+
from trackio.cpu import collect_cpu_metrics
|
| 117 |
+
|
| 118 |
+
metrics.update(collect_cpu_metrics(include_static=True))
|
| 119 |
+
|
| 120 |
+
gpu_info = get_gpu_info()
|
| 121 |
+
if gpu_info.get("detected"):
|
| 122 |
+
metrics["gpu/detected"] = 1
|
| 123 |
+
|
| 124 |
+
return metrics
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class AppleGpuMonitor:
|
| 128 |
+
def __init__(
|
| 129 |
+
self, run: "Run", interval: float = 10.0, include_cpu_metrics: bool = True
|
| 130 |
+
):
|
| 131 |
+
self._run = run
|
| 132 |
+
self._interval = interval
|
| 133 |
+
self._include_cpu_metrics = include_cpu_metrics
|
| 134 |
+
self._stop_flag = threading.Event()
|
| 135 |
+
self._thread: "threading.Thread | None" = None
|
| 136 |
+
|
| 137 |
+
def start(self):
|
| 138 |
+
if not is_apple_silicon():
|
| 139 |
+
warnings.warn(
|
| 140 |
+
"auto_log_gpu=True but not running on Apple Silicon. "
|
| 141 |
+
"Apple GPU logging disabled."
|
| 142 |
+
)
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
if not PSUTIL_AVAILABLE:
|
| 146 |
+
try:
|
| 147 |
+
_ensure_psutil()
|
| 148 |
+
except ImportError:
|
| 149 |
+
warnings.warn(
|
| 150 |
+
"auto_log_gpu=True but psutil not installed. "
|
| 151 |
+
"Install with: pip install psutil"
|
| 152 |
+
)
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
| 156 |
+
self._thread.start()
|
| 157 |
+
|
| 158 |
+
def stop(self):
|
| 159 |
+
self._stop_flag.set()
|
| 160 |
+
if self._thread is not None:
|
| 161 |
+
self._thread.join(timeout=2.0)
|
| 162 |
+
|
| 163 |
+
def _monitor_loop(self):
|
| 164 |
+
while not self._stop_flag.is_set():
|
| 165 |
+
try:
|
| 166 |
+
metrics = collect_apple_metrics(
|
| 167 |
+
include_cpu_metrics=self._include_cpu_metrics
|
| 168 |
+
)
|
| 169 |
+
if metrics:
|
| 170 |
+
self._run.log_system(metrics)
|
| 171 |
+
except Exception:
|
| 172 |
+
pass
|
| 173 |
+
|
| 174 |
+
self._stop_flag.wait(timeout=self._interval)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def log_apple_gpu(run: "Run | None" = None) -> dict:
|
| 178 |
+
"""
|
| 179 |
+
Log Apple Silicon system metrics to the current or specified run.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
run: Optional Run instance. If None, uses current run from context.
|
| 183 |
+
|
| 184 |
+
Returns:
|
| 185 |
+
dict: The system metrics that were logged.
|
| 186 |
+
|
| 187 |
+
Example:
|
| 188 |
+
```python
|
| 189 |
+
import trackio
|
| 190 |
+
|
| 191 |
+
run = trackio.init(project="my-project")
|
| 192 |
+
trackio.log({"loss": 0.5})
|
| 193 |
+
trackio.log_apple_gpu()
|
| 194 |
+
```
|
| 195 |
+
"""
|
| 196 |
+
from trackio import context_vars
|
| 197 |
+
|
| 198 |
+
if run is None:
|
| 199 |
+
run = context_vars.current_run.get()
|
| 200 |
+
if run is None:
|
| 201 |
+
raise RuntimeError("Call trackio.init() before trackio.log_apple_gpu().")
|
| 202 |
+
|
| 203 |
+
metrics = collect_apple_metrics()
|
| 204 |
+
if metrics:
|
| 205 |
+
run.log_system(metrics)
|
| 206 |
+
return metrics
|
trackio/artifact.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from huggingface_hub.utils import get_token
|
| 7 |
+
|
| 8 |
+
from trackio import cas, references, utils
|
| 9 |
+
from trackio.remote_client import _merge_client_headers, _resolve_src_url
|
| 10 |
+
from trackio.typehints import ETag, Manifest, ManifestEntry, Sha256Digest, URIStr
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _materialize(blob: Path, dst: Path, size: int) -> None:
|
| 14 |
+
"""Copy `blob` to `dst` unless `dst` already matches the blob's size and
|
| 15 |
+
mtime.
|
| 16 |
+
"""
|
| 17 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
blob_stat = blob.stat()
|
| 19 |
+
if dst.is_file():
|
| 20 |
+
dst_stat = dst.stat()
|
| 21 |
+
if dst_stat.st_size == size and dst_stat.st_mtime_ns == blob_stat.st_mtime_ns:
|
| 22 |
+
return
|
| 23 |
+
partial = dst.parent / cas.partial_blob_name(dst.name)
|
| 24 |
+
try:
|
| 25 |
+
shutil.copyfile(blob, partial)
|
| 26 |
+
os.utime(partial, ns=(blob_stat.st_atime_ns, blob_stat.st_mtime_ns))
|
| 27 |
+
os.replace(partial, dst)
|
| 28 |
+
except Exception:
|
| 29 |
+
partial.unlink(missing_ok=True)
|
| 30 |
+
raise
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _fetch_blob_from_remote(
|
| 34 |
+
remote_source: dict,
|
| 35 |
+
project: str,
|
| 36 |
+
digest: Sha256Digest,
|
| 37 |
+
target_path: Path,
|
| 38 |
+
) -> None:
|
| 39 |
+
space_id = remote_source.get("space_id")
|
| 40 |
+
src = space_id or remote_source.get("server_base_url")
|
| 41 |
+
if not src:
|
| 42 |
+
raise RuntimeError(
|
| 43 |
+
"Artifact has _remote_source set but neither space_id nor "
|
| 44 |
+
"server_base_url is populated."
|
| 45 |
+
)
|
| 46 |
+
base_url = _resolve_src_url(src).rstrip("/")
|
| 47 |
+
url = f"{base_url}/artifact_blob/{utils.canonical_project_name(project)}/{digest}"
|
| 48 |
+
headers = _merge_client_headers(
|
| 49 |
+
get_token() if space_id else None,
|
| 50 |
+
remote_source.get("write_token"),
|
| 51 |
+
)
|
| 52 |
+
with httpx.stream(
|
| 53 |
+
"GET",
|
| 54 |
+
url,
|
| 55 |
+
headers=headers,
|
| 56 |
+
timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
|
| 57 |
+
) as response:
|
| 58 |
+
if response.status_code == 404:
|
| 59 |
+
raise FileNotFoundError(
|
| 60 |
+
f"Artifact blob {digest} not available on remote at {url}"
|
| 61 |
+
)
|
| 62 |
+
response.raise_for_status()
|
| 63 |
+
cas.stage_blob_from_chunks(
|
| 64 |
+
response.iter_bytes(),
|
| 65 |
+
claimed_digest=digest,
|
| 66 |
+
target_path=target_path,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _materialize_reference(
|
| 71 |
+
uri: str, scheme: str, dest: Path, digest: Sha256Digest | ETag | URIStr
|
| 72 |
+
) -> None:
|
| 73 |
+
"""Fetch the object at `uri` into `dest`, idempotently and atomically.
|
| 74 |
+
|
| 75 |
+
If `dest` already exists it is left untouched and nothing is fetched:
|
| 76 |
+
reference digests are opaque, so a file already present is assumed current
|
| 77 |
+
(mirroring the size/mtime idempotence of `_materialize`), letting a repeated
|
| 78 |
+
`download()` skip references already on disk.
|
| 79 |
+
|
| 80 |
+
Otherwise, when a real checksum was recorded, the source is re-probed and
|
| 81 |
+
compared. When the digest doesn't exist, or when it cannot be re-probed,
|
| 82 |
+
the comparison is skipped and the artifact is still materialized.
|
| 83 |
+
"""
|
| 84 |
+
if dest.is_file():
|
| 85 |
+
return
|
| 86 |
+
if len(digest) > 0 and digest != uri:
|
| 87 |
+
try:
|
| 88 |
+
resolved = references.resolve_reference(
|
| 89 |
+
uri, scheme, checksum=True, max_objects=1
|
| 90 |
+
)[0]
|
| 91 |
+
except Exception:
|
| 92 |
+
resolved = None
|
| 93 |
+
current_digest = resolved.digest if resolved is not None else None
|
| 94 |
+
if current_digest is not None and current_digest != digest:
|
| 95 |
+
utils._emit_nonfatal_warning(
|
| 96 |
+
f"Reference {uri!r} may have changed since it was logged "
|
| 97 |
+
f"(recorded digest {digest}, current {current_digest})."
|
| 98 |
+
)
|
| 99 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
partial = dest.parent / cas.partial_blob_name(dest.name)
|
| 101 |
+
try:
|
| 102 |
+
references.fetch_reference(uri, scheme, partial)
|
| 103 |
+
os.replace(partial, dest)
|
| 104 |
+
except Exception:
|
| 105 |
+
partial.unlink(missing_ok=True)
|
| 106 |
+
raise
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class Artifact:
|
| 110 |
+
"""
|
| 111 |
+
A versioned, named bundle of files and references attached to a project.
|
| 112 |
+
|
| 113 |
+
Construct an `Artifact`, add files to it with `add_file` / `add_dir`
|
| 114 |
+
(bytes staged into content-addressed storage) or `add_reference` (an
|
| 115 |
+
external URI recorded without copying any bytes), then persist it with
|
| 116 |
+
`trackio.log_artifact`. Logging freezes the artifact and populates its
|
| 117 |
+
read-only `version`, `aliases`, `size`, and `manifest`.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
name (`str`):
|
| 121 |
+
Artifact name, unique within the project. Must match
|
| 122 |
+
`^[A-Za-z0-9._-]+$` (letters, digits, dot, underscore, hyphen).
|
| 123 |
+
type (`str`):
|
| 124 |
+
Free-form category such as `"model"` or `"dataset"`, used to group
|
| 125 |
+
artifacts.
|
| 126 |
+
description (`str`, *optional*):
|
| 127 |
+
Human-readable description of the artifact.
|
| 128 |
+
metadata (`dict`, *optional*):
|
| 129 |
+
Arbitrary JSON-serializable metadata stored alongside the version.
|
| 130 |
+
"""
|
| 131 |
+
|
| 132 |
+
def __init__(
|
| 133 |
+
self,
|
| 134 |
+
name: str,
|
| 135 |
+
type: str,
|
| 136 |
+
description: str | None = None,
|
| 137 |
+
metadata: dict | None = None,
|
| 138 |
+
):
|
| 139 |
+
cas.validate_artifact_name(name)
|
| 140 |
+
self._name = name
|
| 141 |
+
self._type = type
|
| 142 |
+
self._description = description
|
| 143 |
+
self._metadata: dict = dict(metadata) if metadata else {}
|
| 144 |
+
self._pending_files: list[tuple[Path, str]] = []
|
| 145 |
+
self._pending_references: list[
|
| 146 |
+
tuple[str, str, Sha256Digest | ETag | URIStr, int]
|
| 147 |
+
] = []
|
| 148 |
+
self._logged = False
|
| 149 |
+
self._version: int | None = None
|
| 150 |
+
self._aliases: tuple[str, ...] = ()
|
| 151 |
+
self._size: int | None = None
|
| 152 |
+
self._manifest: Manifest | None = None
|
| 153 |
+
self._manifest_digest: Sha256Digest | None = None
|
| 154 |
+
self._project: str | None = None
|
| 155 |
+
self._remote_source: dict | None = None
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def name(self) -> str:
|
| 159 |
+
"""Artifact name, unique within its project."""
|
| 160 |
+
return self._name
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def type(self) -> str:
|
| 164 |
+
"""Free-form category such as `"model"` or `"dataset"`."""
|
| 165 |
+
return self._type
|
| 166 |
+
|
| 167 |
+
@property
|
| 168 |
+
def description(self) -> str | None:
|
| 169 |
+
"""Human-readable description, or None if unset."""
|
| 170 |
+
return self._description
|
| 171 |
+
|
| 172 |
+
@description.setter
|
| 173 |
+
def description(self, value: str | None) -> None:
|
| 174 |
+
self._description = value
|
| 175 |
+
|
| 176 |
+
@property
|
| 177 |
+
def metadata(self) -> dict:
|
| 178 |
+
"""Live, mutable metadata dict; assignments like `art.metadata["k"] = v`
|
| 179 |
+
made before logging are preserved."""
|
| 180 |
+
return self._metadata
|
| 181 |
+
|
| 182 |
+
@metadata.setter
|
| 183 |
+
def metadata(self, value: dict | None) -> None:
|
| 184 |
+
self._metadata = dict(value) if value else {}
|
| 185 |
+
|
| 186 |
+
@property
|
| 187 |
+
def version(self) -> str | None:
|
| 188 |
+
"""Version string in `"v<N>"` form (e.g. `"v3"`), or None if the
|
| 189 |
+
artifact has not been logged or fetched yet."""
|
| 190 |
+
return None if self._version is None else f"v{self._version}"
|
| 191 |
+
|
| 192 |
+
@property
|
| 193 |
+
def aliases(self) -> tuple[str, ...]:
|
| 194 |
+
"""Aliases pointing at this version, e.g. `("latest", "prod")`."""
|
| 195 |
+
return self._aliases
|
| 196 |
+
|
| 197 |
+
@property
|
| 198 |
+
def size(self) -> int | None:
|
| 199 |
+
"""Total size in bytes of the artifact's files plus any referenced data
|
| 200 |
+
whose size is known, or None if not yet logged."""
|
| 201 |
+
return self._size
|
| 202 |
+
|
| 203 |
+
@property
|
| 204 |
+
def manifest(self) -> Manifest | None:
|
| 205 |
+
"""List of manifest entries describing the artifact's contents, or None
|
| 206 |
+
if not yet logged or fetched. File entries carry `path`, `digest`, and
|
| 207 |
+
`size`; reference entries carry `path`, `ref` (a URI), `size`, and a
|
| 208 |
+
`digest` — a sha256 for local/LFS references, the provider's opaque ETag
|
| 209 |
+
for HTTP or object-store references, or the URI itself when no checksum
|
| 210 |
+
is available. Returns a fresh copy on each access."""
|
| 211 |
+
return None if self._manifest is None else [dict(e) for e in self._manifest]
|
| 212 |
+
|
| 213 |
+
@property
|
| 214 |
+
def manifest_digest(self) -> Sha256Digest | None:
|
| 215 |
+
"""Digest over the manifest (file paths plus their content), used to
|
| 216 |
+
de-duplicate re-logged content; None until logged or fetched."""
|
| 217 |
+
return self._manifest_digest
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def digest(self) -> Sha256Digest | None:
|
| 221 |
+
"""Alias of `manifest_digest`."""
|
| 222 |
+
return self._manifest_digest
|
| 223 |
+
|
| 224 |
+
@property
|
| 225 |
+
def qualified_name(self) -> str:
|
| 226 |
+
"""`"<project>/<name>:v<N>"` for a logged or fetched artifact."""
|
| 227 |
+
if self._project is None or self._version is None:
|
| 228 |
+
raise RuntimeError(
|
| 229 |
+
"Artifact has no qualified name until it is logged or fetched."
|
| 230 |
+
)
|
| 231 |
+
return f"{self._project}/{self._name}:v{self._version}"
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def project(self) -> str | None:
|
| 235 |
+
"""Project the artifact belongs to, or None until logged or fetched."""
|
| 236 |
+
return self._project
|
| 237 |
+
|
| 238 |
+
def wait(self, timeout: int | None = None) -> "Artifact":
|
| 239 |
+
"""No-op: trackio logs artifacts synchronously, so the artifact is
|
| 240 |
+
already committed by the time `log_artifact` returns."""
|
| 241 |
+
if not self._logged:
|
| 242 |
+
raise RuntimeError(
|
| 243 |
+
"Cannot wait on an Artifact that has not been logged; "
|
| 244 |
+
"call log_artifact first."
|
| 245 |
+
)
|
| 246 |
+
return self
|
| 247 |
+
|
| 248 |
+
def add_file(self, local_path: str | Path, name: str | None = None) -> None:
|
| 249 |
+
"""Stage a single file for inclusion in the artifact.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
local_path (`str` or `Path`):
|
| 253 |
+
Path to an existing regular file to add.
|
| 254 |
+
name (`str`, *optional*):
|
| 255 |
+
Logical path the file is stored under inside the artifact.
|
| 256 |
+
Defaults to the file's basename.
|
| 257 |
+
"""
|
| 258 |
+
if self._logged:
|
| 259 |
+
raise RuntimeError(
|
| 260 |
+
"Cannot add files to an Artifact that has already been logged or fetched."
|
| 261 |
+
)
|
| 262 |
+
src = Path(local_path).resolve()
|
| 263 |
+
if not src.is_file():
|
| 264 |
+
raise ValueError(f"Not a regular file: {local_path}")
|
| 265 |
+
logical = cas.validate_logical_path(name if name is not None else src.name)
|
| 266 |
+
self._pending_files.append((src, logical))
|
| 267 |
+
|
| 268 |
+
def add_dir(self, local_dir: str | Path, name: str | None = None) -> None:
|
| 269 |
+
"""Stage every regular file under a directory, recursively.
|
| 270 |
+
|
| 271 |
+
Symlinks are skipped.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
local_dir (`str` or `Path`):
|
| 275 |
+
Path to an existing directory whose files are added.
|
| 276 |
+
name (`str`, *optional*):
|
| 277 |
+
Logical path prefix for the added files. Defaults to no prefix,
|
| 278 |
+
so files keep their paths relative to `local_dir`.
|
| 279 |
+
"""
|
| 280 |
+
if self._logged:
|
| 281 |
+
raise RuntimeError(
|
| 282 |
+
"Cannot add files to an Artifact that has already been logged or fetched."
|
| 283 |
+
)
|
| 284 |
+
root = Path(local_dir).resolve()
|
| 285 |
+
if not root.is_dir():
|
| 286 |
+
raise ValueError(f"Not a directory: {local_dir}")
|
| 287 |
+
prefix = cas.validate_logical_path(name.rstrip("/")) + "/" if name else ""
|
| 288 |
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
| 289 |
+
dirnames.sort()
|
| 290 |
+
for filename in sorted(filenames):
|
| 291 |
+
entry = Path(dirpath) / filename
|
| 292 |
+
if entry.is_symlink() or not entry.is_file():
|
| 293 |
+
continue
|
| 294 |
+
rel = entry.relative_to(root).as_posix()
|
| 295 |
+
logical = cas.validate_logical_path(prefix + rel)
|
| 296 |
+
self._pending_files.append((entry, logical))
|
| 297 |
+
|
| 298 |
+
def add_reference(
|
| 299 |
+
self,
|
| 300 |
+
uri: str,
|
| 301 |
+
name: str | None = None,
|
| 302 |
+
checksum: bool = True,
|
| 303 |
+
max_objects: int | None = None,
|
| 304 |
+
) -> None:
|
| 305 |
+
"""Stage a reference to external data, without copying any bytes.
|
| 306 |
+
|
| 307 |
+
Unlike `add_file`/`add_dir`, a reference records the data's URI (plus,
|
| 308 |
+
when available, a size and checksum) *without staging the bytes into
|
| 309 |
+
the content-addressed store*. This is how you version and track
|
| 310 |
+
lineage over large or already-durably-stored datasets that must not be
|
| 311 |
+
duplicated.
|
| 312 |
+
|
| 313 |
+
A URI that denotes a directory (`file://`, `hf://`, or a prefix
|
| 314 |
+
expanded by a custom handler) is expanded into one entry per object,
|
| 315 |
+
capped by `max_objects`. Reference bytes are never uploaded to a Space
|
| 316 |
+
or bucket.
|
| 317 |
+
|
| 318 |
+
The URI is stored verbatim in the artifact manifest, and manifests may
|
| 319 |
+
be synced to a shared Hugging Face Dataset — so a signed URI triggers a
|
| 320 |
+
warning for security reasons; please prefer the object's canonical
|
| 321 |
+
unsigned URI (e.g. `s3://bucket/key`).
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
uri (`str`):
|
| 325 |
+
Location of the referenced data, percent-encoded (build local
|
| 326 |
+
URIs with `pathlib.Path.as_uri()`). `file://`, `http(s)://`,
|
| 327 |
+
and `hf://` are resolved and fetched natively; any other scheme
|
| 328 |
+
(`s3://`, `gs://`, Azure blob URLs, ...) requires a custom
|
| 329 |
+
`ReferenceHandler` registered via
|
| 330 |
+
`trackio.register_reference_handler` — see the artifacts
|
| 331 |
+
documentation for complete examples — and raises otherwise.
|
| 332 |
+
name (`str`, *optional*):
|
| 333 |
+
Logical path the reference is stored under. Defaults to the last
|
| 334 |
+
segment of `uri`; for an expanded prefix (ending with /) it is the
|
| 335 |
+
prefix under which each object's relative path is nested.
|
| 336 |
+
checksum (`bool`, *optional*, defaults to `True`):
|
| 337 |
+
When `True`, probes the source to derive its size and a
|
| 338 |
+
checksum (and to expand a directory/prefix): `file://` is
|
| 339 |
+
stream-hashed to a sha256, `hf://` uses its LFS sha256 or git
|
| 340 |
+
blob id, and `http(s)://` uses the server `ETag`. This may read
|
| 341 |
+
the filesystem or make network requests. When no checksum can
|
| 342 |
+
be obtained for a *remote* source (e.g. no ETag), the URI
|
| 343 |
+
itself is used as the `digest` and a warning is emitted. A
|
| 344 |
+
local `file://` path that does not exist instead raises. Pass
|
| 345 |
+
`checksum=False` to skip remote probing entirely.
|
| 346 |
+
max_objects (`int`, *optional*):
|
| 347 |
+
Cap on how many objects a directory/prefix expands into (default
|
| 348 |
+
10,000); exceeding it raises.
|
| 349 |
+
"""
|
| 350 |
+
if self._logged:
|
| 351 |
+
raise RuntimeError(
|
| 352 |
+
"Cannot add references to an Artifact that has already been "
|
| 353 |
+
"logged or fetched."
|
| 354 |
+
)
|
| 355 |
+
scheme, normalized = references.validate_reference_uri(uri)
|
| 356 |
+
if references.looks_signed(normalized):
|
| 357 |
+
utils._emit_nonfatal_warning(
|
| 358 |
+
f"Reference {uri!r} looks like a signed URL, and the full URI "
|
| 359 |
+
"(embedded credential included) is stored in the artifact "
|
| 360 |
+
"manifest, which may be synced to a shared HF Dataset. Prefer "
|
| 361 |
+
"the object's canonical unsigned URI (e.g. s3://bucket/key or "
|
| 362 |
+
"the URL without its signed query string)."
|
| 363 |
+
)
|
| 364 |
+
cap = references.DEFAULT_MAX_OBJECTS if max_objects is None else max_objects
|
| 365 |
+
resolved = references.resolve_reference(normalized, scheme, checksum, cap)
|
| 366 |
+
if not resolved:
|
| 367 |
+
raise ValueError(f"Reference URI {uri!r} matched no objects.")
|
| 368 |
+
used_uri_fallback = False
|
| 369 |
+
name_prefix = f"{name.rstrip('/')}/" if name is not None else ""
|
| 370 |
+
for entry in resolved:
|
| 371 |
+
if entry.relkey is None:
|
| 372 |
+
logical = self._reference_logical_name(name, entry.uri, uri)
|
| 373 |
+
else:
|
| 374 |
+
logical = cas.validate_logical_path(name_prefix + entry.relkey)
|
| 375 |
+
ref_digest: Sha256Digest | ETag | URIStr | None = entry.digest
|
| 376 |
+
if ref_digest is None:
|
| 377 |
+
ref_digest = URIStr(entry.uri)
|
| 378 |
+
used_uri_fallback = True
|
| 379 |
+
ref_size = 0 if entry.size is None else entry.size
|
| 380 |
+
self._pending_references.append((logical, entry.uri, ref_digest, ref_size))
|
| 381 |
+
if checksum and used_uri_fallback:
|
| 382 |
+
utils._emit_nonfatal_warning(
|
| 383 |
+
f"Reference {uri!r} was recorded without a content checksum, so its "
|
| 384 |
+
"version identity is its URI; re-logging changed content at the same "
|
| 385 |
+
"location will reuse the existing version instead of creating a new "
|
| 386 |
+
f"one. {references.checksum_hint(scheme, normalized)}"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
@staticmethod
|
| 390 |
+
def _reference_logical_name(name: str | None, resolved_uri: str, uri: str) -> str:
|
| 391 |
+
if name is not None:
|
| 392 |
+
return cas.validate_logical_path(name)
|
| 393 |
+
derived = references.default_reference_name(resolved_uri)
|
| 394 |
+
if not derived:
|
| 395 |
+
raise ValueError(
|
| 396 |
+
f"Could not derive a name from URI {uri!r}; pass name= explicitly."
|
| 397 |
+
)
|
| 398 |
+
return cas.validate_logical_path(derived)
|
| 399 |
+
|
| 400 |
+
def _build_manifest(self, project: str) -> Manifest:
|
| 401 |
+
if not self._pending_files and not self._pending_references:
|
| 402 |
+
raise ValueError(
|
| 403 |
+
f"Artifact {self._name!r} has no files or references; "
|
| 404 |
+
"call add_file/add_dir/add_reference first."
|
| 405 |
+
)
|
| 406 |
+
cas.assert_manifest_paths_compatible(
|
| 407 |
+
[logical for _, logical in self._pending_files]
|
| 408 |
+
+ [logical for logical, *_ in self._pending_references]
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
entries: Manifest = []
|
| 412 |
+
for src, logical in self._pending_files:
|
| 413 |
+
digest, size = cas.stage_blob_into_project(src, project)
|
| 414 |
+
entries.append({"path": logical, "digest": digest, "size": size})
|
| 415 |
+
for logical, uri, ref_digest, ref_size in self._pending_references:
|
| 416 |
+
entry: ManifestEntry = {
|
| 417 |
+
"path": logical,
|
| 418 |
+
"ref": uri,
|
| 419 |
+
"digest": ref_digest,
|
| 420 |
+
"size": ref_size,
|
| 421 |
+
}
|
| 422 |
+
entries.append(entry)
|
| 423 |
+
return entries
|
| 424 |
+
|
| 425 |
+
def _hydrate_from_db(
|
| 426 |
+
self,
|
| 427 |
+
*,
|
| 428 |
+
project: str,
|
| 429 |
+
version: int,
|
| 430 |
+
aliases: list[str],
|
| 431 |
+
manifest: Manifest,
|
| 432 |
+
manifest_digest: Sha256Digest,
|
| 433 |
+
size_bytes: int,
|
| 434 |
+
description: str | None = None,
|
| 435 |
+
metadata: dict | None = None,
|
| 436 |
+
) -> None:
|
| 437 |
+
if description is not None:
|
| 438 |
+
self._description = description
|
| 439 |
+
if metadata is not None:
|
| 440 |
+
self._metadata = dict(metadata)
|
| 441 |
+
self._project = project
|
| 442 |
+
self._version = version
|
| 443 |
+
self._aliases = tuple(aliases)
|
| 444 |
+
self._manifest = [dict(e) for e in manifest]
|
| 445 |
+
self._manifest_digest = manifest_digest
|
| 446 |
+
self._size = size_bytes
|
| 447 |
+
self._logged = True
|
| 448 |
+
|
| 449 |
+
def download(self, root: str | Path | None = None) -> str:
|
| 450 |
+
"""Materialize the artifact's files into a local directory.
|
| 451 |
+
|
| 452 |
+
Files are copied from Trackio's content-addressed cache (and fetched
|
| 453 |
+
from the remote when missing locally), so repeated calls are cheap and
|
| 454 |
+
idempotent.
|
| 455 |
+
|
| 456 |
+
Reference entries added via `add_reference` are fetched here too: the
|
| 457 |
+
object at each reference URI is downloaded into the directory (via the
|
| 458 |
+
bundled `httpx` for `http(s)://`, `huggingface_hub` for `hf://`, and
|
| 459 |
+
the registered custom `ReferenceHandler` for any other scheme). Each
|
| 460 |
+
object is written atomically and calls are idempotent. Because reference digests
|
| 461 |
+
are opaque (a provider ETag or the URI itself), a present file is assumed
|
| 462 |
+
current and is not re-verified against the source, and a source whose
|
| 463 |
+
re-probed checksum no longer matches the recorded one is downloaded
|
| 464 |
+
anyway with a warning. Downloading can transfer a large amount of data;
|
| 465 |
+
to work with the URIs without downloading, read them from the `references`
|
| 466 |
+
property or `get_entry_uri` instead. A missing source, an unreachable
|
| 467 |
+
server, or a scheme with no registered handler raises.
|
| 468 |
+
|
| 469 |
+
Args:
|
| 470 |
+
root (`str` or `Path`, *optional*):
|
| 471 |
+
Directory to write the files into. Defaults to
|
| 472 |
+
`./.trackio/artifact-downloads/<project>/<name>_v<version>/`,
|
| 473 |
+
keyed by project so same-named artifacts from different projects
|
| 474 |
+
never collide, and by the resolved version so a moving alias
|
| 475 |
+
like `latest` never leaves behind stale files from a previous
|
| 476 |
+
version.
|
| 477 |
+
|
| 478 |
+
Returns:
|
| 479 |
+
The absolute path to the download directory, as a string.
|
| 480 |
+
"""
|
| 481 |
+
if not self._logged:
|
| 482 |
+
raise RuntimeError(
|
| 483 |
+
"Cannot download an Artifact that has not been logged or fetched."
|
| 484 |
+
)
|
| 485 |
+
if self._manifest is None or self._project is None or self._version is None:
|
| 486 |
+
raise RuntimeError("Artifact is missing manifest, project, or version.")
|
| 487 |
+
|
| 488 |
+
if root is None:
|
| 489 |
+
project = utils.canonical_project_name(self._project)
|
| 490 |
+
root_path = (
|
| 491 |
+
Path.cwd()
|
| 492 |
+
/ ".trackio"
|
| 493 |
+
/ "artifact-downloads"
|
| 494 |
+
/ project
|
| 495 |
+
/ f"{self._name}_v{self._version}"
|
| 496 |
+
)
|
| 497 |
+
else:
|
| 498 |
+
root_path = Path(root)
|
| 499 |
+
root_path.mkdir(parents=True, exist_ok=True)
|
| 500 |
+
|
| 501 |
+
for entry in self._manifest:
|
| 502 |
+
logical = cas.validate_logical_path(entry["path"])
|
| 503 |
+
if references.is_reference_entry(entry):
|
| 504 |
+
scheme, uri = references.validate_reference_uri(entry["ref"])
|
| 505 |
+
_materialize_reference(
|
| 506 |
+
uri, scheme, root_path / logical, entry["digest"]
|
| 507 |
+
)
|
| 508 |
+
continue
|
| 509 |
+
digest = cas.validate_digest(entry["digest"])
|
| 510 |
+
blob = cas.blob_path(self._project, digest)
|
| 511 |
+
if not blob.is_file():
|
| 512 |
+
if self._remote_source is None:
|
| 513 |
+
raise FileNotFoundError(
|
| 514 |
+
f"Artifact blob {digest} not available locally or "
|
| 515 |
+
"remotely. The producer machine may not have shipped "
|
| 516 |
+
"this blob yet."
|
| 517 |
+
)
|
| 518 |
+
_fetch_blob_from_remote(
|
| 519 |
+
self._remote_source, self._project, digest, blob
|
| 520 |
+
)
|
| 521 |
+
_materialize(blob, root_path / logical, entry["size"])
|
| 522 |
+
|
| 523 |
+
return str(root_path)
|
| 524 |
+
|
| 525 |
+
@property
|
| 526 |
+
def references(self) -> list[dict]:
|
| 527 |
+
"""Reference entries (each with `path`, `ref`, `size`, and a `digest`)
|
| 528 |
+
recorded via `add_reference`. These point at external data not stored in
|
| 529 |
+
Trackio; read them to work with the URIs without downloading (unlike
|
| 530 |
+
`download()`, which fetches them). Returns a fresh copy on each access;
|
| 531 |
+
empty if the artifact has no references or has not been logged yet."""
|
| 532 |
+
if self._manifest is None:
|
| 533 |
+
return []
|
| 534 |
+
return [dict(e) for e in self._manifest if references.is_reference_entry(e)]
|
| 535 |
+
|
| 536 |
+
def get_entry_uri(self, name: str) -> str | None:
|
| 537 |
+
"""Return the URI recorded for the reference entry at logical path
|
| 538 |
+
`name`, or None if there is no reference entry with that name."""
|
| 539 |
+
if self._manifest is None:
|
| 540 |
+
return None
|
| 541 |
+
for entry in self._manifest:
|
| 542 |
+
if entry.get("path") == name and references.is_reference_entry(entry):
|
| 543 |
+
return entry["ref"]
|
| 544 |
+
return None
|
| 545 |
+
|
| 546 |
+
def __repr__(self) -> str:
|
| 547 |
+
parts: list[str] = [f"name={self._name!r}", f"type={self._type!r}"]
|
| 548 |
+
if self._version is not None:
|
| 549 |
+
parts.append(f"version=v{self._version}")
|
| 550 |
+
if self._aliases:
|
| 551 |
+
parts.append(f"aliases={list(self._aliases)!r}")
|
| 552 |
+
return f"Artifact({', '.join(parts)})"
|
trackio/asgi_app.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import inspect
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
import secrets
|
| 8 |
+
import tempfile
|
| 9 |
+
import threading
|
| 10 |
+
from collections.abc import Callable
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, get_args, get_origin
|
| 13 |
+
from urllib.parse import unquote
|
| 14 |
+
|
| 15 |
+
from starlette.applications import Starlette
|
| 16 |
+
from starlette.requests import Request
|
| 17 |
+
from starlette.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
| 18 |
+
from starlette.routing import Route
|
| 19 |
+
|
| 20 |
+
from trackio import utils
|
| 21 |
+
from trackio.exceptions import TrackioAPIError
|
| 22 |
+
from trackio.remote_client import HTTP_API_VERSION
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("trackio.asgi_app")
|
| 25 |
+
|
| 26 |
+
_PACKAGE_JSON_PATH = Path(__file__).parent / "package.json"
|
| 27 |
+
_TRACKIO_PACKAGE_VERSION = json.loads(_PACKAGE_JSON_PATH.read_text())["version"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _normalize_allowed_file_roots(
|
| 31 |
+
allowed_file_roots: list[str | Path] | None,
|
| 32 |
+
) -> tuple[Path, ...]:
|
| 33 |
+
roots = []
|
| 34 |
+
for root in allowed_file_roots or []:
|
| 35 |
+
roots.append(Path(root).resolve())
|
| 36 |
+
return tuple(roots)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _is_allowed_file_path(path: Path, allowed_roots: tuple[Path, ...]) -> bool:
|
| 40 |
+
resolved_path = path.resolve(strict=False)
|
| 41 |
+
for root in allowed_roots:
|
| 42 |
+
try:
|
| 43 |
+
resolved_path.relative_to(root)
|
| 44 |
+
return True
|
| 45 |
+
except ValueError:
|
| 46 |
+
continue
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _json_safe(data: Any) -> Any:
|
| 51 |
+
if data is None or isinstance(data, (str, bool, int)):
|
| 52 |
+
return data
|
| 53 |
+
if isinstance(data, float):
|
| 54 |
+
return data if math.isfinite(data) else None
|
| 55 |
+
if isinstance(data, dict):
|
| 56 |
+
return {k: _json_safe(v) for k, v in data.items()}
|
| 57 |
+
if isinstance(data, (list, tuple)):
|
| 58 |
+
return [_json_safe(v) for v in data]
|
| 59 |
+
if hasattr(data, "item"):
|
| 60 |
+
try:
|
| 61 |
+
return _json_safe(data.item())
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
return str(data)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def register_uploaded_temp_file(request: Request, file_path: str | Path) -> None:
|
| 68 |
+
resolved_path = Path(file_path).resolve(strict=False)
|
| 69 |
+
with request.app.state.uploaded_temp_files_lock:
|
| 70 |
+
request.app.state.uploaded_temp_files.add(resolved_path)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def consume_uploaded_temp_file(request: Request, file_data: Any) -> Path:
|
| 74 |
+
file_path = file_data.get("path") if isinstance(file_data, dict) else None
|
| 75 |
+
if not isinstance(file_path, str) or not file_path:
|
| 76 |
+
raise TrackioAPIError("Expected uploaded file metadata with a valid path.")
|
| 77 |
+
|
| 78 |
+
resolved_path = Path(file_path).resolve(strict=False)
|
| 79 |
+
with request.app.state.uploaded_temp_files_lock:
|
| 80 |
+
if resolved_path not in request.app.state.uploaded_temp_files:
|
| 81 |
+
raise TrackioAPIError(
|
| 82 |
+
"Uploaded file was not created by this Trackio server."
|
| 83 |
+
)
|
| 84 |
+
request.app.state.uploaded_temp_files.remove(resolved_path)
|
| 85 |
+
|
| 86 |
+
if not resolved_path.is_file():
|
| 87 |
+
raise TrackioAPIError("Uploaded file is missing.")
|
| 88 |
+
|
| 89 |
+
return resolved_path
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def cleanup_uploaded_temp_file(file_path: str | Path) -> None:
|
| 93 |
+
try:
|
| 94 |
+
Path(file_path).unlink(missing_ok=True)
|
| 95 |
+
except Exception:
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _invoke_handler(
|
| 100 |
+
fn: Any,
|
| 101 |
+
request: Request,
|
| 102 |
+
args: list[Any] | None = None,
|
| 103 |
+
kwargs: dict[str, Any] | None = None,
|
| 104 |
+
) -> Any:
|
| 105 |
+
sig = inspect.signature(fn)
|
| 106 |
+
params = list(sig.parameters.values())
|
| 107 |
+
positional_args: list[Any] = []
|
| 108 |
+
keyword_args: dict[str, Any] = {}
|
| 109 |
+
args = list(args or [])
|
| 110 |
+
kwargs = dict(kwargs or {})
|
| 111 |
+
data_index = 0
|
| 112 |
+
|
| 113 |
+
for param in params:
|
| 114 |
+
if param.name == "request":
|
| 115 |
+
keyword_args["request"] = request
|
| 116 |
+
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
|
| 117 |
+
positional_args.extend(args[data_index:])
|
| 118 |
+
data_index = len(args)
|
| 119 |
+
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
| 120 |
+
keyword_args.update(kwargs)
|
| 121 |
+
kwargs.clear()
|
| 122 |
+
elif param.name in kwargs:
|
| 123 |
+
keyword_args[param.name] = kwargs.pop(param.name)
|
| 124 |
+
elif param.kind in (
|
| 125 |
+
inspect.Parameter.POSITIONAL_ONLY,
|
| 126 |
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
| 127 |
+
) and data_index < len(args):
|
| 128 |
+
positional_args.append(args[data_index])
|
| 129 |
+
data_index += 1
|
| 130 |
+
elif param.default is inspect.Signature.empty and param.kind not in (
|
| 131 |
+
inspect.Parameter.VAR_POSITIONAL,
|
| 132 |
+
inspect.Parameter.VAR_KEYWORD,
|
| 133 |
+
):
|
| 134 |
+
raise TrackioAPIError(f"Missing required parameter: {param.name}")
|
| 135 |
+
|
| 136 |
+
return fn(*positional_args, **keyword_args)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def version_handler(request: Request) -> Response:
|
| 140 |
+
mcp_enabled = bool(getattr(request.app.state, "mcp_enabled", False))
|
| 141 |
+
return JSONResponse(
|
| 142 |
+
{
|
| 143 |
+
"version": _TRACKIO_PACKAGE_VERSION,
|
| 144 |
+
"api_version": HTTP_API_VERSION,
|
| 145 |
+
"api_transport": "http",
|
| 146 |
+
"mcp_enabled": mcp_enabled,
|
| 147 |
+
"mcp_path": "/mcp" if mcp_enabled else None,
|
| 148 |
+
}
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _json_schema_and_python_type(annotation: Any) -> tuple[dict[str, Any], str]:
|
| 153 |
+
if annotation is inspect.Parameter.empty:
|
| 154 |
+
return {"type": "object"}, "Any"
|
| 155 |
+
origin = get_origin(annotation)
|
| 156 |
+
args = get_args(annotation)
|
| 157 |
+
if origin is not None and args:
|
| 158 |
+
non_none = tuple(a for a in args if a is not type(None))
|
| 159 |
+
if len(non_none) == 1 and len(args) > 1:
|
| 160 |
+
return _json_schema_and_python_type(non_none[0])
|
| 161 |
+
if origin is list:
|
| 162 |
+
inner, py_inner = _json_schema_and_python_type(
|
| 163 |
+
args[0] if args else inspect.Parameter.empty
|
| 164 |
+
)
|
| 165 |
+
return {"type": "array", "items": inner}, f"list[{py_inner}]"
|
| 166 |
+
if origin is dict:
|
| 167 |
+
return {"type": "object"}, "dict"
|
| 168 |
+
if annotation in (str, bytes):
|
| 169 |
+
return {"type": "string"}, "str"
|
| 170 |
+
if annotation is int:
|
| 171 |
+
return {"type": "integer"}, "int"
|
| 172 |
+
if annotation is float:
|
| 173 |
+
return {"type": "number"}, "float"
|
| 174 |
+
if annotation is bool:
|
| 175 |
+
return {"type": "boolean"}, "bool"
|
| 176 |
+
name = getattr(annotation, "__name__", None)
|
| 177 |
+
if name:
|
| 178 |
+
return {"type": "object"}, name
|
| 179 |
+
return {"type": "object"}, "Any"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def build_gradio_api_info(api_registry: dict[str, Any]) -> dict[str, Any]:
|
| 183 |
+
named_endpoints: dict[str, Any] = {}
|
| 184 |
+
for name in sorted(api_registry.keys()):
|
| 185 |
+
fn = api_registry[name]
|
| 186 |
+
if not callable(fn):
|
| 187 |
+
continue
|
| 188 |
+
sig = inspect.signature(fn)
|
| 189 |
+
parameters: list[dict[str, Any]] = []
|
| 190 |
+
for pname, param in sig.parameters.items():
|
| 191 |
+
if pname == "request":
|
| 192 |
+
continue
|
| 193 |
+
jtype, pytype = _json_schema_and_python_type(param.annotation)
|
| 194 |
+
has_default = param.default is not inspect.Parameter.empty
|
| 195 |
+
parameters.append(
|
| 196 |
+
{
|
| 197 |
+
"label": pname,
|
| 198 |
+
"parameter_name": pname,
|
| 199 |
+
"parameter_has_default": has_default,
|
| 200 |
+
"parameter_default": None if not has_default else param.default,
|
| 201 |
+
"type": jtype,
|
| 202 |
+
"python_type": {"type": pytype, "description": ""},
|
| 203 |
+
"component": "Api",
|
| 204 |
+
"example_input": None,
|
| 205 |
+
}
|
| 206 |
+
)
|
| 207 |
+
ret_ann = sig.return_annotation
|
| 208 |
+
if ret_ann is inspect.Signature.empty:
|
| 209 |
+
ret_ann = Any
|
| 210 |
+
rjtype, rpytype = _json_schema_and_python_type(ret_ann)
|
| 211 |
+
returns = [
|
| 212 |
+
{
|
| 213 |
+
"label": "result",
|
| 214 |
+
"type": rjtype,
|
| 215 |
+
"python_type": {"type": rpytype, "description": ""},
|
| 216 |
+
"component": "Api",
|
| 217 |
+
}
|
| 218 |
+
]
|
| 219 |
+
named_endpoints[f"/{name}"] = {
|
| 220 |
+
"parameters": parameters,
|
| 221 |
+
"returns": returns,
|
| 222 |
+
"api_visibility": "public",
|
| 223 |
+
}
|
| 224 |
+
return {"named_endpoints": named_endpoints, "unnamed_endpoints": {}}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
_MAX_GRADIO_CALL_EVENTS = 256
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _hf_token_value_is_unset(value: Any) -> bool:
|
| 231 |
+
if value is None:
|
| 232 |
+
return True
|
| 233 |
+
if isinstance(value, str) and value.strip() == "":
|
| 234 |
+
return True
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _authorization_bearer_token(request: Request) -> str | None:
|
| 239 |
+
auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
| 240 |
+
if not auth:
|
| 241 |
+
return None
|
| 242 |
+
parts = auth.split()
|
| 243 |
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
| 244 |
+
return None
|
| 245 |
+
tok = parts[1].strip()
|
| 246 |
+
return tok or None
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _maybe_apply_hf_token_from_authorization(
|
| 250 |
+
request: Request, fn: Any, args: list[Any], kwargs: dict[str, Any]
|
| 251 |
+
) -> None:
|
| 252 |
+
if not utils.on_spaces():
|
| 253 |
+
return
|
| 254 |
+
token = _authorization_bearer_token(request)
|
| 255 |
+
if not token:
|
| 256 |
+
return
|
| 257 |
+
sig = inspect.signature(fn)
|
| 258 |
+
if "hf_token" not in sig.parameters:
|
| 259 |
+
return
|
| 260 |
+
params = [p for p in sig.parameters.values() if p.name != "request"]
|
| 261 |
+
names = [p.name for p in params]
|
| 262 |
+
if "hf_token" not in names:
|
| 263 |
+
return
|
| 264 |
+
idx = names.index("hf_token")
|
| 265 |
+
if "hf_token" in kwargs:
|
| 266 |
+
if _hf_token_value_is_unset(kwargs["hf_token"]):
|
| 267 |
+
kwargs["hf_token"] = token
|
| 268 |
+
return
|
| 269 |
+
if idx < len(args):
|
| 270 |
+
if _hf_token_value_is_unset(args[idx]):
|
| 271 |
+
args[idx] = token
|
| 272 |
+
return
|
| 273 |
+
kwargs["hf_token"] = token
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _store_gradio_call_result(
|
| 277 |
+
request: Request, event_id: str, api_name: str, data: Any
|
| 278 |
+
) -> None:
|
| 279 |
+
with request.app.state.gradio_call_events_lock:
|
| 280 |
+
d = request.app.state.gradio_call_events
|
| 281 |
+
while len(d) >= _MAX_GRADIO_CALL_EVENTS:
|
| 282 |
+
d.pop(next(iter(d)))
|
| 283 |
+
d[event_id] = {"api_name": api_name, "data": data}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
async def run_api_request(request: Request, api_name: str) -> Response:
|
| 287 |
+
api_registry = request.app.state.api_registry
|
| 288 |
+
fn = api_registry.get(api_name)
|
| 289 |
+
if fn is None:
|
| 290 |
+
return JSONResponse({"error": f"Unknown API: {api_name}"}, status_code=404)
|
| 291 |
+
|
| 292 |
+
try:
|
| 293 |
+
body = await request.json()
|
| 294 |
+
except Exception:
|
| 295 |
+
body = {}
|
| 296 |
+
|
| 297 |
+
args: list[Any] = []
|
| 298 |
+
kwargs: dict[str, Any] = {}
|
| 299 |
+
if isinstance(body, dict):
|
| 300 |
+
if "args" in body or "kwargs" in body:
|
| 301 |
+
args = body.get("args") or []
|
| 302 |
+
kwargs = body.get("kwargs") or {}
|
| 303 |
+
elif "data" in body and isinstance(body["data"], list):
|
| 304 |
+
args = body["data"]
|
| 305 |
+
else:
|
| 306 |
+
kwargs = body
|
| 307 |
+
elif isinstance(body, list):
|
| 308 |
+
args = body
|
| 309 |
+
elif body is not None:
|
| 310 |
+
args = [body]
|
| 311 |
+
|
| 312 |
+
if not isinstance(args, list):
|
| 313 |
+
args = [args]
|
| 314 |
+
if not isinstance(kwargs, dict):
|
| 315 |
+
kwargs = {}
|
| 316 |
+
|
| 317 |
+
_maybe_apply_hf_token_from_authorization(request, fn, args, kwargs)
|
| 318 |
+
|
| 319 |
+
try:
|
| 320 |
+
result = _invoke_handler(fn, request, args=args, kwargs=kwargs)
|
| 321 |
+
return JSONResponse({"data": _json_safe(result)})
|
| 322 |
+
except TrackioAPIError as e:
|
| 323 |
+
return JSONResponse({"error": str(e)}, status_code=400)
|
| 324 |
+
except Exception as e:
|
| 325 |
+
return JSONResponse({"error": str(e)}, status_code=500)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def api_handler(request: Request) -> Response:
|
| 329 |
+
return await run_api_request(request, request.path_params["api_name"])
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
async def gradio_api_info_handler(request: Request) -> Response:
|
| 333 |
+
return JSONResponse(
|
| 334 |
+
build_gradio_api_info(request.app.state.api_registry),
|
| 335 |
+
headers={"Cache-Control": "no-store"},
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
async def gradio_call_post_handler(request: Request) -> Response:
|
| 340 |
+
api_name = request.path_params["api_name"]
|
| 341 |
+
resp = await run_api_request(request, api_name)
|
| 342 |
+
if resp.status_code != 200:
|
| 343 |
+
return resp
|
| 344 |
+
body = json.loads(bytes(resp.body).decode())
|
| 345 |
+
event_id = secrets.token_urlsafe(16)
|
| 346 |
+
_store_gradio_call_result(request, event_id, api_name, body["data"])
|
| 347 |
+
return JSONResponse({"event_id": event_id})
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
async def gradio_call_poll_handler(request: Request) -> Response:
|
| 351 |
+
api_name = request.path_params["api_name"]
|
| 352 |
+
event_id = request.path_params["event_id"]
|
| 353 |
+
with request.app.state.gradio_call_events_lock:
|
| 354 |
+
event = request.app.state.gradio_call_events.pop(event_id, None)
|
| 355 |
+
if event is None:
|
| 356 |
+
logger.info("gradio_api poll: unknown or expired event_id")
|
| 357 |
+
return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404)
|
| 358 |
+
if event.get("api_name") != api_name:
|
| 359 |
+
logger.info(
|
| 360 |
+
"gradio_api poll: api_name mismatch (path=%r, stored=%r)",
|
| 361 |
+
api_name,
|
| 362 |
+
event.get("api_name"),
|
| 363 |
+
)
|
| 364 |
+
with request.app.state.gradio_call_events_lock:
|
| 365 |
+
d = request.app.state.gradio_call_events
|
| 366 |
+
while len(d) >= _MAX_GRADIO_CALL_EVENTS:
|
| 367 |
+
d.pop(next(iter(d)))
|
| 368 |
+
d[event_id] = event
|
| 369 |
+
return JSONResponse({"error": "Unknown or expired event_id"}, status_code=404)
|
| 370 |
+
|
| 371 |
+
data = event["data"]
|
| 372 |
+
payload = json.dumps(_json_safe([data]))
|
| 373 |
+
|
| 374 |
+
async def sse() -> Any:
|
| 375 |
+
yield f"event: complete\ndata: {payload}\n\n"
|
| 376 |
+
|
| 377 |
+
return StreamingResponse(
|
| 378 |
+
sse(),
|
| 379 |
+
media_type="text/event-stream",
|
| 380 |
+
headers={
|
| 381 |
+
"Cache-Control": "no-store",
|
| 382 |
+
"X-Accel-Buffering": "no",
|
| 383 |
+
},
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
async def upload_handler(request: Request) -> Response:
|
| 388 |
+
upload_authorizer = getattr(request.app.state, "upload_authorizer", None)
|
| 389 |
+
if callable(upload_authorizer):
|
| 390 |
+
try:
|
| 391 |
+
upload_authorizer(request)
|
| 392 |
+
except TrackioAPIError as e:
|
| 393 |
+
return JSONResponse({"error": str(e)}, status_code=400)
|
| 394 |
+
|
| 395 |
+
form = await request.form()
|
| 396 |
+
uploads = form.getlist("files")
|
| 397 |
+
saved_paths = []
|
| 398 |
+
for upload in uploads:
|
| 399 |
+
suffix = Path(getattr(upload, "filename", "") or "").suffix
|
| 400 |
+
with tempfile.NamedTemporaryFile(
|
| 401 |
+
delete=False,
|
| 402 |
+
prefix="trackio-upload-",
|
| 403 |
+
suffix=suffix,
|
| 404 |
+
) as tmp:
|
| 405 |
+
tmp.write(await upload.read())
|
| 406 |
+
register_uploaded_temp_file(request, tmp.name)
|
| 407 |
+
saved_paths.append(tmp.name)
|
| 408 |
+
return JSONResponse({"paths": saved_paths})
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
async def gradio_upload_alias_handler(request: Request) -> Response:
|
| 412 |
+
return await upload_handler(request)
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
_DISALLOWED_FILE_SUFFIXES = frozenset(
|
| 416 |
+
{".db", ".db-journal", ".db-wal", ".db-shm", ".sqlite", ".sqlite3"}
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
async def file_handler(request: Request) -> Response:
|
| 421 |
+
fs_path = request.query_params.get("path")
|
| 422 |
+
if fs_path is None:
|
| 423 |
+
return Response("Missing path", status_code=400)
|
| 424 |
+
fp = Path(unquote(fs_path)).resolve(strict=False)
|
| 425 |
+
if fp.suffix.lower() in _DISALLOWED_FILE_SUFFIXES:
|
| 426 |
+
return Response("Not found", status_code=404)
|
| 427 |
+
if _is_allowed_file_path(fp, (utils.ARTIFACTS_DIR.resolve(),)):
|
| 428 |
+
return Response("Not found", status_code=404)
|
| 429 |
+
allowed_roots = getattr(request.app.state, "allowed_file_roots", ())
|
| 430 |
+
if fp.is_file() and _is_allowed_file_path(fp, allowed_roots):
|
| 431 |
+
return FileResponse(str(fp))
|
| 432 |
+
return Response("Not found", status_code=404)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
async def artifact_blob_handler(request: Request) -> Response:
|
| 436 |
+
from trackio import cas # noqa: PLC0415
|
| 437 |
+
from trackio import server as _server # noqa: PLC0415
|
| 438 |
+
|
| 439 |
+
project = request.path_params.get("project")
|
| 440 |
+
digest = request.path_params.get("digest")
|
| 441 |
+
try:
|
| 442 |
+
project = _server._validate_project_name(project)
|
| 443 |
+
digest = _server._validate_sha256_digest(digest)
|
| 444 |
+
except TrackioAPIError:
|
| 445 |
+
return Response("Not found", status_code=404)
|
| 446 |
+
blob = cas.blob_path(project, digest)
|
| 447 |
+
if not blob.is_file():
|
| 448 |
+
return Response("Not found", status_code=404)
|
| 449 |
+
return FileResponse(str(blob))
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def create_trackio_starlette_app(
|
| 453 |
+
oauth_routes: list[Route],
|
| 454 |
+
api_registry: dict[str, Any],
|
| 455 |
+
extra_routes: list[Any] | None = None,
|
| 456 |
+
mcp_lifespan: Any = None,
|
| 457 |
+
mcp_enabled: bool = False,
|
| 458 |
+
allowed_file_roots: list[str | Path] | None = None,
|
| 459 |
+
upload_authorizer: Callable[[Request], None] | None = None,
|
| 460 |
+
) -> Starlette:
|
| 461 |
+
routes: list[Any] = list(oauth_routes)
|
| 462 |
+
routes.extend(
|
| 463 |
+
[
|
| 464 |
+
Route("/version", endpoint=version_handler, methods=["GET"]),
|
| 465 |
+
Route("/api/upload", endpoint=upload_handler, methods=["POST"]),
|
| 466 |
+
Route("/api/{api_name:str}", endpoint=api_handler, methods=["POST"]),
|
| 467 |
+
Route("/file", endpoint=file_handler, methods=["GET"]),
|
| 468 |
+
Route(
|
| 469 |
+
"/artifact_blob/{project:str}/{digest:str}",
|
| 470 |
+
endpoint=artifact_blob_handler,
|
| 471 |
+
methods=["GET"],
|
| 472 |
+
),
|
| 473 |
+
]
|
| 474 |
+
)
|
| 475 |
+
if utils.on_spaces():
|
| 476 |
+
routes.extend(
|
| 477 |
+
[
|
| 478 |
+
Route(
|
| 479 |
+
"/gradio_api/info",
|
| 480 |
+
endpoint=gradio_api_info_handler,
|
| 481 |
+
methods=["GET"],
|
| 482 |
+
),
|
| 483 |
+
Route(
|
| 484 |
+
"/gradio_api/info/",
|
| 485 |
+
endpoint=gradio_api_info_handler,
|
| 486 |
+
methods=["GET"],
|
| 487 |
+
),
|
| 488 |
+
Route(
|
| 489 |
+
"/gradio_api/upload",
|
| 490 |
+
endpoint=gradio_upload_alias_handler,
|
| 491 |
+
methods=["POST"],
|
| 492 |
+
),
|
| 493 |
+
Route(
|
| 494 |
+
"/gradio_api/upload/",
|
| 495 |
+
endpoint=gradio_upload_alias_handler,
|
| 496 |
+
methods=["POST"],
|
| 497 |
+
),
|
| 498 |
+
Route(
|
| 499 |
+
"/gradio_api/call/{api_name:str}",
|
| 500 |
+
endpoint=gradio_call_post_handler,
|
| 501 |
+
methods=["POST"],
|
| 502 |
+
),
|
| 503 |
+
Route(
|
| 504 |
+
"/gradio_api/call/{api_name:str}/",
|
| 505 |
+
endpoint=gradio_call_post_handler,
|
| 506 |
+
methods=["POST"],
|
| 507 |
+
),
|
| 508 |
+
Route(
|
| 509 |
+
"/gradio_api/call/{api_name:str}/{event_id:str}",
|
| 510 |
+
endpoint=gradio_call_poll_handler,
|
| 511 |
+
methods=["GET"],
|
| 512 |
+
),
|
| 513 |
+
Route(
|
| 514 |
+
"/gradio_api/call/{api_name:str}/{event_id:str}/",
|
| 515 |
+
endpoint=gradio_call_poll_handler,
|
| 516 |
+
methods=["GET"],
|
| 517 |
+
),
|
| 518 |
+
]
|
| 519 |
+
)
|
| 520 |
+
routes.extend(extra_routes or [])
|
| 521 |
+
app = Starlette(routes=routes, lifespan=mcp_lifespan)
|
| 522 |
+
app.state.api_registry = api_registry
|
| 523 |
+
app.state.mcp_enabled = mcp_enabled
|
| 524 |
+
app.state.allowed_file_roots = _normalize_allowed_file_roots(allowed_file_roots)
|
| 525 |
+
app.state.upload_authorizer = upload_authorizer
|
| 526 |
+
app.state.uploaded_temp_files = set()
|
| 527 |
+
app.state.uploaded_temp_files_lock = threading.Lock()
|
| 528 |
+
if utils.on_spaces():
|
| 529 |
+
app.state.gradio_call_events = {}
|
| 530 |
+
app.state.gradio_call_events_lock = threading.Lock()
|
| 531 |
+
return app
|
trackio/assets/badge.png
ADDED
|
Git LFS Details
|
trackio/assets/trackio_logo_dark.png
ADDED
|
trackio/assets/trackio_logo_light.png
ADDED
|
trackio/assets/trackio_logo_old.png
ADDED
|
Git LFS Details
|
trackio/assets/trackio_logo_type_dark.png
ADDED
|
trackio/assets/trackio_logo_type_dark_transparent.png
ADDED
|
trackio/assets/trackio_logo_type_light.png
ADDED
|
trackio/assets/trackio_logo_type_light_transparent.png
ADDED
|
trackio/bucket_storage.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import huggingface_hub
|
| 5 |
+
|
| 6 |
+
from trackio.cas import is_partial_blob
|
| 7 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 8 |
+
from trackio.utils import (
|
| 9 |
+
TRACKIO_DIR,
|
| 10 |
+
canonical_project_name,
|
| 11 |
+
project_artifacts_dir,
|
| 12 |
+
project_media_dir,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_bucket_if_not_exists(bucket_id: str, private: bool | None = None) -> None:
|
| 17 |
+
huggingface_hub.create_bucket(bucket_id, private=private, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
LOGBOOK_FILES_BUCKET_PREFIX = "logbook-files"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def upload_logbook_path_artifacts_to_bucket(
|
| 24 |
+
bucket_id: str, entries: list[dict]
|
| 25 |
+
) -> list[str]:
|
| 26 |
+
"""Uploads local files referenced by path-artifact cells, preserving their
|
| 27 |
+
relative path under a stable bucket prefix. Returns the relative paths that
|
| 28 |
+
were actually uploaded (missing files are skipped)."""
|
| 29 |
+
additions: list[tuple[str, str]] = []
|
| 30 |
+
uploaded: list[str] = []
|
| 31 |
+
for entry in entries:
|
| 32 |
+
local_path = Path(entry["abs_path"])
|
| 33 |
+
if not local_path.is_file():
|
| 34 |
+
continue
|
| 35 |
+
remote_path = f"{LOGBOOK_FILES_BUCKET_PREFIX}/{entry['path']}"
|
| 36 |
+
additions.append((str(local_path), remote_path))
|
| 37 |
+
uploaded.append(entry["path"])
|
| 38 |
+
if additions:
|
| 39 |
+
huggingface_hub.batch_bucket_files(
|
| 40 |
+
bucket_id, add=additions, token=huggingface_hub.utils.get_token()
|
| 41 |
+
)
|
| 42 |
+
return uploaded
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _list_bucket_file_paths(bucket_id: str, prefix: str | None = None) -> list[str]:
|
| 46 |
+
items = huggingface_hub.list_bucket_tree(bucket_id, prefix=prefix, recursive=True)
|
| 47 |
+
return [
|
| 48 |
+
item.path
|
| 49 |
+
for item in items
|
| 50 |
+
if getattr(item, "type", None) == "file" and getattr(item, "path", None)
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def download_bucket_to_trackio_dir(bucket_id: str) -> None:
|
| 55 |
+
TRACKIO_DIR.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
huggingface_hub.sync_bucket(
|
| 57 |
+
source=f"hf://buckets/{bucket_id}/trackio",
|
| 58 |
+
dest=str(TRACKIO_DIR),
|
| 59 |
+
quiet=True,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def upload_project_to_bucket(project: str, bucket_id: str) -> None:
|
| 64 |
+
db_path = SQLiteStorage.get_project_db_path(project)
|
| 65 |
+
if not db_path.exists():
|
| 66 |
+
raise FileNotFoundError(f"No database found for project '{project}'")
|
| 67 |
+
|
| 68 |
+
with SQLiteStorage._get_connection(
|
| 69 |
+
db_path, configure_pragmas=False, row_factory=None
|
| 70 |
+
) as conn:
|
| 71 |
+
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
| 72 |
+
|
| 73 |
+
files_to_add = [(str(db_path), f"trackio/{db_path.name}")]
|
| 74 |
+
|
| 75 |
+
media_dir = project_media_dir(project)
|
| 76 |
+
if media_dir.exists():
|
| 77 |
+
for media_file in media_dir.rglob("*"):
|
| 78 |
+
if media_file.is_file():
|
| 79 |
+
rel = media_file.relative_to(TRACKIO_DIR)
|
| 80 |
+
files_to_add.append((str(media_file), f"trackio/{rel.as_posix()}"))
|
| 81 |
+
|
| 82 |
+
artifacts_dir = project_artifacts_dir(project)
|
| 83 |
+
if artifacts_dir.exists():
|
| 84 |
+
for blob_file in artifacts_dir.rglob("*"):
|
| 85 |
+
if not blob_file.is_file():
|
| 86 |
+
continue
|
| 87 |
+
if is_partial_blob(blob_file.name):
|
| 88 |
+
continue
|
| 89 |
+
rel = blob_file.relative_to(TRACKIO_DIR)
|
| 90 |
+
files_to_add.append((str(blob_file), f"trackio/{rel.as_posix()}"))
|
| 91 |
+
|
| 92 |
+
huggingface_hub.batch_bucket_files(bucket_id, add=files_to_add)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _download_db_from_bucket(
|
| 96 |
+
project: str, bucket_id: str, dest_path: Path | None = None
|
| 97 |
+
) -> bool:
|
| 98 |
+
db_filename = SQLiteStorage.get_project_db_filename(project)
|
| 99 |
+
remote_path = f"trackio/{db_filename}"
|
| 100 |
+
local_path = dest_path or SQLiteStorage.get_project_db_path(project)
|
| 101 |
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
try:
|
| 103 |
+
huggingface_hub.download_bucket_files(
|
| 104 |
+
bucket_id,
|
| 105 |
+
files=[(remote_path, str(local_path))],
|
| 106 |
+
token=huggingface_hub.utils.get_token(),
|
| 107 |
+
)
|
| 108 |
+
return local_path.exists()
|
| 109 |
+
except Exception:
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _local_db_has_data(project: str) -> bool:
|
| 114 |
+
db_path = SQLiteStorage.get_project_db_path(project)
|
| 115 |
+
if not db_path.exists() or db_path.stat().st_size == 0:
|
| 116 |
+
return False
|
| 117 |
+
try:
|
| 118 |
+
with SQLiteStorage._get_connection(
|
| 119 |
+
db_path, configure_pragmas=False, row_factory=None
|
| 120 |
+
) as conn:
|
| 121 |
+
count = conn.execute("SELECT COUNT(*) FROM metrics").fetchone()[0]
|
| 122 |
+
return count > 0
|
| 123 |
+
except Exception:
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _export_and_upload_static(
|
| 128 |
+
project: str,
|
| 129 |
+
dest_bucket_id: str,
|
| 130 |
+
db_path: Path,
|
| 131 |
+
media_dir: Path | None = None,
|
| 132 |
+
artifacts_dir: Path | None = None,
|
| 133 |
+
) -> None:
|
| 134 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 135 |
+
output_dir = Path(tmp_dir)
|
| 136 |
+
SQLiteStorage.export_for_static_space(
|
| 137 |
+
project, output_dir, db_path_override=db_path
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
files_to_add = []
|
| 141 |
+
for f in output_dir.rglob("*"):
|
| 142 |
+
if f.is_file():
|
| 143 |
+
rel = f.relative_to(output_dir)
|
| 144 |
+
files_to_add.append((str(f), rel.as_posix()))
|
| 145 |
+
|
| 146 |
+
if media_dir and media_dir.exists():
|
| 147 |
+
for f in media_dir.rglob("*"):
|
| 148 |
+
if f.is_file():
|
| 149 |
+
rel = f.relative_to(media_dir)
|
| 150 |
+
files_to_add.append((str(f), f"media/{rel.as_posix()}"))
|
| 151 |
+
|
| 152 |
+
if artifacts_dir and artifacts_dir.exists():
|
| 153 |
+
for f in artifacts_dir.rglob("*"):
|
| 154 |
+
if f.is_file() and not is_partial_blob(f.name):
|
| 155 |
+
rel = f.relative_to(artifacts_dir)
|
| 156 |
+
files_to_add.append((str(f), f"artifacts/{rel.as_posix()}"))
|
| 157 |
+
|
| 158 |
+
huggingface_hub.batch_bucket_files(dest_bucket_id, add=files_to_add)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _copy_project_prefix_between_buckets(
|
| 162 |
+
source_bucket_id: str, dest_bucket_id: str, project: str, kind: str
|
| 163 |
+
) -> None:
|
| 164 |
+
source_prefix = f"trackio/{kind}/{canonical_project_name(project)}/"
|
| 165 |
+
files_to_copy = _list_bucket_file_paths(source_bucket_id, prefix=source_prefix)
|
| 166 |
+
if not files_to_copy:
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
huggingface_hub.copy_files(
|
| 170 |
+
f"hf://buckets/{source_bucket_id}/{source_prefix}",
|
| 171 |
+
f"hf://buckets/{dest_bucket_id}/{kind}/",
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def upload_project_to_bucket_for_static(project: str, bucket_id: str) -> None:
|
| 176 |
+
if not _local_db_has_data(project):
|
| 177 |
+
_download_db_from_bucket(project, bucket_id)
|
| 178 |
+
|
| 179 |
+
db_path = SQLiteStorage.get_project_db_path(project)
|
| 180 |
+
_export_and_upload_static(
|
| 181 |
+
project,
|
| 182 |
+
bucket_id,
|
| 183 |
+
db_path,
|
| 184 |
+
project_media_dir(project),
|
| 185 |
+
project_artifacts_dir(project),
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def export_from_bucket_for_static(
|
| 190 |
+
source_bucket_id: str,
|
| 191 |
+
dest_bucket_id: str,
|
| 192 |
+
project: str,
|
| 193 |
+
) -> None:
|
| 194 |
+
with tempfile.TemporaryDirectory() as work_dir:
|
| 195 |
+
work_path = Path(work_dir)
|
| 196 |
+
db_path = work_path / SQLiteStorage.get_project_db_filename(project)
|
| 197 |
+
|
| 198 |
+
if not _download_db_from_bucket(project, source_bucket_id, dest_path=db_path):
|
| 199 |
+
raise FileNotFoundError(
|
| 200 |
+
f"Could not download database for project '{project}' "
|
| 201 |
+
f"from bucket '{source_bucket_id}'."
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
_export_and_upload_static(project, dest_bucket_id, db_path)
|
| 205 |
+
for kind in ("media", "artifacts"):
|
| 206 |
+
_copy_project_prefix_between_buckets(
|
| 207 |
+
source_bucket_id, dest_bucket_id, project, kind
|
| 208 |
+
)
|
trackio/cas.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content-addressed storage (CAS) helpers shared by the artifact client,
|
| 2 |
+
server, and storage layers.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import uuid
|
| 9 |
+
from collections.abc import Iterable, Iterator
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from trackio import utils
|
| 13 |
+
from trackio.typehints import Sha256Digest
|
| 14 |
+
|
| 15 |
+
SHA256_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
| 16 |
+
ARTIFACT_VERSION_SPEC_RE = re.compile(r"^v(\d+)$")
|
| 17 |
+
ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+\Z")
|
| 18 |
+
HASH_CHUNK_SIZE = 1024 * 1024
|
| 19 |
+
PARTIAL_BLOB_MARKER = ".partial."
|
| 20 |
+
PARTIAL_BLOB_GLOB = f"*{PARTIAL_BLOB_MARKER}*"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def partial_blob_name(base: str = "") -> str:
|
| 24 |
+
"""Temp filename for an in-flight blob write, excluded from sync/export."""
|
| 25 |
+
return f"{base}{PARTIAL_BLOB_MARKER}{uuid.uuid4().hex}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def is_partial_blob(filename: str) -> bool:
|
| 29 |
+
return PARTIAL_BLOB_MARKER in filename
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validate_artifact_name(name: str) -> str:
|
| 33 |
+
if not isinstance(name, str) or not ARTIFACT_NAME_RE.match(name):
|
| 34 |
+
raise ValueError(
|
| 35 |
+
f"Artifact name {name!r} must match ^[A-Za-z0-9._-]+$ "
|
| 36 |
+
"(letters, digits, dot, underscore, hyphen)."
|
| 37 |
+
)
|
| 38 |
+
return name
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def validate_aliases(aliases: list[str] | None) -> list[str]:
|
| 42 |
+
if aliases is None:
|
| 43 |
+
return []
|
| 44 |
+
if isinstance(aliases, str) or not isinstance(aliases, (list, tuple)):
|
| 45 |
+
raise ValueError(
|
| 46 |
+
f"aliases must be a list of strings, got {type(aliases).__name__}."
|
| 47 |
+
)
|
| 48 |
+
validated: list[str] = []
|
| 49 |
+
for alias in aliases:
|
| 50 |
+
if not isinstance(alias, str) or not alias:
|
| 51 |
+
raise ValueError(f"Alias {alias!r} must be a non-empty string.")
|
| 52 |
+
if not ARTIFACT_NAME_RE.match(alias):
|
| 53 |
+
raise ValueError(
|
| 54 |
+
f"Alias {alias!r} must match ^[A-Za-z0-9._-]+$ "
|
| 55 |
+
"(letters, digits, dot, underscore, hyphen)."
|
| 56 |
+
)
|
| 57 |
+
if ARTIFACT_VERSION_SPEC_RE.match(alias):
|
| 58 |
+
raise ValueError(
|
| 59 |
+
f"Alias {alias!r} is reserved for version pointers (vN); "
|
| 60 |
+
"choose another."
|
| 61 |
+
)
|
| 62 |
+
validated.append(alias)
|
| 63 |
+
return validated
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _project_blobs_root(project: str) -> Path:
|
| 67 |
+
return utils.project_artifacts_dir(project) / "blobs" / "sha256"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def blob_path(project: str, digest: Sha256Digest) -> Path:
|
| 71 |
+
return _project_blobs_root(project) / digest[:2] / digest
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def hash_file(path: Path) -> tuple[Sha256Digest, int]:
|
| 75 |
+
size = path.stat().st_size
|
| 76 |
+
file_digest = getattr(hashlib, "file_digest", None)
|
| 77 |
+
if file_digest is not None:
|
| 78 |
+
with path.open("rb") as f:
|
| 79 |
+
sha = file_digest(f, "sha256")
|
| 80 |
+
else:
|
| 81 |
+
sha = hashlib.sha256()
|
| 82 |
+
with path.open("rb") as f:
|
| 83 |
+
while chunk := f.read(HASH_CHUNK_SIZE):
|
| 84 |
+
sha.update(chunk)
|
| 85 |
+
return Sha256Digest(sha.hexdigest()), size
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def validate_logical_path(logical: str) -> str:
|
| 89 |
+
"""Validate a manifest logical path: relative, no `.`/`..` segments, no
|
| 90 |
+
backslashes, no drive letters, non-empty. Returns the path unchanged.
|
| 91 |
+
"""
|
| 92 |
+
if not isinstance(logical, str) or not logical:
|
| 93 |
+
raise ValueError(f"Invalid artifact path: {logical!r} (must be non-empty)")
|
| 94 |
+
if "\\" in logical or "\x00" in logical:
|
| 95 |
+
raise ValueError(
|
| 96 |
+
f"Invalid artifact path {logical!r}: backslashes and NUL are not allowed"
|
| 97 |
+
)
|
| 98 |
+
if logical.startswith("/") or re.match(r"^[A-Za-z]:", logical):
|
| 99 |
+
raise ValueError(f"Invalid artifact path {logical!r}: must be relative")
|
| 100 |
+
parts = logical.split("/")
|
| 101 |
+
if any(part in ("", ".", "..") for part in parts):
|
| 102 |
+
raise ValueError(
|
| 103 |
+
f"Invalid artifact path {logical!r}: "
|
| 104 |
+
"'.', '..', and empty segments are not allowed"
|
| 105 |
+
)
|
| 106 |
+
return logical
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def assert_manifest_paths_compatible(paths: Iterable[str]) -> None:
|
| 110 |
+
"""Validate a manifest's logical paths as a set: reject exact duplicates and
|
| 111 |
+
file/directory prefix collisions. A path cannot be both a file and a
|
| 112 |
+
directory, so `sub` and `sub/x` (or `a/b` and `a/b/c`) cannot coexist;
|
| 113 |
+
siblings like `a/b` and `a/c` are fine.
|
| 114 |
+
"""
|
| 115 |
+
seen: set[str] = set()
|
| 116 |
+
for p in paths:
|
| 117 |
+
if p in seen:
|
| 118 |
+
raise ValueError(f"Duplicate logical path in manifest: {p!r}")
|
| 119 |
+
seen.add(p)
|
| 120 |
+
for p in seen:
|
| 121 |
+
parts = p.split("/")
|
| 122 |
+
for i in range(1, len(parts)):
|
| 123 |
+
ancestor = "/".join(parts[:i])
|
| 124 |
+
if ancestor in seen:
|
| 125 |
+
raise ValueError(
|
| 126 |
+
f"Artifact path {p!r} collides with {ancestor!r}: a path "
|
| 127 |
+
"cannot be both a file and a directory in the same artifact."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def validate_digest(digest: str) -> Sha256Digest:
|
| 132 |
+
"""Validate that `digest` is a 64-char lowercase sha256 hex string and
|
| 133 |
+
return it unchanged.
|
| 134 |
+
"""
|
| 135 |
+
if not isinstance(digest, str) or not SHA256_DIGEST_RE.match(digest):
|
| 136 |
+
raise ValueError(f"Invalid artifact blob digest: {digest!r}")
|
| 137 |
+
return Sha256Digest(digest)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def stage_blob_from_chunks(
|
| 141 |
+
chunks: Iterable[bytes],
|
| 142 |
+
claimed_digest: Sha256Digest,
|
| 143 |
+
target_path: Path,
|
| 144 |
+
) -> None:
|
| 145 |
+
"""Stream `chunks` to `target_path`, verifying they hash to `claimed_digest`.
|
| 146 |
+
No-op if `target_path` already exists (the chunks are not consumed).
|
| 147 |
+
"""
|
| 148 |
+
if target_path.is_file():
|
| 149 |
+
return
|
| 150 |
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
| 151 |
+
partial = target_path.parent / partial_blob_name(target_path.name)
|
| 152 |
+
sha = hashlib.sha256()
|
| 153 |
+
try:
|
| 154 |
+
with partial.open("wb") as dst:
|
| 155 |
+
for chunk in chunks:
|
| 156 |
+
if not chunk:
|
| 157 |
+
continue
|
| 158 |
+
sha.update(chunk)
|
| 159 |
+
dst.write(chunk)
|
| 160 |
+
actual = sha.hexdigest()
|
| 161 |
+
if actual != claimed_digest:
|
| 162 |
+
raise ValueError(
|
| 163 |
+
f"Digest mismatch: claimed {claimed_digest}, computed {actual}"
|
| 164 |
+
)
|
| 165 |
+
os.replace(partial, target_path)
|
| 166 |
+
except Exception:
|
| 167 |
+
partial.unlink(missing_ok=True)
|
| 168 |
+
raise
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def stage_blob_from_file(
|
| 172 |
+
src_path: Path,
|
| 173 |
+
claimed_digest: Sha256Digest,
|
| 174 |
+
target_path: Path,
|
| 175 |
+
) -> None:
|
| 176 |
+
"""Copy `src_path` to `target_path`, verifying it hashes to `claimed_digest`."""
|
| 177 |
+
if target_path.is_file():
|
| 178 |
+
return
|
| 179 |
+
|
| 180 |
+
def _file_chunks() -> Iterator[bytes]:
|
| 181 |
+
with src_path.open("rb") as src:
|
| 182 |
+
while chunk := src.read(HASH_CHUNK_SIZE):
|
| 183 |
+
yield chunk
|
| 184 |
+
|
| 185 |
+
stage_blob_from_chunks(_file_chunks(), claimed_digest, target_path)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def stage_blob_into_project(src_path: Path, project: str) -> tuple[Sha256Digest, int]:
|
| 189 |
+
"""Copy `src_path` into the project's CAS in a single pass, computing its
|
| 190 |
+
sha256 and size while writing. Returns `(digest, size)`. If the blob is
|
| 191 |
+
already present, the copy is discarded.
|
| 192 |
+
"""
|
| 193 |
+
blobs_root = _project_blobs_root(project)
|
| 194 |
+
blobs_root.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
partial = blobs_root / partial_blob_name()
|
| 196 |
+
sha = hashlib.sha256()
|
| 197 |
+
size = 0
|
| 198 |
+
try:
|
| 199 |
+
with src_path.open("rb") as src, partial.open("wb") as dst:
|
| 200 |
+
while chunk := src.read(HASH_CHUNK_SIZE):
|
| 201 |
+
sha.update(chunk)
|
| 202 |
+
dst.write(chunk)
|
| 203 |
+
size += len(chunk)
|
| 204 |
+
digest = Sha256Digest(sha.hexdigest())
|
| 205 |
+
target = blob_path(project, digest)
|
| 206 |
+
if target.is_file():
|
| 207 |
+
partial.unlink(missing_ok=True)
|
| 208 |
+
return digest, size
|
| 209 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
os.replace(partial, target)
|
| 211 |
+
return digest, size
|
| 212 |
+
except Exception:
|
| 213 |
+
partial.unlink(missing_ok=True)
|
| 214 |
+
raise
|
trackio/cli.py
ADDED
|
@@ -0,0 +1,2307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import huggingface_hub
|
| 8 |
+
|
| 9 |
+
import trackio
|
| 10 |
+
from trackio import freeze, show, sync
|
| 11 |
+
from trackio.cli_helpers import (
|
| 12 |
+
error_exit,
|
| 13 |
+
format_alerts,
|
| 14 |
+
format_artifact,
|
| 15 |
+
format_artifacts,
|
| 16 |
+
format_json,
|
| 17 |
+
format_list,
|
| 18 |
+
format_metric_values,
|
| 19 |
+
format_project_summary,
|
| 20 |
+
format_query_result,
|
| 21 |
+
format_run_summary,
|
| 22 |
+
format_snapshot,
|
| 23 |
+
format_spaces,
|
| 24 |
+
format_system_metric_names,
|
| 25 |
+
format_system_metrics,
|
| 26 |
+
)
|
| 27 |
+
from trackio.frontend_config import (
|
| 28 |
+
TRACKIO_CONFIG_PATH,
|
| 29 |
+
get_persisted_frontend_dir,
|
| 30 |
+
set_persisted_frontend_dir,
|
| 31 |
+
unset_persisted_frontend_dir,
|
| 32 |
+
)
|
| 33 |
+
from trackio.markdown import Markdown
|
| 34 |
+
from trackio.server import get_project_summary, get_run_summary
|
| 35 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _get_space(args):
|
| 39 |
+
return getattr(args, "space", None)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _get_remote(args):
|
| 43 |
+
from trackio.remote_client import RemoteClient
|
| 44 |
+
|
| 45 |
+
space = _get_space(args)
|
| 46 |
+
if not space:
|
| 47 |
+
return None
|
| 48 |
+
hf_token = getattr(args, "hf_token", None)
|
| 49 |
+
return RemoteClient(space, hf_token=hf_token)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _handle_status():
|
| 53 |
+
print("Reading local Trackio projects...\n")
|
| 54 |
+
projects = SQLiteStorage.get_projects()
|
| 55 |
+
if not projects:
|
| 56 |
+
print("No Trackio projects found.")
|
| 57 |
+
return
|
| 58 |
+
|
| 59 |
+
local_projects = []
|
| 60 |
+
synced_projects = []
|
| 61 |
+
unsynced_projects = []
|
| 62 |
+
|
| 63 |
+
for project in projects:
|
| 64 |
+
space_id = SQLiteStorage.get_space_id(project)
|
| 65 |
+
if space_id is None:
|
| 66 |
+
local_projects.append(project)
|
| 67 |
+
elif SQLiteStorage.has_pending_data(project):
|
| 68 |
+
unsynced_projects.append(project)
|
| 69 |
+
else:
|
| 70 |
+
synced_projects.append(project)
|
| 71 |
+
|
| 72 |
+
print("Finished reading Trackio projects")
|
| 73 |
+
if local_projects:
|
| 74 |
+
print(f" * {len(local_projects)} local trackio project(s) [OK]")
|
| 75 |
+
if synced_projects:
|
| 76 |
+
print(f" * {len(synced_projects)} trackio project(s) synced to Spaces [OK]")
|
| 77 |
+
if unsynced_projects:
|
| 78 |
+
print(
|
| 79 |
+
f" * {len(unsynced_projects)} trackio project(s) with unsynced changes [WARNING]:"
|
| 80 |
+
)
|
| 81 |
+
for p in unsynced_projects:
|
| 82 |
+
print(f" - {p}")
|
| 83 |
+
|
| 84 |
+
if unsynced_projects:
|
| 85 |
+
print(
|
| 86 |
+
f"\nRun `trackio sync --project {unsynced_projects[0]}` to sync. "
|
| 87 |
+
"Or run `trackio sync --all` to sync all unsynced changes."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _handle_sync(args):
|
| 92 |
+
from trackio.deploy import sync_incremental
|
| 93 |
+
|
| 94 |
+
if args.sync_all and args.project:
|
| 95 |
+
error_exit("Cannot use --all and --project together.")
|
| 96 |
+
if not args.sync_all and not args.project:
|
| 97 |
+
error_exit("Must provide either --project or --all.")
|
| 98 |
+
|
| 99 |
+
if args.sync_all:
|
| 100 |
+
projects = SQLiteStorage.get_projects()
|
| 101 |
+
synced_any = False
|
| 102 |
+
for project in projects:
|
| 103 |
+
space_id = SQLiteStorage.get_space_id(project)
|
| 104 |
+
if space_id and SQLiteStorage.has_pending_data(project):
|
| 105 |
+
sync_incremental(
|
| 106 |
+
project,
|
| 107 |
+
space_id,
|
| 108 |
+
private=args.private,
|
| 109 |
+
pending_only=True,
|
| 110 |
+
frontend_dir=args.frontend,
|
| 111 |
+
)
|
| 112 |
+
synced_any = True
|
| 113 |
+
if not synced_any:
|
| 114 |
+
print("No projects with unsynced data found.")
|
| 115 |
+
else:
|
| 116 |
+
space_id = args.space_id
|
| 117 |
+
if space_id is None:
|
| 118 |
+
space_id = SQLiteStorage.get_space_id(args.project)
|
| 119 |
+
sync(
|
| 120 |
+
project=args.project,
|
| 121 |
+
space_id=space_id,
|
| 122 |
+
private=args.private,
|
| 123 |
+
force=args.force,
|
| 124 |
+
sdk=args.sdk,
|
| 125 |
+
frontend_dir=args.frontend,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _handle_config(args):
|
| 130 |
+
if args.config_command == "get":
|
| 131 |
+
frontend_dir = get_persisted_frontend_dir()
|
| 132 |
+
if frontend_dir is None:
|
| 133 |
+
print("No Trackio frontend config is set.")
|
| 134 |
+
print(f"Config file: {TRACKIO_CONFIG_PATH}")
|
| 135 |
+
return
|
| 136 |
+
print(f"frontend: {frontend_dir}")
|
| 137 |
+
print(f"config: {TRACKIO_CONFIG_PATH}")
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
if args.config_command == "set":
|
| 141 |
+
try:
|
| 142 |
+
frontend_dir = set_persisted_frontend_dir(args.frontend)
|
| 143 |
+
except ValueError as e:
|
| 144 |
+
error_exit(str(e))
|
| 145 |
+
print(f"Saved Trackio default frontend: {frontend_dir}")
|
| 146 |
+
print("Reset with `trackio config unset frontend`.")
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
if args.config_command == "unset":
|
| 150 |
+
removed = unset_persisted_frontend_dir()
|
| 151 |
+
if removed:
|
| 152 |
+
print("Removed Trackio default frontend.")
|
| 153 |
+
else:
|
| 154 |
+
print("No Trackio default frontend was set.")
|
| 155 |
+
return
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _extract_reports(
|
| 159 |
+
run: str, logs: list[dict], report_name: str | None = None
|
| 160 |
+
) -> list[dict]:
|
| 161 |
+
reports = []
|
| 162 |
+
for log in logs:
|
| 163 |
+
timestamp = log.get("timestamp")
|
| 164 |
+
step = log.get("step")
|
| 165 |
+
for key, value in log.items():
|
| 166 |
+
if report_name is not None and key != report_name:
|
| 167 |
+
continue
|
| 168 |
+
if isinstance(value, dict) and value.get("_type") == Markdown.TYPE:
|
| 169 |
+
content = value.get("_value")
|
| 170 |
+
if isinstance(content, str):
|
| 171 |
+
reports.append(
|
| 172 |
+
{
|
| 173 |
+
"run": run,
|
| 174 |
+
"report": key,
|
| 175 |
+
"step": step,
|
| 176 |
+
"timestamp": timestamp,
|
| 177 |
+
"content": content,
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
return reports
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _handle_query(args):
|
| 184 |
+
remote = _get_remote(args)
|
| 185 |
+
try:
|
| 186 |
+
if remote:
|
| 187 |
+
result = remote.predict(args.project, args.sql, api_name="/query_project")
|
| 188 |
+
else:
|
| 189 |
+
result = SQLiteStorage.query_project(args.project, args.sql)
|
| 190 |
+
except FileNotFoundError as e:
|
| 191 |
+
error_exit(str(e))
|
| 192 |
+
except ValueError as e:
|
| 193 |
+
error_exit(str(e))
|
| 194 |
+
|
| 195 |
+
if args.json:
|
| 196 |
+
print(format_json(result))
|
| 197 |
+
else:
|
| 198 |
+
print(format_query_result(result))
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _datetime_to_iso(value):
|
| 202 |
+
return value.isoformat() if value is not None else None
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _space_url(space) -> str | None:
|
| 206 |
+
host = getattr(space, "host", None)
|
| 207 |
+
if host:
|
| 208 |
+
return host if host.startswith("http") else f"https://{host}"
|
| 209 |
+
|
| 210 |
+
subdomain = getattr(space, "subdomain", None)
|
| 211 |
+
if subdomain:
|
| 212 |
+
return f"https://{subdomain}.hf.space"
|
| 213 |
+
|
| 214 |
+
space_id = getattr(space, "id", None)
|
| 215 |
+
if not space_id:
|
| 216 |
+
return None
|
| 217 |
+
return f"https://huggingface.co/spaces/{space_id}"
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _serialize_space(space) -> dict:
|
| 221 |
+
space_id = getattr(space, "id", "")
|
| 222 |
+
namespace, _, name = space_id.partition("/")
|
| 223 |
+
return {
|
| 224 |
+
"id": space_id,
|
| 225 |
+
"namespace": namespace,
|
| 226 |
+
"name": name or space_id,
|
| 227 |
+
"author": getattr(space, "author", None) or namespace,
|
| 228 |
+
"private": bool(getattr(space, "private", False)),
|
| 229 |
+
"sdk": getattr(space, "sdk", None),
|
| 230 |
+
"url": _space_url(space),
|
| 231 |
+
"last_modified": _datetime_to_iso(
|
| 232 |
+
getattr(space, "last_modified", None)
|
| 233 |
+
or getattr(space, "lastModified", None)
|
| 234 |
+
),
|
| 235 |
+
"created_at": _datetime_to_iso(getattr(space, "created_at", None)),
|
| 236 |
+
"tags": getattr(space, "tags", None) or [],
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _maybe_handle_logbook_run_argv() -> bool:
|
| 241 |
+
argv = sys.argv[1:]
|
| 242 |
+
try:
|
| 243 |
+
logbook_idx = argv.index("logbook")
|
| 244 |
+
except ValueError:
|
| 245 |
+
return False
|
| 246 |
+
if len(argv) <= logbook_idx + 1 or argv[logbook_idx + 1] != "run":
|
| 247 |
+
return False
|
| 248 |
+
|
| 249 |
+
run_argv = argv[logbook_idx + 2 :]
|
| 250 |
+
|
| 251 |
+
run_parser = argparse.ArgumentParser(
|
| 252 |
+
prog=f"{os.path.basename(sys.argv[0])} logbook run",
|
| 253 |
+
description=(
|
| 254 |
+
"Run a command; log the command, its scripts, and output to a page"
|
| 255 |
+
),
|
| 256 |
+
)
|
| 257 |
+
run_parser.add_argument("--page", help="Page title or slug")
|
| 258 |
+
run_parser.add_argument("--title", help="Cell title")
|
| 259 |
+
run_parser.add_argument(
|
| 260 |
+
"--no-artifacts",
|
| 261 |
+
action="store_true",
|
| 262 |
+
help="Do not record output model/data files as artifact cells",
|
| 263 |
+
)
|
| 264 |
+
if "--" in run_argv:
|
| 265 |
+
sep = run_argv.index("--")
|
| 266 |
+
opts = run_parser.parse_args(run_argv[:sep])
|
| 267 |
+
command = run_argv[sep + 1 :]
|
| 268 |
+
else:
|
| 269 |
+
opts, command = run_parser.parse_known_args(run_argv)
|
| 270 |
+
if not command:
|
| 271 |
+
run_parser.error("No command provided. Use: trackio logbook run -- <command>")
|
| 272 |
+
args = argparse.Namespace(
|
| 273 |
+
logbook_action="run",
|
| 274 |
+
page=opts.page,
|
| 275 |
+
title=opts.title,
|
| 276 |
+
no_artifacts=opts.no_artifacts,
|
| 277 |
+
command=command,
|
| 278 |
+
)
|
| 279 |
+
_handle_logbook(args)
|
| 280 |
+
return True
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _maybe_rewrite_logbook_read_argv() -> None:
|
| 284 |
+
argv = sys.argv
|
| 285 |
+
for i in range(1, len(argv) - 1):
|
| 286 |
+
if argv[i] == "logbook" and argv[i + 1] == "read":
|
| 287 |
+
j = i + 2
|
| 288 |
+
if (
|
| 289 |
+
j < len(argv)
|
| 290 |
+
and not argv[j].startswith("-")
|
| 291 |
+
and argv[j] not in ("pages", "page", "cell")
|
| 292 |
+
):
|
| 293 |
+
argv[j : j + 1] = ["--path", argv[j]]
|
| 294 |
+
return
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _trackio_space_namespaces(api, token: str | None, author: str | None) -> list[str]:
|
| 298 |
+
if author:
|
| 299 |
+
return [author]
|
| 300 |
+
|
| 301 |
+
if not token:
|
| 302 |
+
error_exit(
|
| 303 |
+
"Log in with `huggingface-cli login`, pass `--hf-token`, or provide `--author`."
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
try:
|
| 307 |
+
whoami = api.whoami(token=token, cache=True)
|
| 308 |
+
except Exception as e:
|
| 309 |
+
error_exit(f"Failed to read Hugging Face account information: {e}")
|
| 310 |
+
|
| 311 |
+
namespaces = [whoami["name"]]
|
| 312 |
+
for org in whoami.get("orgs", []):
|
| 313 |
+
org_name = org.get("name") if isinstance(org, dict) else org
|
| 314 |
+
if org_name:
|
| 315 |
+
namespaces.append(org_name)
|
| 316 |
+
|
| 317 |
+
return list(dict.fromkeys(namespaces))
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _handle_list_spaces(args):
|
| 321 |
+
if _get_space(args):
|
| 322 |
+
error_exit("The 'list spaces' command does not support --space.")
|
| 323 |
+
if args.limit is not None and args.limit < 0:
|
| 324 |
+
error_exit("--limit must be zero or greater.")
|
| 325 |
+
|
| 326 |
+
token = args.hf_token or huggingface_hub.utils.get_token()
|
| 327 |
+
api = huggingface_hub.HfApi(token=token)
|
| 328 |
+
namespaces = _trackio_space_namespaces(api, token, args.author)
|
| 329 |
+
|
| 330 |
+
spaces_by_id = {}
|
| 331 |
+
try:
|
| 332 |
+
for namespace in namespaces:
|
| 333 |
+
spaces = api.list_spaces(
|
| 334 |
+
author=namespace,
|
| 335 |
+
filter="trackio",
|
| 336 |
+
full=True,
|
| 337 |
+
token=token,
|
| 338 |
+
)
|
| 339 |
+
for space in spaces:
|
| 340 |
+
space_id = getattr(space, "id", None)
|
| 341 |
+
if space_id:
|
| 342 |
+
spaces_by_id[space_id] = _serialize_space(space)
|
| 343 |
+
except Exception as e:
|
| 344 |
+
error_exit(f"Failed to list Trackio Spaces: {e}")
|
| 345 |
+
|
| 346 |
+
spaces = sorted(
|
| 347 |
+
spaces_by_id.values(),
|
| 348 |
+
key=lambda space: space.get("last_modified") or "",
|
| 349 |
+
reverse=True,
|
| 350 |
+
)
|
| 351 |
+
if args.limit is not None:
|
| 352 |
+
spaces = spaces[: args.limit]
|
| 353 |
+
|
| 354 |
+
if args.json:
|
| 355 |
+
print(format_json({"spaces": spaces}))
|
| 356 |
+
else:
|
| 357 |
+
print(format_spaces(spaces))
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def main():
|
| 361 |
+
if _maybe_handle_logbook_run_argv():
|
| 362 |
+
return
|
| 363 |
+
_maybe_rewrite_logbook_read_argv()
|
| 364 |
+
|
| 365 |
+
parser = argparse.ArgumentParser(description="Trackio CLI")
|
| 366 |
+
parser.add_argument(
|
| 367 |
+
"--version",
|
| 368 |
+
action="version",
|
| 369 |
+
version=f"trackio {trackio.__version__}",
|
| 370 |
+
)
|
| 371 |
+
parser.add_argument(
|
| 372 |
+
"--space",
|
| 373 |
+
required=False,
|
| 374 |
+
help="HF Space ID (e.g. 'user/space') or Space URL to query remotely.",
|
| 375 |
+
)
|
| 376 |
+
parser.add_argument(
|
| 377 |
+
"--hf-token",
|
| 378 |
+
required=False,
|
| 379 |
+
help="HF token for accessing private Spaces.",
|
| 380 |
+
)
|
| 381 |
+
subparsers = parser.add_subparsers(dest="command")
|
| 382 |
+
|
| 383 |
+
ui_parser = subparsers.add_parser(
|
| 384 |
+
"show", help="Show the Trackio dashboard UI for a project"
|
| 385 |
+
)
|
| 386 |
+
ui_parser.add_argument(
|
| 387 |
+
"--project", required=False, help="Project name to show in the dashboard"
|
| 388 |
+
)
|
| 389 |
+
ui_parser.add_argument(
|
| 390 |
+
"--theme",
|
| 391 |
+
required=False,
|
| 392 |
+
default="default",
|
| 393 |
+
help="A Gradio Theme to use for the dashboard instead of the default, can be a built-in theme (e.g. 'soft', 'citrus'), or a theme from the Hub (e.g. 'gstaff/xkcd').",
|
| 394 |
+
)
|
| 395 |
+
ui_parser.add_argument(
|
| 396 |
+
"--mcp-server",
|
| 397 |
+
action="store_true",
|
| 398 |
+
help="Enable MCP server functionality. The Trackio dashboard will be set up as an MCP server and certain functions will be exposed as MCP tools.",
|
| 399 |
+
)
|
| 400 |
+
ui_parser.add_argument(
|
| 401 |
+
"--footer",
|
| 402 |
+
action="store_true",
|
| 403 |
+
default=True,
|
| 404 |
+
help="Show the Gradio footer. Use --no-footer to hide it.",
|
| 405 |
+
)
|
| 406 |
+
ui_parser.add_argument(
|
| 407 |
+
"--no-footer",
|
| 408 |
+
dest="footer",
|
| 409 |
+
action="store_false",
|
| 410 |
+
help="Hide the Gradio footer.",
|
| 411 |
+
)
|
| 412 |
+
ui_parser.add_argument(
|
| 413 |
+
"--color-palette",
|
| 414 |
+
required=False,
|
| 415 |
+
help="Comma-separated list of hex color codes for plot lines (e.g. '#FF0000,#00FF00,#0000FF'). If not provided, the TRACKIO_COLOR_PALETTE environment variable will be used, or the default palette if not set.",
|
| 416 |
+
)
|
| 417 |
+
ui_parser.add_argument(
|
| 418 |
+
"--host",
|
| 419 |
+
required=False,
|
| 420 |
+
help="Host to bind the server to (e.g. '0.0.0.0' for remote access). If not provided, defaults to '127.0.0.1' (localhost only).",
|
| 421 |
+
)
|
| 422 |
+
ui_parser.add_argument(
|
| 423 |
+
"--frontend",
|
| 424 |
+
required=False,
|
| 425 |
+
help="Custom frontend directory to serve. Must contain index.html.",
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
subparsers.add_parser(
|
| 429 |
+
"status",
|
| 430 |
+
help="Show the status of all local Trackio projects, including sync status.",
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
sync_parser = subparsers.add_parser(
|
| 434 |
+
"sync",
|
| 435 |
+
help="Sync a local project's database to a Hugging Face Space. If the Space does not exist, it will be created.",
|
| 436 |
+
)
|
| 437 |
+
sync_parser.add_argument(
|
| 438 |
+
"--project",
|
| 439 |
+
required=False,
|
| 440 |
+
help="The name of the local project.",
|
| 441 |
+
)
|
| 442 |
+
sync_parser.add_argument(
|
| 443 |
+
"--space-id",
|
| 444 |
+
required=False,
|
| 445 |
+
help="The Hugging Face Space ID where the project will be synced (e.g. username/space_id). If not provided, uses the previously-configured Space.",
|
| 446 |
+
)
|
| 447 |
+
sync_parser.add_argument(
|
| 448 |
+
"--all",
|
| 449 |
+
action="store_true",
|
| 450 |
+
dest="sync_all",
|
| 451 |
+
help="Sync all projects that have unsynced data to their configured Spaces.",
|
| 452 |
+
)
|
| 453 |
+
sync_parser.add_argument(
|
| 454 |
+
"--private",
|
| 455 |
+
action="store_true",
|
| 456 |
+
help="Make the Hugging Face Space private if creating a new Space. By default, the repo will be public unless the organization's default is private. This value is ignored if the repo already exists.",
|
| 457 |
+
)
|
| 458 |
+
sync_parser.add_argument(
|
| 459 |
+
"--force",
|
| 460 |
+
action="store_true",
|
| 461 |
+
help="Overwrite the existing database without prompting for confirmation.",
|
| 462 |
+
)
|
| 463 |
+
sync_parser.add_argument(
|
| 464 |
+
"--sdk",
|
| 465 |
+
choices=["gradio", "static"],
|
| 466 |
+
default="gradio",
|
| 467 |
+
help="The type of Space to deploy. 'gradio' (default) deploys a live Gradio server. 'static' deploys a static Space that reads from an HF Bucket.",
|
| 468 |
+
)
|
| 469 |
+
sync_parser.add_argument(
|
| 470 |
+
"--frontend",
|
| 471 |
+
required=False,
|
| 472 |
+
help="Custom frontend directory to deploy. Must contain index.html.",
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
freeze_parser = subparsers.add_parser(
|
| 476 |
+
"freeze",
|
| 477 |
+
help="Create a one-time static Space snapshot from a project's data.",
|
| 478 |
+
)
|
| 479 |
+
freeze_parser.add_argument(
|
| 480 |
+
"--space-id",
|
| 481 |
+
required=True,
|
| 482 |
+
help="The source Gradio Space ID (e.g. username/space_id).",
|
| 483 |
+
)
|
| 484 |
+
freeze_parser.add_argument(
|
| 485 |
+
"--project",
|
| 486 |
+
required=True,
|
| 487 |
+
help="The name of the project to freeze into a static snapshot.",
|
| 488 |
+
)
|
| 489 |
+
freeze_parser.add_argument(
|
| 490 |
+
"--new-space-id",
|
| 491 |
+
required=False,
|
| 492 |
+
help="The Space ID for the new static Space. Defaults to {space_id}_static.",
|
| 493 |
+
)
|
| 494 |
+
freeze_parser.add_argument(
|
| 495 |
+
"--private",
|
| 496 |
+
action="store_true",
|
| 497 |
+
help="Make the new static Space private.",
|
| 498 |
+
)
|
| 499 |
+
freeze_parser.add_argument(
|
| 500 |
+
"--frontend",
|
| 501 |
+
required=False,
|
| 502 |
+
help="Custom frontend directory to deploy to the frozen static Space.",
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
config_parser = subparsers.add_parser(
|
| 506 |
+
"config",
|
| 507 |
+
help="Manage persistent Trackio configuration.",
|
| 508 |
+
)
|
| 509 |
+
config_subparsers = config_parser.add_subparsers(
|
| 510 |
+
dest="config_command",
|
| 511 |
+
required=True,
|
| 512 |
+
)
|
| 513 |
+
config_subparsers.add_parser("get", help="Show current Trackio config.")
|
| 514 |
+
config_set_parser = config_subparsers.add_parser(
|
| 515 |
+
"set",
|
| 516 |
+
help="Set a persistent Trackio config value.",
|
| 517 |
+
)
|
| 518 |
+
config_set_parser.add_argument(
|
| 519 |
+
"key",
|
| 520 |
+
choices=["frontend"],
|
| 521 |
+
help="Config key to set.",
|
| 522 |
+
)
|
| 523 |
+
config_set_parser.add_argument(
|
| 524 |
+
"frontend",
|
| 525 |
+
help="Frontend directory to persist.",
|
| 526 |
+
)
|
| 527 |
+
config_unset_parser = config_subparsers.add_parser(
|
| 528 |
+
"unset",
|
| 529 |
+
help="Unset a persistent Trackio config value.",
|
| 530 |
+
)
|
| 531 |
+
config_unset_parser.add_argument(
|
| 532 |
+
"key",
|
| 533 |
+
choices=["frontend"],
|
| 534 |
+
help="Config key to unset.",
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
list_parser = subparsers.add_parser(
|
| 538 |
+
"list",
|
| 539 |
+
help="List projects, runs, or metrics",
|
| 540 |
+
)
|
| 541 |
+
list_subparsers = list_parser.add_subparsers(dest="list_type", required=True)
|
| 542 |
+
|
| 543 |
+
list_projects_parser = list_subparsers.add_parser(
|
| 544 |
+
"projects",
|
| 545 |
+
help="List all projects",
|
| 546 |
+
)
|
| 547 |
+
list_projects_parser.add_argument(
|
| 548 |
+
"--json",
|
| 549 |
+
action="store_true",
|
| 550 |
+
help="Output in JSON format",
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
list_spaces_parser = list_subparsers.add_parser(
|
| 554 |
+
"spaces",
|
| 555 |
+
help="List Trackio Spaces for your Hugging Face account and organizations",
|
| 556 |
+
)
|
| 557 |
+
list_spaces_parser.add_argument(
|
| 558 |
+
"--author",
|
| 559 |
+
required=False,
|
| 560 |
+
help="Only list Trackio Spaces under this user or organization namespace.",
|
| 561 |
+
)
|
| 562 |
+
list_spaces_parser.add_argument(
|
| 563 |
+
"--limit",
|
| 564 |
+
type=int,
|
| 565 |
+
required=False,
|
| 566 |
+
help="Maximum number of Spaces to return.",
|
| 567 |
+
)
|
| 568 |
+
list_spaces_parser.add_argument(
|
| 569 |
+
"--json",
|
| 570 |
+
action="store_true",
|
| 571 |
+
help="Output in JSON format",
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
list_runs_parser = list_subparsers.add_parser(
|
| 575 |
+
"runs",
|
| 576 |
+
help="List runs for a project",
|
| 577 |
+
)
|
| 578 |
+
list_runs_parser.add_argument(
|
| 579 |
+
"--project",
|
| 580 |
+
required=True,
|
| 581 |
+
help="Project name",
|
| 582 |
+
)
|
| 583 |
+
list_runs_parser.add_argument(
|
| 584 |
+
"--json",
|
| 585 |
+
action="store_true",
|
| 586 |
+
help="Output in JSON format",
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
list_metrics_parser = list_subparsers.add_parser(
|
| 590 |
+
"metrics",
|
| 591 |
+
help="List metrics for a run",
|
| 592 |
+
)
|
| 593 |
+
list_metrics_parser.add_argument(
|
| 594 |
+
"--project",
|
| 595 |
+
required=True,
|
| 596 |
+
help="Project name",
|
| 597 |
+
)
|
| 598 |
+
list_metrics_parser.add_argument(
|
| 599 |
+
"--run",
|
| 600 |
+
required=True,
|
| 601 |
+
help="Run name",
|
| 602 |
+
)
|
| 603 |
+
list_metrics_parser.add_argument(
|
| 604 |
+
"--json",
|
| 605 |
+
action="store_true",
|
| 606 |
+
help="Output in JSON format",
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
list_system_metrics_parser = list_subparsers.add_parser(
|
| 610 |
+
"system-metrics",
|
| 611 |
+
help="List system metrics for a run",
|
| 612 |
+
)
|
| 613 |
+
list_system_metrics_parser.add_argument(
|
| 614 |
+
"--project",
|
| 615 |
+
required=True,
|
| 616 |
+
help="Project name",
|
| 617 |
+
)
|
| 618 |
+
list_system_metrics_parser.add_argument(
|
| 619 |
+
"--run",
|
| 620 |
+
required=True,
|
| 621 |
+
help="Run name",
|
| 622 |
+
)
|
| 623 |
+
list_system_metrics_parser.add_argument(
|
| 624 |
+
"--json",
|
| 625 |
+
action="store_true",
|
| 626 |
+
help="Output in JSON format",
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
list_alerts_parser = list_subparsers.add_parser(
|
| 630 |
+
"alerts",
|
| 631 |
+
help="List alerts for a project or run",
|
| 632 |
+
)
|
| 633 |
+
list_alerts_parser.add_argument(
|
| 634 |
+
"--project",
|
| 635 |
+
required=True,
|
| 636 |
+
help="Project name",
|
| 637 |
+
)
|
| 638 |
+
list_alerts_parser.add_argument(
|
| 639 |
+
"--run",
|
| 640 |
+
required=False,
|
| 641 |
+
help="Run name (optional)",
|
| 642 |
+
)
|
| 643 |
+
list_alerts_parser.add_argument(
|
| 644 |
+
"--level",
|
| 645 |
+
required=False,
|
| 646 |
+
help="Filter by alert level (info, warn, error)",
|
| 647 |
+
)
|
| 648 |
+
list_alerts_parser.add_argument(
|
| 649 |
+
"--json",
|
| 650 |
+
action="store_true",
|
| 651 |
+
help="Output in JSON format",
|
| 652 |
+
)
|
| 653 |
+
list_alerts_parser.add_argument(
|
| 654 |
+
"--since",
|
| 655 |
+
required=False,
|
| 656 |
+
help="Only show alerts after this ISO 8601 timestamp",
|
| 657 |
+
)
|
| 658 |
+
|
| 659 |
+
list_reports_parser = list_subparsers.add_parser(
|
| 660 |
+
"reports",
|
| 661 |
+
help="List markdown reports for a project or run",
|
| 662 |
+
)
|
| 663 |
+
list_reports_parser.add_argument(
|
| 664 |
+
"--project",
|
| 665 |
+
required=True,
|
| 666 |
+
help="Project name",
|
| 667 |
+
)
|
| 668 |
+
list_reports_parser.add_argument(
|
| 669 |
+
"--run",
|
| 670 |
+
required=False,
|
| 671 |
+
help="Run name (optional)",
|
| 672 |
+
)
|
| 673 |
+
list_reports_parser.add_argument(
|
| 674 |
+
"--json",
|
| 675 |
+
action="store_true",
|
| 676 |
+
help="Output in JSON format",
|
| 677 |
+
)
|
| 678 |
+
|
| 679 |
+
list_artifacts_parser = list_subparsers.add_parser(
|
| 680 |
+
"artifacts",
|
| 681 |
+
help="List artifacts for a project",
|
| 682 |
+
)
|
| 683 |
+
list_artifacts_parser.add_argument(
|
| 684 |
+
"--project",
|
| 685 |
+
required=True,
|
| 686 |
+
help="Project name",
|
| 687 |
+
)
|
| 688 |
+
list_artifacts_parser.add_argument(
|
| 689 |
+
"--json",
|
| 690 |
+
action="store_true",
|
| 691 |
+
help="Output in JSON format",
|
| 692 |
+
)
|
| 693 |
+
|
| 694 |
+
get_parser = subparsers.add_parser(
|
| 695 |
+
"get",
|
| 696 |
+
help="Get project, run, or metric information",
|
| 697 |
+
)
|
| 698 |
+
get_subparsers = get_parser.add_subparsers(dest="get_type", required=True)
|
| 699 |
+
|
| 700 |
+
get_project_parser = get_subparsers.add_parser(
|
| 701 |
+
"project",
|
| 702 |
+
help="Get project summary",
|
| 703 |
+
)
|
| 704 |
+
get_project_parser.add_argument(
|
| 705 |
+
"--project",
|
| 706 |
+
required=True,
|
| 707 |
+
help="Project name",
|
| 708 |
+
)
|
| 709 |
+
get_project_parser.add_argument(
|
| 710 |
+
"--json",
|
| 711 |
+
action="store_true",
|
| 712 |
+
help="Output in JSON format",
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
get_run_parser = get_subparsers.add_parser(
|
| 716 |
+
"run",
|
| 717 |
+
help="Get run summary",
|
| 718 |
+
)
|
| 719 |
+
get_run_parser.add_argument(
|
| 720 |
+
"--project",
|
| 721 |
+
required=True,
|
| 722 |
+
help="Project name",
|
| 723 |
+
)
|
| 724 |
+
get_run_parser.add_argument(
|
| 725 |
+
"--run",
|
| 726 |
+
required=True,
|
| 727 |
+
help="Run name",
|
| 728 |
+
)
|
| 729 |
+
get_run_parser.add_argument(
|
| 730 |
+
"--json",
|
| 731 |
+
action="store_true",
|
| 732 |
+
help="Output in JSON format",
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
get_artifact_parser = get_subparsers.add_parser(
|
| 736 |
+
"artifact",
|
| 737 |
+
help="Get an artifact version (manifest, aliases, metadata)",
|
| 738 |
+
)
|
| 739 |
+
get_artifact_parser.add_argument(
|
| 740 |
+
"--project",
|
| 741 |
+
required=True,
|
| 742 |
+
help="Project name",
|
| 743 |
+
)
|
| 744 |
+
get_artifact_parser.add_argument(
|
| 745 |
+
"--name",
|
| 746 |
+
required=True,
|
| 747 |
+
help="Artifact name",
|
| 748 |
+
)
|
| 749 |
+
get_artifact_parser.add_argument(
|
| 750 |
+
"--version",
|
| 751 |
+
required=False,
|
| 752 |
+
help="Version or alias to resolve (e.g. 'v2' or 'best'). Defaults to latest.",
|
| 753 |
+
)
|
| 754 |
+
get_artifact_parser.add_argument(
|
| 755 |
+
"--json",
|
| 756 |
+
action="store_true",
|
| 757 |
+
help="Output in JSON format",
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
get_metric_parser = get_subparsers.add_parser(
|
| 761 |
+
"metric",
|
| 762 |
+
help="Get metric values for a run",
|
| 763 |
+
)
|
| 764 |
+
get_metric_parser.add_argument(
|
| 765 |
+
"--project",
|
| 766 |
+
required=True,
|
| 767 |
+
help="Project name",
|
| 768 |
+
)
|
| 769 |
+
get_metric_parser.add_argument(
|
| 770 |
+
"--run",
|
| 771 |
+
required=True,
|
| 772 |
+
help="Run name",
|
| 773 |
+
)
|
| 774 |
+
get_metric_parser.add_argument(
|
| 775 |
+
"--metric",
|
| 776 |
+
required=True,
|
| 777 |
+
help="Metric name",
|
| 778 |
+
)
|
| 779 |
+
get_metric_parser.add_argument(
|
| 780 |
+
"--step",
|
| 781 |
+
type=int,
|
| 782 |
+
required=False,
|
| 783 |
+
help="Get metric at exactly this step",
|
| 784 |
+
)
|
| 785 |
+
get_metric_parser.add_argument(
|
| 786 |
+
"--around",
|
| 787 |
+
type=int,
|
| 788 |
+
required=False,
|
| 789 |
+
help="Get metrics around this step (use with --window)",
|
| 790 |
+
)
|
| 791 |
+
get_metric_parser.add_argument(
|
| 792 |
+
"--at-time",
|
| 793 |
+
required=False,
|
| 794 |
+
help="Get metrics around this ISO 8601 timestamp (use with --window)",
|
| 795 |
+
)
|
| 796 |
+
get_metric_parser.add_argument(
|
| 797 |
+
"--window",
|
| 798 |
+
type=int,
|
| 799 |
+
required=False,
|
| 800 |
+
default=10,
|
| 801 |
+
help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)",
|
| 802 |
+
)
|
| 803 |
+
get_metric_parser.add_argument(
|
| 804 |
+
"--json",
|
| 805 |
+
action="store_true",
|
| 806 |
+
help="Output in JSON format",
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
get_snapshot_parser = get_subparsers.add_parser(
|
| 810 |
+
"snapshot",
|
| 811 |
+
help="Get all metrics at/around a step or timestamp",
|
| 812 |
+
)
|
| 813 |
+
get_snapshot_parser.add_argument(
|
| 814 |
+
"--project",
|
| 815 |
+
required=True,
|
| 816 |
+
help="Project name",
|
| 817 |
+
)
|
| 818 |
+
get_snapshot_parser.add_argument(
|
| 819 |
+
"--run",
|
| 820 |
+
required=True,
|
| 821 |
+
help="Run name",
|
| 822 |
+
)
|
| 823 |
+
get_snapshot_parser.add_argument(
|
| 824 |
+
"--step",
|
| 825 |
+
type=int,
|
| 826 |
+
required=False,
|
| 827 |
+
help="Get all metrics at exactly this step",
|
| 828 |
+
)
|
| 829 |
+
get_snapshot_parser.add_argument(
|
| 830 |
+
"--around",
|
| 831 |
+
type=int,
|
| 832 |
+
required=False,
|
| 833 |
+
help="Get all metrics around this step (use with --window)",
|
| 834 |
+
)
|
| 835 |
+
get_snapshot_parser.add_argument(
|
| 836 |
+
"--at-time",
|
| 837 |
+
required=False,
|
| 838 |
+
help="Get all metrics around this ISO 8601 timestamp (use with --window)",
|
| 839 |
+
)
|
| 840 |
+
get_snapshot_parser.add_argument(
|
| 841 |
+
"--window",
|
| 842 |
+
type=int,
|
| 843 |
+
required=False,
|
| 844 |
+
default=10,
|
| 845 |
+
help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)",
|
| 846 |
+
)
|
| 847 |
+
get_snapshot_parser.add_argument(
|
| 848 |
+
"--json",
|
| 849 |
+
action="store_true",
|
| 850 |
+
help="Output in JSON format",
|
| 851 |
+
)
|
| 852 |
+
|
| 853 |
+
get_system_metric_parser = get_subparsers.add_parser(
|
| 854 |
+
"system-metric",
|
| 855 |
+
help="Get system metric values for a run",
|
| 856 |
+
)
|
| 857 |
+
get_system_metric_parser.add_argument(
|
| 858 |
+
"--project",
|
| 859 |
+
required=True,
|
| 860 |
+
help="Project name",
|
| 861 |
+
)
|
| 862 |
+
get_system_metric_parser.add_argument(
|
| 863 |
+
"--run",
|
| 864 |
+
required=True,
|
| 865 |
+
help="Run name",
|
| 866 |
+
)
|
| 867 |
+
get_system_metric_parser.add_argument(
|
| 868 |
+
"--metric",
|
| 869 |
+
required=False,
|
| 870 |
+
help="System metric name (optional, if not provided returns all system metrics)",
|
| 871 |
+
)
|
| 872 |
+
get_system_metric_parser.add_argument(
|
| 873 |
+
"--json",
|
| 874 |
+
action="store_true",
|
| 875 |
+
help="Output in JSON format",
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
get_alerts_parser = get_subparsers.add_parser(
|
| 879 |
+
"alerts",
|
| 880 |
+
help="Get alerts for a project or run",
|
| 881 |
+
)
|
| 882 |
+
get_alerts_parser.add_argument(
|
| 883 |
+
"--project",
|
| 884 |
+
required=True,
|
| 885 |
+
help="Project name",
|
| 886 |
+
)
|
| 887 |
+
get_alerts_parser.add_argument(
|
| 888 |
+
"--run",
|
| 889 |
+
required=False,
|
| 890 |
+
help="Run name (optional)",
|
| 891 |
+
)
|
| 892 |
+
get_alerts_parser.add_argument(
|
| 893 |
+
"--level",
|
| 894 |
+
required=False,
|
| 895 |
+
help="Filter by alert level (info, warn, error)",
|
| 896 |
+
)
|
| 897 |
+
get_alerts_parser.add_argument(
|
| 898 |
+
"--json",
|
| 899 |
+
action="store_true",
|
| 900 |
+
help="Output in JSON format",
|
| 901 |
+
)
|
| 902 |
+
get_alerts_parser.add_argument(
|
| 903 |
+
"--since",
|
| 904 |
+
required=False,
|
| 905 |
+
help="Only show alerts after this ISO 8601 timestamp",
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
get_report_parser = get_subparsers.add_parser(
|
| 909 |
+
"report",
|
| 910 |
+
help="Get markdown report entries for a run",
|
| 911 |
+
)
|
| 912 |
+
get_report_parser.add_argument(
|
| 913 |
+
"--project",
|
| 914 |
+
required=True,
|
| 915 |
+
help="Project name",
|
| 916 |
+
)
|
| 917 |
+
get_report_parser.add_argument(
|
| 918 |
+
"--run",
|
| 919 |
+
required=True,
|
| 920 |
+
help="Run name",
|
| 921 |
+
)
|
| 922 |
+
get_report_parser.add_argument(
|
| 923 |
+
"--report",
|
| 924 |
+
required=True,
|
| 925 |
+
help="Report metric name",
|
| 926 |
+
)
|
| 927 |
+
get_report_parser.add_argument(
|
| 928 |
+
"--json",
|
| 929 |
+
action="store_true",
|
| 930 |
+
help="Output in JSON format",
|
| 931 |
+
)
|
| 932 |
+
|
| 933 |
+
query_parser = subparsers.add_parser(
|
| 934 |
+
"query",
|
| 935 |
+
help="Run a read-only SQL query against a project database",
|
| 936 |
+
)
|
| 937 |
+
query_subparsers = query_parser.add_subparsers(dest="query_type", required=True)
|
| 938 |
+
query_project_parser = query_subparsers.add_parser(
|
| 939 |
+
"project",
|
| 940 |
+
help="Run a read-only SQL query against a project's SQLite database",
|
| 941 |
+
)
|
| 942 |
+
query_project_parser.add_argument(
|
| 943 |
+
"--project",
|
| 944 |
+
required=True,
|
| 945 |
+
help="Project name",
|
| 946 |
+
)
|
| 947 |
+
query_project_parser.add_argument(
|
| 948 |
+
"--sql",
|
| 949 |
+
required=True,
|
| 950 |
+
help="Read-only SQL query to execute",
|
| 951 |
+
)
|
| 952 |
+
query_project_parser.add_argument(
|
| 953 |
+
"--json",
|
| 954 |
+
action="store_true",
|
| 955 |
+
help="Output in JSON format",
|
| 956 |
+
)
|
| 957 |
+
|
| 958 |
+
skills_parser = subparsers.add_parser(
|
| 959 |
+
"skills",
|
| 960 |
+
help="Manage Trackio skills for AI coding assistants",
|
| 961 |
+
)
|
| 962 |
+
skills_subparsers = skills_parser.add_subparsers(
|
| 963 |
+
dest="skills_action", required=True
|
| 964 |
+
)
|
| 965 |
+
skills_add_parser = skills_subparsers.add_parser(
|
| 966 |
+
"add",
|
| 967 |
+
help=(
|
| 968 |
+
"Install the Trackio skill to the central .agents/skills location; "
|
| 969 |
+
"pass agent flags to also symlink it for specific assistants"
|
| 970 |
+
),
|
| 971 |
+
)
|
| 972 |
+
skills_add_parser.add_argument(
|
| 973 |
+
"--cursor",
|
| 974 |
+
action="store_true",
|
| 975 |
+
help="Install for Cursor",
|
| 976 |
+
)
|
| 977 |
+
skills_add_parser.add_argument(
|
| 978 |
+
"--claude",
|
| 979 |
+
action="store_true",
|
| 980 |
+
help="Install for Claude Code",
|
| 981 |
+
)
|
| 982 |
+
skills_add_parser.add_argument(
|
| 983 |
+
"--codex",
|
| 984 |
+
action="store_true",
|
| 985 |
+
help="Install for Codex",
|
| 986 |
+
)
|
| 987 |
+
skills_add_parser.add_argument(
|
| 988 |
+
"--opencode",
|
| 989 |
+
action="store_true",
|
| 990 |
+
help="Install for OpenCode",
|
| 991 |
+
)
|
| 992 |
+
skills_add_parser.add_argument(
|
| 993 |
+
"--pi",
|
| 994 |
+
action="store_true",
|
| 995 |
+
help="Install for pi",
|
| 996 |
+
)
|
| 997 |
+
skills_add_parser.add_argument(
|
| 998 |
+
"--global",
|
| 999 |
+
dest="global_",
|
| 1000 |
+
action="store_true",
|
| 1001 |
+
help="Install globally (user-level) instead of in the current project directory",
|
| 1002 |
+
)
|
| 1003 |
+
skills_add_parser.add_argument(
|
| 1004 |
+
"--dest",
|
| 1005 |
+
type=str,
|
| 1006 |
+
required=False,
|
| 1007 |
+
help="Install into a custom destination (path to skills directory)",
|
| 1008 |
+
)
|
| 1009 |
+
skills_add_parser.add_argument(
|
| 1010 |
+
"--force",
|
| 1011 |
+
action="store_true",
|
| 1012 |
+
help="Overwrite existing skill if it already exists",
|
| 1013 |
+
)
|
| 1014 |
+
skills_add_parser.add_argument(
|
| 1015 |
+
"--no-command",
|
| 1016 |
+
dest="no_command",
|
| 1017 |
+
action="store_true",
|
| 1018 |
+
help="Do not install the /logbook slash command alongside the skill",
|
| 1019 |
+
)
|
| 1020 |
+
skills_add_parser.add_argument(
|
| 1021 |
+
"--no-hook",
|
| 1022 |
+
dest="no_hook",
|
| 1023 |
+
action="store_true",
|
| 1024 |
+
help="Do not install the Claude Code hook that mirrors todos into the logbook",
|
| 1025 |
+
)
|
| 1026 |
+
|
| 1027 |
+
logbook_parser = subparsers.add_parser(
|
| 1028 |
+
"logbook",
|
| 1029 |
+
help="Create and publish a shareable experiment logbook",
|
| 1030 |
+
)
|
| 1031 |
+
logbook_sub = logbook_parser.add_subparsers(
|
| 1032 |
+
dest="logbook_action",
|
| 1033 |
+
required=True,
|
| 1034 |
+
metavar="{open,cell,run,page,read,serve,publish,sync}",
|
| 1035 |
+
)
|
| 1036 |
+
|
| 1037 |
+
lb_open = logbook_sub.add_parser(
|
| 1038 |
+
"open", help="Start or attach to the logbook in this directory"
|
| 1039 |
+
)
|
| 1040 |
+
lb_open.add_argument(
|
| 1041 |
+
"space_id", nargs="?", help="Optional HF Space id to publish to"
|
| 1042 |
+
)
|
| 1043 |
+
lb_open.add_argument("--title", help="Logbook title (only when creating)")
|
| 1044 |
+
lb_open.add_argument(
|
| 1045 |
+
"--no-serve",
|
| 1046 |
+
action="store_true",
|
| 1047 |
+
help="Open or attach without launching the local logbook preview",
|
| 1048 |
+
)
|
| 1049 |
+
lb_open.add_argument("--port", type=int, default=7861)
|
| 1050 |
+
lb_open.add_argument("--no-browser", action="store_true")
|
| 1051 |
+
|
| 1052 |
+
lb_cell = logbook_sub.add_parser(
|
| 1053 |
+
"cell", help="Append a typed notebook-style cell to a logbook page"
|
| 1054 |
+
)
|
| 1055 |
+
lb_cell_sub = lb_cell.add_subparsers(dest="cell_type", required=True)
|
| 1056 |
+
|
| 1057 |
+
lb_cell_md = lb_cell_sub.add_parser("markdown", help="Append a markdown cell")
|
| 1058 |
+
lb_cell_md.add_argument(
|
| 1059 |
+
"body",
|
| 1060 |
+
help="Markdown body (literal \\n escape sequences are converted to line breaks)",
|
| 1061 |
+
)
|
| 1062 |
+
lb_cell_md.add_argument("--title", help="Cell title")
|
| 1063 |
+
lb_cell_md.add_argument("--page", help="Page title or slug")
|
| 1064 |
+
lb_cell_art = lb_cell_sub.add_parser(
|
| 1065 |
+
"artifact", help="Append an artifact cell referencing a Trackio artifact"
|
| 1066 |
+
)
|
| 1067 |
+
lb_cell_art.add_argument("name", help="Artifact reference project/name:vN")
|
| 1068 |
+
lb_cell_art.add_argument("--title", help="Cell title")
|
| 1069 |
+
lb_cell_art.add_argument("--page", help="Page title or slug")
|
| 1070 |
+
lb_cell_art.add_argument(
|
| 1071 |
+
"--type",
|
| 1072 |
+
dest="artifact_type",
|
| 1073 |
+
help='Artifact type, e.g. "dataset" or "model"',
|
| 1074 |
+
)
|
| 1075 |
+
|
| 1076 |
+
lb_cell_code = lb_cell_sub.add_parser("code", help="Append a code cell")
|
| 1077 |
+
lb_cell_code.add_argument("--title", help="Cell title")
|
| 1078 |
+
lb_cell_code.add_argument("--page", help="Page title or slug")
|
| 1079 |
+
lb_cell_code.add_argument(
|
| 1080 |
+
"--code", action="append", default=[], help="Path to code/config to include"
|
| 1081 |
+
)
|
| 1082 |
+
lb_cell_code.add_argument("--code-text", help="Inline code to include")
|
| 1083 |
+
lb_cell_code.add_argument("--language", help="Language for --code-text")
|
| 1084 |
+
lb_cell_code.add_argument("--output", required=True, help="Output text")
|
| 1085 |
+
|
| 1086 |
+
lb_cell_figure = lb_cell_sub.add_parser("figure", help="Append a figure cell")
|
| 1087 |
+
lb_cell_figure.add_argument("--title", help="Cell title")
|
| 1088 |
+
lb_cell_figure.add_argument("--page", help="Page title or slug")
|
| 1089 |
+
lb_cell_figure.add_argument(
|
| 1090 |
+
"--html", help="Path to an HTML or image file, or inline HTML text"
|
| 1091 |
+
)
|
| 1092 |
+
lb_cell_figure.add_argument("--html-text", help="Inline HTML text")
|
| 1093 |
+
lb_cell_figure.add_argument(
|
| 1094 |
+
"--image", help="Path to an image file (PNG, JPG, GIF, WEBP, SVG, ...)"
|
| 1095 |
+
)
|
| 1096 |
+
lb_cell_figure.add_argument("--raw", help="Path/URL/text for raw data")
|
| 1097 |
+
lb_cell_figure.add_argument("--raw-text", help="Inline raw data")
|
| 1098 |
+
|
| 1099 |
+
lb_cell_dash = lb_cell_sub.add_parser(
|
| 1100 |
+
"dashboard", help="Embed a Trackio dashboard for a project"
|
| 1101 |
+
)
|
| 1102 |
+
lb_cell_dash.add_argument("project", help="Trackio project name")
|
| 1103 |
+
lb_cell_dash.add_argument(
|
| 1104 |
+
"--space",
|
| 1105 |
+
dest="space_id",
|
| 1106 |
+
help="HF Space id (owner/name) hosting the dashboard",
|
| 1107 |
+
)
|
| 1108 |
+
lb_cell_dash.add_argument("--title", help="Cell title")
|
| 1109 |
+
lb_cell_dash.add_argument("--page", help="Page title or slug")
|
| 1110 |
+
|
| 1111 |
+
lb_run = logbook_sub.add_parser(
|
| 1112 |
+
"run",
|
| 1113 |
+
help="Run a command; log the command, its scripts, and output to a page",
|
| 1114 |
+
)
|
| 1115 |
+
lb_run.add_argument("--page", help="Page title or slug")
|
| 1116 |
+
lb_run.add_argument("--title", help="Cell title")
|
| 1117 |
+
lb_run.add_argument(
|
| 1118 |
+
"--no-artifacts",
|
| 1119 |
+
action="store_true",
|
| 1120 |
+
help="Do not record output model/data files as artifact cells",
|
| 1121 |
+
)
|
| 1122 |
+
lb_run.add_argument("command", nargs="*")
|
| 1123 |
+
|
| 1124 |
+
lb_page = logbook_sub.add_parser(
|
| 1125 |
+
"page", help="Create or select a page and make it the default target"
|
| 1126 |
+
)
|
| 1127 |
+
lb_page.add_argument("title", help="Page title")
|
| 1128 |
+
|
| 1129 |
+
lb_read = logbook_sub.add_parser(
|
| 1130 |
+
"read", help="Read logbook pages/cells in an agent-friendly form"
|
| 1131 |
+
)
|
| 1132 |
+
lb_read.add_argument(
|
| 1133 |
+
"--path",
|
| 1134 |
+
help=(
|
| 1135 |
+
"Logbook to read: local path, HF Space id, or URL "
|
| 1136 |
+
"(can also be passed positionally: trackio logbook read <source>)"
|
| 1137 |
+
),
|
| 1138 |
+
)
|
| 1139 |
+
lb_read.add_argument("--json", action="store_true", help="Output JSON")
|
| 1140 |
+
lb_read.add_argument(
|
| 1141 |
+
"--head",
|
| 1142 |
+
type=int,
|
| 1143 |
+
default=None,
|
| 1144 |
+
help="Lines of code shown per code cell (default 3; 0 hides code)",
|
| 1145 |
+
)
|
| 1146 |
+
lb_read.add_argument(
|
| 1147 |
+
"--tail",
|
| 1148 |
+
type=int,
|
| 1149 |
+
default=None,
|
| 1150 |
+
help="Lines of output shown per code cell (default 3; 0 hides output)",
|
| 1151 |
+
)
|
| 1152 |
+
lb_read.add_argument(
|
| 1153 |
+
"--raw-limit",
|
| 1154 |
+
type=int,
|
| 1155 |
+
default=None,
|
| 1156 |
+
help="Inline figure raw data up to this many chars (default 500; 0 disables)",
|
| 1157 |
+
)
|
| 1158 |
+
lb_read_sub = lb_read.add_subparsers(dest="read_target")
|
| 1159 |
+
lb_read_pages = lb_read_sub.add_parser("pages", help="List logbook pages")
|
| 1160 |
+
lb_read_pages.add_argument("--json", action="store_true", help="Output JSON")
|
| 1161 |
+
lb_read_page = lb_read_sub.add_parser("page", help="Read a page for agents")
|
| 1162 |
+
lb_read_page.add_argument("page", nargs="?", help="Page title or slug")
|
| 1163 |
+
lb_read_page.add_argument("--json", action="store_true", help="Output JSON")
|
| 1164 |
+
lb_read_page.add_argument(
|
| 1165 |
+
"--head", type=int, default=argparse.SUPPRESS, help="Code lines per code cell"
|
| 1166 |
+
)
|
| 1167 |
+
lb_read_page.add_argument(
|
| 1168 |
+
"--tail", type=int, default=argparse.SUPPRESS, help="Output lines per code cell"
|
| 1169 |
+
)
|
| 1170 |
+
lb_read_page.add_argument(
|
| 1171 |
+
"--raw-limit",
|
| 1172 |
+
type=int,
|
| 1173 |
+
default=argparse.SUPPRESS,
|
| 1174 |
+
help="Inline figure raw data up to this many chars",
|
| 1175 |
+
)
|
| 1176 |
+
lb_read_cell = lb_read_sub.add_parser("cell", help="Read one cell by id")
|
| 1177 |
+
lb_read_cell.add_argument("cell_id", help="Cell id")
|
| 1178 |
+
lb_read_cell.add_argument("--json", action="store_true", help="Output JSON")
|
| 1179 |
+
lb_read_cell.add_argument("--full", action="store_true", help="Include full body")
|
| 1180 |
+
lb_read_cell.add_argument(
|
| 1181 |
+
"--raw", action="store_true", help="Include figure raw data"
|
| 1182 |
+
)
|
| 1183 |
+
lb_read_cell.add_argument("--html", action="store_true", help="Include figure HTML")
|
| 1184 |
+
|
| 1185 |
+
lb_serve = logbook_sub.add_parser("serve", help="Preview the logbook locally")
|
| 1186 |
+
lb_serve.add_argument("path", nargs="?", help="Logbook workspace/path to serve")
|
| 1187 |
+
lb_serve.add_argument("--port", type=int, default=7861)
|
| 1188 |
+
lb_serve.add_argument("--no-browser", action="store_true")
|
| 1189 |
+
|
| 1190 |
+
lb_pub = logbook_sub.add_parser(
|
| 1191 |
+
"publish", help="Publish to a static HF Space (first publish enables auto-sync)"
|
| 1192 |
+
)
|
| 1193 |
+
lb_pub.add_argument("space_id", nargs="?", help="HF Space id (username/space)")
|
| 1194 |
+
lb_pub.add_argument(
|
| 1195 |
+
"--private",
|
| 1196 |
+
action="store_true",
|
| 1197 |
+
help="Make the logbook and any promoted dashboards/buckets private",
|
| 1198 |
+
)
|
| 1199 |
+
|
| 1200 |
+
lb_pin = logbook_sub.add_parser(
|
| 1201 |
+
"pin",
|
| 1202 |
+
help="Pin (or unpin) a cell so it surfaces on the logbook intro",
|
| 1203 |
+
)
|
| 1204 |
+
lb_pin.add_argument(
|
| 1205 |
+
"cell_id",
|
| 1206 |
+
nargs="?",
|
| 1207 |
+
help="Cell id to pin (default: the most recent cell on the target page)",
|
| 1208 |
+
)
|
| 1209 |
+
lb_pin.add_argument(
|
| 1210 |
+
"--page",
|
| 1211 |
+
help="Page title or slug to scope the search / pick the last cell from",
|
| 1212 |
+
)
|
| 1213 |
+
lb_pin.add_argument(
|
| 1214 |
+
"--unpin", action="store_true", help="Unpin the cell instead of pinning it"
|
| 1215 |
+
)
|
| 1216 |
+
|
| 1217 |
+
logbook_sub.add_parser(
|
| 1218 |
+
"sync", help="Push local edits to the Space now (after the first publish)"
|
| 1219 |
+
)
|
| 1220 |
+
|
| 1221 |
+
logbook_sub.add_parser("_sync")
|
| 1222 |
+
logbook_sub.add_parser("sync-todos")
|
| 1223 |
+
|
| 1224 |
+
args, unknown_args = parser.parse_known_args()
|
| 1225 |
+
if unknown_args:
|
| 1226 |
+
trailing_global_parser = argparse.ArgumentParser(add_help=False)
|
| 1227 |
+
trailing_global_parser.add_argument("--space", required=False)
|
| 1228 |
+
trailing_global_parser.add_argument("--hf-token", required=False)
|
| 1229 |
+
trailing_globals, remaining_unknown = trailing_global_parser.parse_known_args(
|
| 1230 |
+
unknown_args
|
| 1231 |
+
)
|
| 1232 |
+
if remaining_unknown:
|
| 1233 |
+
parser.error(f"unrecognized arguments: {' '.join(remaining_unknown)}")
|
| 1234 |
+
if trailing_globals.space is not None:
|
| 1235 |
+
args.space = trailing_globals.space
|
| 1236 |
+
if trailing_globals.hf_token is not None:
|
| 1237 |
+
args.hf_token = trailing_globals.hf_token
|
| 1238 |
+
|
| 1239 |
+
if args.command in ("show", "status", "sync", "freeze", "skills") and _get_space(
|
| 1240 |
+
args
|
| 1241 |
+
):
|
| 1242 |
+
error_exit(
|
| 1243 |
+
f"The '{args.command}' command does not support --space (remote mode)."
|
| 1244 |
+
)
|
| 1245 |
+
|
| 1246 |
+
if args.command == "show":
|
| 1247 |
+
color_palette = None
|
| 1248 |
+
if args.color_palette:
|
| 1249 |
+
color_palette = [color.strip() for color in args.color_palette.split(",")]
|
| 1250 |
+
show(
|
| 1251 |
+
project=args.project,
|
| 1252 |
+
theme=args.theme,
|
| 1253 |
+
mcp_server=args.mcp_server,
|
| 1254 |
+
footer=args.footer,
|
| 1255 |
+
color_palette=color_palette,
|
| 1256 |
+
host=args.host,
|
| 1257 |
+
frontend_dir=args.frontend,
|
| 1258 |
+
)
|
| 1259 |
+
elif args.command == "status":
|
| 1260 |
+
_handle_status()
|
| 1261 |
+
elif args.command == "sync":
|
| 1262 |
+
_handle_sync(args)
|
| 1263 |
+
elif args.command == "freeze":
|
| 1264 |
+
freeze(
|
| 1265 |
+
space_id=args.space_id,
|
| 1266 |
+
project=args.project,
|
| 1267 |
+
new_space_id=args.new_space_id,
|
| 1268 |
+
private=args.private,
|
| 1269 |
+
frontend_dir=args.frontend,
|
| 1270 |
+
)
|
| 1271 |
+
elif args.command == "config":
|
| 1272 |
+
_handle_config(args)
|
| 1273 |
+
elif args.command == "list":
|
| 1274 |
+
if args.list_type == "spaces":
|
| 1275 |
+
_handle_list_spaces(args)
|
| 1276 |
+
return
|
| 1277 |
+
|
| 1278 |
+
remote = _get_remote(args)
|
| 1279 |
+
if args.list_type == "projects":
|
| 1280 |
+
if remote:
|
| 1281 |
+
projects = remote.predict(api_name="/get_all_projects")
|
| 1282 |
+
else:
|
| 1283 |
+
projects = SQLiteStorage.get_projects()
|
| 1284 |
+
if args.json:
|
| 1285 |
+
print(format_json({"projects": projects}))
|
| 1286 |
+
else:
|
| 1287 |
+
print(format_list(projects, "Projects"))
|
| 1288 |
+
elif args.list_type == "runs":
|
| 1289 |
+
if remote:
|
| 1290 |
+
run_records = remote.predict(
|
| 1291 |
+
args.project, api_name="/get_runs_for_project"
|
| 1292 |
+
)
|
| 1293 |
+
runs = [r["name"] if isinstance(r, dict) else r for r in run_records]
|
| 1294 |
+
else:
|
| 1295 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1296 |
+
if not db_path.exists():
|
| 1297 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1298 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1299 |
+
if args.json:
|
| 1300 |
+
print(format_json({"project": args.project, "runs": runs}))
|
| 1301 |
+
else:
|
| 1302 |
+
print(format_list(runs, f"Runs in '{args.project}'"))
|
| 1303 |
+
elif args.list_type == "metrics":
|
| 1304 |
+
if remote:
|
| 1305 |
+
metrics = remote.predict(
|
| 1306 |
+
args.project, args.run, api_name="/get_metrics_for_run"
|
| 1307 |
+
)
|
| 1308 |
+
else:
|
| 1309 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1310 |
+
if not db_path.exists():
|
| 1311 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1312 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1313 |
+
if args.run not in runs:
|
| 1314 |
+
error_exit(
|
| 1315 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1316 |
+
)
|
| 1317 |
+
metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run)
|
| 1318 |
+
if args.json:
|
| 1319 |
+
print(
|
| 1320 |
+
format_json(
|
| 1321 |
+
{"project": args.project, "run": args.run, "metrics": metrics}
|
| 1322 |
+
)
|
| 1323 |
+
)
|
| 1324 |
+
else:
|
| 1325 |
+
print(
|
| 1326 |
+
format_list(
|
| 1327 |
+
metrics, f"Metrics for '{args.run}' in '{args.project}'"
|
| 1328 |
+
)
|
| 1329 |
+
)
|
| 1330 |
+
elif args.list_type == "system-metrics":
|
| 1331 |
+
if remote:
|
| 1332 |
+
system_metrics = remote.predict(
|
| 1333 |
+
args.project, args.run, api_name="/get_system_metrics_for_run"
|
| 1334 |
+
)
|
| 1335 |
+
else:
|
| 1336 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1337 |
+
if not db_path.exists():
|
| 1338 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1339 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1340 |
+
if args.run not in runs:
|
| 1341 |
+
error_exit(
|
| 1342 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1343 |
+
)
|
| 1344 |
+
system_metrics = SQLiteStorage.get_all_system_metrics_for_run(
|
| 1345 |
+
args.project, args.run
|
| 1346 |
+
)
|
| 1347 |
+
if args.json:
|
| 1348 |
+
print(
|
| 1349 |
+
format_json(
|
| 1350 |
+
{
|
| 1351 |
+
"project": args.project,
|
| 1352 |
+
"run": args.run,
|
| 1353 |
+
"system_metrics": system_metrics,
|
| 1354 |
+
}
|
| 1355 |
+
)
|
| 1356 |
+
)
|
| 1357 |
+
else:
|
| 1358 |
+
print(format_system_metric_names(system_metrics))
|
| 1359 |
+
elif args.list_type == "alerts":
|
| 1360 |
+
if remote:
|
| 1361 |
+
alerts = remote.predict(
|
| 1362 |
+
args.project,
|
| 1363 |
+
args.run,
|
| 1364 |
+
args.level,
|
| 1365 |
+
args.since,
|
| 1366 |
+
api_name="/get_alerts",
|
| 1367 |
+
)
|
| 1368 |
+
else:
|
| 1369 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1370 |
+
if not db_path.exists():
|
| 1371 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1372 |
+
alerts = SQLiteStorage.get_alerts(
|
| 1373 |
+
args.project,
|
| 1374 |
+
run_name=args.run,
|
| 1375 |
+
level=args.level,
|
| 1376 |
+
since=args.since,
|
| 1377 |
+
)
|
| 1378 |
+
if args.json:
|
| 1379 |
+
print(
|
| 1380 |
+
format_json(
|
| 1381 |
+
{
|
| 1382 |
+
"project": args.project,
|
| 1383 |
+
"run": args.run,
|
| 1384 |
+
"level": args.level,
|
| 1385 |
+
"since": args.since,
|
| 1386 |
+
"alerts": alerts,
|
| 1387 |
+
}
|
| 1388 |
+
)
|
| 1389 |
+
)
|
| 1390 |
+
else:
|
| 1391 |
+
print(format_alerts(alerts))
|
| 1392 |
+
elif args.list_type == "reports":
|
| 1393 |
+
if remote:
|
| 1394 |
+
run_records = remote.predict(
|
| 1395 |
+
args.project, api_name="/get_runs_for_project"
|
| 1396 |
+
)
|
| 1397 |
+
runs = [r["name"] if isinstance(r, dict) else r for r in run_records]
|
| 1398 |
+
else:
|
| 1399 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1400 |
+
if not db_path.exists():
|
| 1401 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1402 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1403 |
+
if args.run and args.run not in runs:
|
| 1404 |
+
error_exit(f"Run '{args.run}' not found in project '{args.project}'.")
|
| 1405 |
+
|
| 1406 |
+
target_runs = [args.run] if args.run else runs
|
| 1407 |
+
all_reports = []
|
| 1408 |
+
for run_name in target_runs:
|
| 1409 |
+
if remote:
|
| 1410 |
+
logs = remote.predict(args.project, run_name, api_name="/get_logs")
|
| 1411 |
+
else:
|
| 1412 |
+
logs = SQLiteStorage.get_logs(args.project, run_name)
|
| 1413 |
+
all_reports.extend(_extract_reports(run_name, logs))
|
| 1414 |
+
|
| 1415 |
+
if args.json:
|
| 1416 |
+
print(
|
| 1417 |
+
format_json(
|
| 1418 |
+
{
|
| 1419 |
+
"project": args.project,
|
| 1420 |
+
"run": args.run,
|
| 1421 |
+
"reports": all_reports,
|
| 1422 |
+
}
|
| 1423 |
+
)
|
| 1424 |
+
)
|
| 1425 |
+
else:
|
| 1426 |
+
report_lines = [
|
| 1427 |
+
f"{entry['run']} | {entry['report']} | step={entry['step']} | {entry['timestamp']}"
|
| 1428 |
+
for entry in all_reports
|
| 1429 |
+
]
|
| 1430 |
+
if args.run:
|
| 1431 |
+
print(
|
| 1432 |
+
format_list(
|
| 1433 |
+
report_lines,
|
| 1434 |
+
f"Reports for '{args.run}' in '{args.project}'",
|
| 1435 |
+
)
|
| 1436 |
+
)
|
| 1437 |
+
else:
|
| 1438 |
+
print(format_list(report_lines, f"Reports in '{args.project}'"))
|
| 1439 |
+
elif args.list_type == "artifacts":
|
| 1440 |
+
if remote:
|
| 1441 |
+
artifacts = remote.predict(args.project, api_name="/get_artifacts")
|
| 1442 |
+
else:
|
| 1443 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1444 |
+
if not db_path.exists():
|
| 1445 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1446 |
+
artifacts = SQLiteStorage.get_artifacts(args.project)
|
| 1447 |
+
if args.json:
|
| 1448 |
+
print(format_json({"project": args.project, "artifacts": artifacts}))
|
| 1449 |
+
else:
|
| 1450 |
+
print(format_artifacts(artifacts, args.project))
|
| 1451 |
+
elif args.command == "get":
|
| 1452 |
+
remote = _get_remote(args)
|
| 1453 |
+
if args.get_type == "artifact":
|
| 1454 |
+
if remote:
|
| 1455 |
+
record = remote.predict(
|
| 1456 |
+
args.project,
|
| 1457 |
+
args.name,
|
| 1458 |
+
args.version,
|
| 1459 |
+
api_name="/get_artifact_manifest",
|
| 1460 |
+
)
|
| 1461 |
+
else:
|
| 1462 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1463 |
+
if not db_path.exists():
|
| 1464 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1465 |
+
record = SQLiteStorage.get_artifact_manifest(
|
| 1466 |
+
args.project, args.name, args.version
|
| 1467 |
+
)
|
| 1468 |
+
if record is None:
|
| 1469 |
+
spec = f":{args.version}" if args.version else ""
|
| 1470 |
+
error_exit(
|
| 1471 |
+
f"Artifact '{args.name}{spec}' not found in project "
|
| 1472 |
+
f"'{args.project}'."
|
| 1473 |
+
)
|
| 1474 |
+
if args.json:
|
| 1475 |
+
print(format_json(record))
|
| 1476 |
+
else:
|
| 1477 |
+
print(format_artifact(record))
|
| 1478 |
+
elif args.get_type == "project":
|
| 1479 |
+
if remote:
|
| 1480 |
+
summary = remote.predict(args.project, api_name="/get_project_summary")
|
| 1481 |
+
else:
|
| 1482 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1483 |
+
if not db_path.exists():
|
| 1484 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1485 |
+
summary = get_project_summary(args.project)
|
| 1486 |
+
if args.json:
|
| 1487 |
+
print(format_json(summary))
|
| 1488 |
+
else:
|
| 1489 |
+
print(format_project_summary(summary))
|
| 1490 |
+
elif args.get_type == "run":
|
| 1491 |
+
if remote:
|
| 1492 |
+
summary = remote.predict(
|
| 1493 |
+
args.project, args.run, api_name="/get_run_summary"
|
| 1494 |
+
)
|
| 1495 |
+
else:
|
| 1496 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1497 |
+
if not db_path.exists():
|
| 1498 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1499 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1500 |
+
if args.run not in runs:
|
| 1501 |
+
error_exit(
|
| 1502 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1503 |
+
)
|
| 1504 |
+
summary = get_run_summary(args.project, args.run)
|
| 1505 |
+
if args.json:
|
| 1506 |
+
print(format_json(summary))
|
| 1507 |
+
else:
|
| 1508 |
+
print(format_run_summary(summary))
|
| 1509 |
+
elif args.get_type == "metric":
|
| 1510 |
+
at_time = getattr(args, "at_time", None)
|
| 1511 |
+
if remote:
|
| 1512 |
+
values = remote.predict(
|
| 1513 |
+
args.project,
|
| 1514 |
+
args.run,
|
| 1515 |
+
args.metric,
|
| 1516 |
+
args.step,
|
| 1517 |
+
args.around,
|
| 1518 |
+
at_time,
|
| 1519 |
+
args.window,
|
| 1520 |
+
api_name="/get_metric_values",
|
| 1521 |
+
)
|
| 1522 |
+
else:
|
| 1523 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1524 |
+
if not db_path.exists():
|
| 1525 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1526 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1527 |
+
if args.run not in runs:
|
| 1528 |
+
error_exit(
|
| 1529 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1530 |
+
)
|
| 1531 |
+
metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run)
|
| 1532 |
+
if args.metric not in metrics:
|
| 1533 |
+
error_exit(
|
| 1534 |
+
f"Metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
|
| 1535 |
+
)
|
| 1536 |
+
values = SQLiteStorage.get_metric_values(
|
| 1537 |
+
args.project,
|
| 1538 |
+
args.run,
|
| 1539 |
+
args.metric,
|
| 1540 |
+
step=args.step,
|
| 1541 |
+
around_step=args.around,
|
| 1542 |
+
at_time=at_time,
|
| 1543 |
+
window=args.window,
|
| 1544 |
+
)
|
| 1545 |
+
if args.json:
|
| 1546 |
+
print(
|
| 1547 |
+
format_json(
|
| 1548 |
+
{
|
| 1549 |
+
"project": args.project,
|
| 1550 |
+
"run": args.run,
|
| 1551 |
+
"metric": args.metric,
|
| 1552 |
+
"values": values,
|
| 1553 |
+
}
|
| 1554 |
+
)
|
| 1555 |
+
)
|
| 1556 |
+
else:
|
| 1557 |
+
print(format_metric_values(values))
|
| 1558 |
+
elif args.get_type == "snapshot":
|
| 1559 |
+
if not args.step and not args.around and not getattr(args, "at_time", None):
|
| 1560 |
+
error_exit(
|
| 1561 |
+
"Provide --step, --around (with --window), or --at-time (with --window)."
|
| 1562 |
+
)
|
| 1563 |
+
at_time = getattr(args, "at_time", None)
|
| 1564 |
+
if remote:
|
| 1565 |
+
snapshot = remote.predict(
|
| 1566 |
+
args.project,
|
| 1567 |
+
args.run,
|
| 1568 |
+
args.step,
|
| 1569 |
+
args.around,
|
| 1570 |
+
at_time,
|
| 1571 |
+
args.window,
|
| 1572 |
+
api_name="/get_snapshot",
|
| 1573 |
+
)
|
| 1574 |
+
else:
|
| 1575 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1576 |
+
if not db_path.exists():
|
| 1577 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1578 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1579 |
+
if args.run not in runs:
|
| 1580 |
+
error_exit(
|
| 1581 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1582 |
+
)
|
| 1583 |
+
snapshot = SQLiteStorage.get_snapshot(
|
| 1584 |
+
args.project,
|
| 1585 |
+
args.run,
|
| 1586 |
+
step=args.step,
|
| 1587 |
+
around_step=args.around,
|
| 1588 |
+
at_time=at_time,
|
| 1589 |
+
window=args.window,
|
| 1590 |
+
)
|
| 1591 |
+
if args.json:
|
| 1592 |
+
result = {
|
| 1593 |
+
"project": args.project,
|
| 1594 |
+
"run": args.run,
|
| 1595 |
+
"metrics": snapshot,
|
| 1596 |
+
}
|
| 1597 |
+
if args.step is not None:
|
| 1598 |
+
result["step"] = args.step
|
| 1599 |
+
if args.around is not None:
|
| 1600 |
+
result["around"] = args.around
|
| 1601 |
+
result["window"] = args.window
|
| 1602 |
+
if at_time is not None:
|
| 1603 |
+
result["at_time"] = at_time
|
| 1604 |
+
result["window"] = args.window
|
| 1605 |
+
print(format_json(result))
|
| 1606 |
+
else:
|
| 1607 |
+
print(format_snapshot(snapshot))
|
| 1608 |
+
elif args.get_type == "system-metric":
|
| 1609 |
+
if remote:
|
| 1610 |
+
system_metrics = remote.predict(
|
| 1611 |
+
args.project, args.run, api_name="/get_system_logs"
|
| 1612 |
+
)
|
| 1613 |
+
if args.metric:
|
| 1614 |
+
all_system_metric_names = remote.predict(
|
| 1615 |
+
args.project,
|
| 1616 |
+
args.run,
|
| 1617 |
+
api_name="/get_system_metrics_for_run",
|
| 1618 |
+
)
|
| 1619 |
+
if args.metric not in all_system_metric_names:
|
| 1620 |
+
error_exit(
|
| 1621 |
+
f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
|
| 1622 |
+
)
|
| 1623 |
+
filtered_metrics = [
|
| 1624 |
+
{
|
| 1625 |
+
k: v
|
| 1626 |
+
for k, v in entry.items()
|
| 1627 |
+
if k == "timestamp" or k == args.metric
|
| 1628 |
+
}
|
| 1629 |
+
for entry in system_metrics
|
| 1630 |
+
if args.metric in entry
|
| 1631 |
+
]
|
| 1632 |
+
if args.json:
|
| 1633 |
+
print(
|
| 1634 |
+
format_json(
|
| 1635 |
+
{
|
| 1636 |
+
"project": args.project,
|
| 1637 |
+
"run": args.run,
|
| 1638 |
+
"metric": args.metric,
|
| 1639 |
+
"values": filtered_metrics,
|
| 1640 |
+
}
|
| 1641 |
+
)
|
| 1642 |
+
)
|
| 1643 |
+
else:
|
| 1644 |
+
print(format_system_metrics(filtered_metrics))
|
| 1645 |
+
else:
|
| 1646 |
+
if args.json:
|
| 1647 |
+
print(
|
| 1648 |
+
format_json(
|
| 1649 |
+
{
|
| 1650 |
+
"project": args.project,
|
| 1651 |
+
"run": args.run,
|
| 1652 |
+
"system_metrics": system_metrics,
|
| 1653 |
+
}
|
| 1654 |
+
)
|
| 1655 |
+
)
|
| 1656 |
+
else:
|
| 1657 |
+
print(format_system_metrics(system_metrics))
|
| 1658 |
+
else:
|
| 1659 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1660 |
+
if not db_path.exists():
|
| 1661 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1662 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1663 |
+
if args.run not in runs:
|
| 1664 |
+
error_exit(
|
| 1665 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1666 |
+
)
|
| 1667 |
+
if args.metric:
|
| 1668 |
+
system_metrics = SQLiteStorage.get_system_logs(
|
| 1669 |
+
args.project, args.run
|
| 1670 |
+
)
|
| 1671 |
+
all_system_metric_names = (
|
| 1672 |
+
SQLiteStorage.get_all_system_metrics_for_run(
|
| 1673 |
+
args.project, args.run
|
| 1674 |
+
)
|
| 1675 |
+
)
|
| 1676 |
+
if args.metric not in all_system_metric_names:
|
| 1677 |
+
error_exit(
|
| 1678 |
+
f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
|
| 1679 |
+
)
|
| 1680 |
+
filtered_metrics = [
|
| 1681 |
+
{
|
| 1682 |
+
k: v
|
| 1683 |
+
for k, v in entry.items()
|
| 1684 |
+
if k == "timestamp" or k == args.metric
|
| 1685 |
+
}
|
| 1686 |
+
for entry in system_metrics
|
| 1687 |
+
if args.metric in entry
|
| 1688 |
+
]
|
| 1689 |
+
if args.json:
|
| 1690 |
+
print(
|
| 1691 |
+
format_json(
|
| 1692 |
+
{
|
| 1693 |
+
"project": args.project,
|
| 1694 |
+
"run": args.run,
|
| 1695 |
+
"metric": args.metric,
|
| 1696 |
+
"values": filtered_metrics,
|
| 1697 |
+
}
|
| 1698 |
+
)
|
| 1699 |
+
)
|
| 1700 |
+
else:
|
| 1701 |
+
print(format_system_metrics(filtered_metrics))
|
| 1702 |
+
else:
|
| 1703 |
+
system_metrics = SQLiteStorage.get_system_logs(
|
| 1704 |
+
args.project, args.run
|
| 1705 |
+
)
|
| 1706 |
+
if args.json:
|
| 1707 |
+
print(
|
| 1708 |
+
format_json(
|
| 1709 |
+
{
|
| 1710 |
+
"project": args.project,
|
| 1711 |
+
"run": args.run,
|
| 1712 |
+
"system_metrics": system_metrics,
|
| 1713 |
+
}
|
| 1714 |
+
)
|
| 1715 |
+
)
|
| 1716 |
+
else:
|
| 1717 |
+
print(format_system_metrics(system_metrics))
|
| 1718 |
+
elif args.get_type == "alerts":
|
| 1719 |
+
if remote:
|
| 1720 |
+
alerts = remote.predict(
|
| 1721 |
+
args.project,
|
| 1722 |
+
args.run,
|
| 1723 |
+
args.level,
|
| 1724 |
+
args.since,
|
| 1725 |
+
api_name="/get_alerts",
|
| 1726 |
+
)
|
| 1727 |
+
else:
|
| 1728 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1729 |
+
if not db_path.exists():
|
| 1730 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1731 |
+
alerts = SQLiteStorage.get_alerts(
|
| 1732 |
+
args.project,
|
| 1733 |
+
run_name=args.run,
|
| 1734 |
+
level=args.level,
|
| 1735 |
+
since=args.since,
|
| 1736 |
+
)
|
| 1737 |
+
if args.json:
|
| 1738 |
+
print(
|
| 1739 |
+
format_json(
|
| 1740 |
+
{
|
| 1741 |
+
"project": args.project,
|
| 1742 |
+
"run": args.run,
|
| 1743 |
+
"level": args.level,
|
| 1744 |
+
"since": args.since,
|
| 1745 |
+
"alerts": alerts,
|
| 1746 |
+
}
|
| 1747 |
+
)
|
| 1748 |
+
)
|
| 1749 |
+
else:
|
| 1750 |
+
print(format_alerts(alerts))
|
| 1751 |
+
elif args.get_type == "report":
|
| 1752 |
+
if remote:
|
| 1753 |
+
logs = remote.predict(args.project, args.run, api_name="/get_logs")
|
| 1754 |
+
else:
|
| 1755 |
+
db_path = SQLiteStorage.get_project_db_path(args.project)
|
| 1756 |
+
if not db_path.exists():
|
| 1757 |
+
error_exit(f"Project '{args.project}' not found.")
|
| 1758 |
+
runs = SQLiteStorage.get_runs(args.project)
|
| 1759 |
+
if args.run not in runs:
|
| 1760 |
+
error_exit(
|
| 1761 |
+
f"Run '{args.run}' not found in project '{args.project}'."
|
| 1762 |
+
)
|
| 1763 |
+
logs = SQLiteStorage.get_logs(args.project, args.run)
|
| 1764 |
+
|
| 1765 |
+
reports = _extract_reports(args.run, logs, report_name=args.report)
|
| 1766 |
+
if not reports:
|
| 1767 |
+
error_exit(
|
| 1768 |
+
f"Report '{args.report}' not found in run '{args.run}' of project '{args.project}'."
|
| 1769 |
+
)
|
| 1770 |
+
|
| 1771 |
+
if args.json:
|
| 1772 |
+
print(
|
| 1773 |
+
format_json(
|
| 1774 |
+
{
|
| 1775 |
+
"project": args.project,
|
| 1776 |
+
"run": args.run,
|
| 1777 |
+
"report": args.report,
|
| 1778 |
+
"values": reports,
|
| 1779 |
+
}
|
| 1780 |
+
)
|
| 1781 |
+
)
|
| 1782 |
+
else:
|
| 1783 |
+
output = []
|
| 1784 |
+
for idx, entry in enumerate(reports, start=1):
|
| 1785 |
+
output.append(
|
| 1786 |
+
f"Entry {idx} | step={entry['step']} | timestamp={entry['timestamp']}"
|
| 1787 |
+
)
|
| 1788 |
+
output.append(entry["content"])
|
| 1789 |
+
if idx < len(reports):
|
| 1790 |
+
output.append("-" * 80)
|
| 1791 |
+
print("\n".join(output))
|
| 1792 |
+
elif args.command == "query":
|
| 1793 |
+
if args.query_type == "project":
|
| 1794 |
+
_handle_query(args)
|
| 1795 |
+
elif args.command == "skills":
|
| 1796 |
+
if args.skills_action == "add":
|
| 1797 |
+
_handle_skills_add(args)
|
| 1798 |
+
elif args.command == "logbook":
|
| 1799 |
+
_handle_logbook(args)
|
| 1800 |
+
else:
|
| 1801 |
+
parser.print_help()
|
| 1802 |
+
|
| 1803 |
+
|
| 1804 |
+
def _sync_suffix(lb, proj):
|
| 1805 |
+
if lb.is_autosync(proj):
|
| 1806 |
+
space = lb.read_metadata(proj).get("space_id")
|
| 1807 |
+
return f" · syncing to {space}…"
|
| 1808 |
+
return ""
|
| 1809 |
+
|
| 1810 |
+
|
| 1811 |
+
def _logbook_cell_target(lb, proj, args):
|
| 1812 |
+
return lb.resolve_page(proj, getattr(args, "page", None))
|
| 1813 |
+
|
| 1814 |
+
|
| 1815 |
+
def _print_logbook_pages(pages):
|
| 1816 |
+
if not pages:
|
| 1817 |
+
print("No pages.")
|
| 1818 |
+
return
|
| 1819 |
+
print("Pages:")
|
| 1820 |
+
for page in pages:
|
| 1821 |
+
count = page["cell_count"]
|
| 1822 |
+
print(
|
| 1823 |
+
f"- {page['slug']} · {page['title']} · "
|
| 1824 |
+
f"{count} cell{'' if count == 1 else 's'}"
|
| 1825 |
+
)
|
| 1826 |
+
|
| 1827 |
+
|
| 1828 |
+
def _print_logbook_page_outline(page):
|
| 1829 |
+
print(f"Page: {page['title']} ({page['slug']})")
|
| 1830 |
+
if not page["cells"]:
|
| 1831 |
+
print("No cells.")
|
| 1832 |
+
return
|
| 1833 |
+
for cell in page["cells"]:
|
| 1834 |
+
created = (
|
| 1835 |
+
f" · {cell['created_at'].replace('T', ' ')[:16]}"
|
| 1836 |
+
if cell.get("created_at")
|
| 1837 |
+
else ""
|
| 1838 |
+
)
|
| 1839 |
+
print(f"\n### {cell['title']} · {cell['type']} · {cell['id']}{created}")
|
| 1840 |
+
if cell.get("preview"):
|
| 1841 |
+
print(cell["preview"].rstrip())
|
| 1842 |
+
print(
|
| 1843 |
+
"\nFetch full payloads with: trackio logbook read cell <cell-id> "
|
| 1844 |
+
"[--full|--raw|--html]"
|
| 1845 |
+
)
|
| 1846 |
+
|
| 1847 |
+
|
| 1848 |
+
def _print_logbook_cell(cell):
|
| 1849 |
+
print(f"Cell: {cell['title']} ({cell['id']})")
|
| 1850 |
+
print(f"Page: {cell['page_title']} ({cell['page']})")
|
| 1851 |
+
print(f"Type: {cell['type']}")
|
| 1852 |
+
if cell.get("created_at"):
|
| 1853 |
+
print(f"Created: {cell['created_at']}")
|
| 1854 |
+
content_keys = [key for key in ("body", "raw", "html") if key in cell]
|
| 1855 |
+
if not content_keys:
|
| 1856 |
+
if cell["type"] == "code":
|
| 1857 |
+
print("\nBody omitted. Use --full to include code/output.")
|
| 1858 |
+
elif cell["type"] == "figure":
|
| 1859 |
+
print("\nFigure content omitted. Use --raw, --html, or --full.")
|
| 1860 |
+
return
|
| 1861 |
+
for key in content_keys:
|
| 1862 |
+
label = key.upper()
|
| 1863 |
+
print(f"\n--- {label} ---\n")
|
| 1864 |
+
print(cell[key])
|
| 1865 |
+
|
| 1866 |
+
|
| 1867 |
+
def _read_logbook_payload(path_or_text, inline_text):
|
| 1868 |
+
if inline_text is not None:
|
| 1869 |
+
return inline_text
|
| 1870 |
+
if not path_or_text:
|
| 1871 |
+
return ""
|
| 1872 |
+
path = Path(path_or_text)
|
| 1873 |
+
if path.is_file():
|
| 1874 |
+
return path.read_text(encoding="utf-8")
|
| 1875 |
+
return path_or_text
|
| 1876 |
+
|
| 1877 |
+
|
| 1878 |
+
def _handle_logbook(args):
|
| 1879 |
+
from trackio import logbook as lb
|
| 1880 |
+
|
| 1881 |
+
action = args.logbook_action
|
| 1882 |
+
try:
|
| 1883 |
+
if action == "open":
|
| 1884 |
+
print("Warning: Trackio Logbook is an experimental feature.")
|
| 1885 |
+
proj = lb.find_project_dir()
|
| 1886 |
+
if proj is not None:
|
| 1887 |
+
if args.space_id:
|
| 1888 |
+
metadata = lb.read_metadata(proj)
|
| 1889 |
+
metadata["space_id"] = args.space_id
|
| 1890 |
+
lb.write_metadata(proj, metadata)
|
| 1891 |
+
print(f"Attached to existing logbook at {lb.logbook_root(proj)}")
|
| 1892 |
+
if args.title:
|
| 1893 |
+
print(
|
| 1894 |
+
"Note: --title was ignored because this logbook already "
|
| 1895 |
+
"exists. To retitle it, edit the '# ...' heading in "
|
| 1896 |
+
f"{lb.logbook_root(proj) / 'pages' / 'index.md'}."
|
| 1897 |
+
)
|
| 1898 |
+
elif args.space_id:
|
| 1899 |
+
proj = lb.clone_logbook(args.space_id)
|
| 1900 |
+
if proj is not None:
|
| 1901 |
+
print(
|
| 1902 |
+
f"Cloned logbook from {args.space_id} into "
|
| 1903 |
+
f"{lb.logbook_root(proj)}"
|
| 1904 |
+
)
|
| 1905 |
+
if proj is None:
|
| 1906 |
+
proj = lb.create_logbook(
|
| 1907 |
+
args.title or os.path.basename(os.getcwd()), space_id=args.space_id
|
| 1908 |
+
)
|
| 1909 |
+
print(f"Opened logbook at {lb.logbook_root(proj)}")
|
| 1910 |
+
if args.space_id:
|
| 1911 |
+
print(f"Will publish to: {args.space_id}")
|
| 1912 |
+
if not args.no_serve:
|
| 1913 |
+
lb.start_preview(proj, port=args.port, open_browser=not args.no_browser)
|
| 1914 |
+
elif action == "run":
|
| 1915 |
+
proj = lb.require_project_dir()
|
| 1916 |
+
command = list(args.command or [])
|
| 1917 |
+
if command and command[0] == "--":
|
| 1918 |
+
command = command[1:]
|
| 1919 |
+
if not command:
|
| 1920 |
+
error_exit("No command provided. Use: trackio logbook run -- <command>")
|
| 1921 |
+
rc = lb.run_and_log(
|
| 1922 |
+
proj,
|
| 1923 |
+
command,
|
| 1924 |
+
page=args.page,
|
| 1925 |
+
title=args.title,
|
| 1926 |
+
capture_artifacts=not getattr(args, "no_artifacts", False),
|
| 1927 |
+
)
|
| 1928 |
+
slug = lb.read_metadata(proj).get("last_page", "?")
|
| 1929 |
+
print(f"Logged run to page '{slug}'.{_sync_suffix(lb, proj)}")
|
| 1930 |
+
sys.exit(rc)
|
| 1931 |
+
elif action == "cell":
|
| 1932 |
+
proj = lb.require_project_dir()
|
| 1933 |
+
slug = _logbook_cell_target(lb, proj, args)
|
| 1934 |
+
if args.cell_type == "markdown":
|
| 1935 |
+
# Agent/tool callers often send a single argument containing
|
| 1936 |
+
# escaped newlines. Treat those as Markdown line breaks while
|
| 1937 |
+
# preserving normal shell-provided newlines unchanged.
|
| 1938 |
+
body = args.body.replace("\\r\\n", "\n").replace("\\n", "\n")
|
| 1939 |
+
lb.add_markdown_cell(proj, slug, body, title=args.title)
|
| 1940 |
+
elif args.cell_type == "artifact":
|
| 1941 |
+
lb.add_artifact_cell(
|
| 1942 |
+
proj,
|
| 1943 |
+
slug,
|
| 1944 |
+
args.name,
|
| 1945 |
+
title=args.title,
|
| 1946 |
+
artifact_type=args.artifact_type,
|
| 1947 |
+
)
|
| 1948 |
+
elif args.cell_type == "code":
|
| 1949 |
+
lb.add_code_cell(
|
| 1950 |
+
proj,
|
| 1951 |
+
slug,
|
| 1952 |
+
args.output,
|
| 1953 |
+
title=args.title,
|
| 1954 |
+
code_paths=args.code,
|
| 1955 |
+
code_text=args.code_text,
|
| 1956 |
+
language=args.language,
|
| 1957 |
+
)
|
| 1958 |
+
elif args.cell_type == "figure":
|
| 1959 |
+
if args.image:
|
| 1960 |
+
html = lb.figure_html_from_image(args.image)
|
| 1961 |
+
elif (
|
| 1962 |
+
args.html
|
| 1963 |
+
and args.html_text is None
|
| 1964 |
+
and Path(args.html).is_file()
|
| 1965 |
+
and lb.is_figure_image_path(args.html)
|
| 1966 |
+
):
|
| 1967 |
+
# `--html plot.png` — embed the image instead of trying to
|
| 1968 |
+
# read a binary file as UTF-8 text.
|
| 1969 |
+
html = lb.figure_html_from_image(args.html)
|
| 1970 |
+
else:
|
| 1971 |
+
html = _read_logbook_payload(args.html, args.html_text)
|
| 1972 |
+
raw = _read_logbook_payload(args.raw, args.raw_text)
|
| 1973 |
+
lb.add_figure_cell(
|
| 1974 |
+
proj,
|
| 1975 |
+
slug,
|
| 1976 |
+
html=html,
|
| 1977 |
+
raw=raw,
|
| 1978 |
+
title=args.title,
|
| 1979 |
+
)
|
| 1980 |
+
elif args.cell_type == "dashboard":
|
| 1981 |
+
lb.add_dashboard_cell(
|
| 1982 |
+
proj,
|
| 1983 |
+
slug,
|
| 1984 |
+
args.project,
|
| 1985 |
+
space_id=args.space_id,
|
| 1986 |
+
title=args.title,
|
| 1987 |
+
)
|
| 1988 |
+
print(
|
| 1989 |
+
f"Logged {args.cell_type} cell to page "
|
| 1990 |
+
f"'{slug}'.{_sync_suffix(lb, proj)}"
|
| 1991 |
+
)
|
| 1992 |
+
lb.trigger_autosync(proj)
|
| 1993 |
+
elif action == "page":
|
| 1994 |
+
proj = lb.require_project_dir()
|
| 1995 |
+
page_slug = lb.ensure_page(proj, args.title)
|
| 1996 |
+
print(f"Selected page '{page_slug}' as default.{_sync_suffix(lb, proj)}")
|
| 1997 |
+
lb.trigger_autosync(proj)
|
| 1998 |
+
elif action == "pin":
|
| 1999 |
+
proj = lb.require_project_dir()
|
| 2000 |
+
cell_id = args.cell_id or lb.last_cell_id(proj, page=args.page)
|
| 2001 |
+
if not cell_id:
|
| 2002 |
+
error_exit(
|
| 2003 |
+
"No cell to pin. Pass a cell id, or add a cell first "
|
| 2004 |
+
"(optionally scope with --page)."
|
| 2005 |
+
)
|
| 2006 |
+
res = lb.set_cell_pinned(
|
| 2007 |
+
proj, cell_id, pinned=not args.unpin, page=args.page
|
| 2008 |
+
)
|
| 2009 |
+
verb = "Unpinned" if args.unpin else "Pinned"
|
| 2010 |
+
print(
|
| 2011 |
+
f"{verb} cell {cell_id} on page "
|
| 2012 |
+
f"'{res['page']}'.{_sync_suffix(lb, proj)}"
|
| 2013 |
+
)
|
| 2014 |
+
lb.trigger_autosync(proj)
|
| 2015 |
+
elif action == "read":
|
| 2016 |
+
proj = lb.resolve_read_source(args.path)
|
| 2017 |
+
preview_opts = {
|
| 2018 |
+
"head": lb.DEFAULT_HEAD
|
| 2019 |
+
if getattr(args, "head", None) is None
|
| 2020 |
+
else args.head,
|
| 2021 |
+
"tail": lb.DEFAULT_TAIL
|
| 2022 |
+
if getattr(args, "tail", None) is None
|
| 2023 |
+
else args.tail,
|
| 2024 |
+
"raw_limit": lb.DEFAULT_RAW_LIMIT
|
| 2025 |
+
if getattr(args, "raw_limit", None) is None
|
| 2026 |
+
else args.raw_limit,
|
| 2027 |
+
}
|
| 2028 |
+
if args.read_target is None:
|
| 2029 |
+
if args.json:
|
| 2030 |
+
print(format_json(lb.read_logbook_data(proj, **preview_opts)))
|
| 2031 |
+
else:
|
| 2032 |
+
print(lb.read_logbook(proj, **preview_opts))
|
| 2033 |
+
elif args.read_target == "pages":
|
| 2034 |
+
pages = lb.list_pages(proj)
|
| 2035 |
+
if args.json:
|
| 2036 |
+
print(format_json({"pages": pages}))
|
| 2037 |
+
else:
|
| 2038 |
+
_print_logbook_pages(pages)
|
| 2039 |
+
elif args.read_target == "page":
|
| 2040 |
+
page = lb.read_page_outline(proj, args.page, **preview_opts)
|
| 2041 |
+
if args.json:
|
| 2042 |
+
print(format_json(page))
|
| 2043 |
+
else:
|
| 2044 |
+
_print_logbook_page_outline(page)
|
| 2045 |
+
elif args.read_target == "cell":
|
| 2046 |
+
cell = lb.read_cell(
|
| 2047 |
+
proj,
|
| 2048 |
+
args.cell_id,
|
| 2049 |
+
include_full=args.full,
|
| 2050 |
+
include_raw=args.raw,
|
| 2051 |
+
include_html=args.html,
|
| 2052 |
+
)
|
| 2053 |
+
if args.json:
|
| 2054 |
+
print(format_json(cell))
|
| 2055 |
+
else:
|
| 2056 |
+
_print_logbook_cell(cell)
|
| 2057 |
+
elif action == "_sync":
|
| 2058 |
+
lb.sync_worker()
|
| 2059 |
+
elif action == "sync-todos":
|
| 2060 |
+
lb.sync_todos_from_stdin()
|
| 2061 |
+
elif action == "serve":
|
| 2062 |
+
lb.serve(path=args.path, port=args.port, open_browser=not args.no_browser)
|
| 2063 |
+
elif action == "publish":
|
| 2064 |
+
url = lb.publish(space_id=args.space_id, private=args.private)
|
| 2065 |
+
print(f"Published: {url}")
|
| 2066 |
+
space_id = lb.read_metadata(lb.require_project_dir()).get("space_id")
|
| 2067 |
+
if space_id:
|
| 2068 |
+
subdomain = re.sub(r"[^a-z0-9]+", "-", space_id.lower()).strip("-")
|
| 2069 |
+
print(f"Rendered at: https://{subdomain}.static.hf.space/")
|
| 2070 |
+
elif action == "sync":
|
| 2071 |
+
proj = lb.require_project_dir()
|
| 2072 |
+
if not lb.is_autosync(proj):
|
| 2073 |
+
error_exit("Publish first: trackio logbook publish <username/space>")
|
| 2074 |
+
lb.trigger_autosync(proj)
|
| 2075 |
+
print(f"Syncing to {lb.read_metadata(proj).get('space_id')}…")
|
| 2076 |
+
except lb.LogbookError as e:
|
| 2077 |
+
error_exit(str(e))
|
| 2078 |
+
|
| 2079 |
+
|
| 2080 |
+
def _handle_skills_add(args):
|
| 2081 |
+
import shutil
|
| 2082 |
+
from pathlib import Path
|
| 2083 |
+
|
| 2084 |
+
CENTRAL_LOCAL = Path(".agents/skills")
|
| 2085 |
+
CENTRAL_GLOBAL = Path("~/.agents/skills")
|
| 2086 |
+
CLAUDE_LOCAL = Path(".claude/skills")
|
| 2087 |
+
CLAUDE_GLOBAL = Path("~/.claude/skills")
|
| 2088 |
+
|
| 2089 |
+
SKILL_ID = "trackio"
|
| 2090 |
+
GITHUB_RAW = "https://raw.githubusercontent.com/gradio-app/trackio/main"
|
| 2091 |
+
SKILL_PREFIX = ".agents/skills/trackio"
|
| 2092 |
+
SKILL_FILES = [
|
| 2093 |
+
"SKILL.md",
|
| 2094 |
+
"alerts.md",
|
| 2095 |
+
"logging_metrics.md",
|
| 2096 |
+
"retrieving_metrics.md",
|
| 2097 |
+
"storage_schema.md",
|
| 2098 |
+
"logbook.md",
|
| 2099 |
+
]
|
| 2100 |
+
COMMAND_PREFIX = ".agents/commands"
|
| 2101 |
+
COMMAND_FILE = "logbook.md"
|
| 2102 |
+
|
| 2103 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 2104 |
+
USE_LOCAL = (REPO_ROOT / SKILL_PREFIX / "SKILL.md").is_file()
|
| 2105 |
+
|
| 2106 |
+
if USE_LOCAL:
|
| 2107 |
+
print(f"Using local Trackio source at {REPO_ROOT}")
|
| 2108 |
+
|
| 2109 |
+
def download(url: str) -> str:
|
| 2110 |
+
from huggingface_hub.utils import get_session
|
| 2111 |
+
|
| 2112 |
+
try:
|
| 2113 |
+
response = get_session().get(url)
|
| 2114 |
+
response.raise_for_status()
|
| 2115 |
+
except Exception as e:
|
| 2116 |
+
error_exit(
|
| 2117 |
+
f"Failed to download {url}\n{e}\n\n"
|
| 2118 |
+
"Make sure you have internet access. The skill files are fetched from "
|
| 2119 |
+
"the Trackio GitHub repository."
|
| 2120 |
+
)
|
| 2121 |
+
return response.text
|
| 2122 |
+
|
| 2123 |
+
def get_content(prefix: str, fname: str) -> str:
|
| 2124 |
+
if USE_LOCAL:
|
| 2125 |
+
local = REPO_ROOT / prefix / fname
|
| 2126 |
+
if local.is_file():
|
| 2127 |
+
return local.read_text(encoding="utf-8")
|
| 2128 |
+
return download(f"{GITHUB_RAW}/{prefix}/{fname}")
|
| 2129 |
+
|
| 2130 |
+
def remove_existing(path: Path, force: bool):
|
| 2131 |
+
if not (path.exists() or path.is_symlink()):
|
| 2132 |
+
return
|
| 2133 |
+
if not force:
|
| 2134 |
+
error_exit(
|
| 2135 |
+
f"Skill already exists at {path}.\nRe-run with --force to overwrite."
|
| 2136 |
+
)
|
| 2137 |
+
if path.is_dir() and not path.is_symlink():
|
| 2138 |
+
shutil.rmtree(path)
|
| 2139 |
+
else:
|
| 2140 |
+
path.unlink()
|
| 2141 |
+
|
| 2142 |
+
def install_to(skills_dir: Path, force: bool) -> Path:
|
| 2143 |
+
skills_dir = skills_dir.expanduser().resolve()
|
| 2144 |
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
| 2145 |
+
dest = skills_dir / SKILL_ID
|
| 2146 |
+
remove_existing(dest, force)
|
| 2147 |
+
dest.mkdir()
|
| 2148 |
+
for fname in SKILL_FILES:
|
| 2149 |
+
(dest / fname).write_text(
|
| 2150 |
+
get_content(SKILL_PREFIX, fname), encoding="utf-8"
|
| 2151 |
+
)
|
| 2152 |
+
return dest
|
| 2153 |
+
|
| 2154 |
+
def create_symlink(
|
| 2155 |
+
agent_skills_dir: Path, central_skill_path: Path, force: bool
|
| 2156 |
+
) -> Path:
|
| 2157 |
+
agent_skills_dir = agent_skills_dir.expanduser().resolve()
|
| 2158 |
+
agent_skills_dir.mkdir(parents=True, exist_ok=True)
|
| 2159 |
+
link_path = agent_skills_dir / SKILL_ID
|
| 2160 |
+
remove_existing(link_path, force)
|
| 2161 |
+
link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir))
|
| 2162 |
+
return link_path
|
| 2163 |
+
|
| 2164 |
+
def install_command(
|
| 2165 |
+
command_dir: Path, force: bool, strip_frontmatter: bool = False
|
| 2166 |
+
) -> Path:
|
| 2167 |
+
import re
|
| 2168 |
+
|
| 2169 |
+
command_dir = command_dir.expanduser().resolve()
|
| 2170 |
+
command_dir.mkdir(parents=True, exist_ok=True)
|
| 2171 |
+
dest = command_dir / COMMAND_FILE
|
| 2172 |
+
if dest.exists() and not force:
|
| 2173 |
+
error_exit(
|
| 2174 |
+
f"Command already exists at {dest}.\nRe-run with --force to overwrite."
|
| 2175 |
+
)
|
| 2176 |
+
content = get_content(COMMAND_PREFIX, COMMAND_FILE)
|
| 2177 |
+
if strip_frontmatter:
|
| 2178 |
+
content = re.sub(r"\A---\n.*?\n---\n+", "", content, flags=re.S)
|
| 2179 |
+
dest.write_text(content, encoding="utf-8")
|
| 2180 |
+
return dest
|
| 2181 |
+
|
| 2182 |
+
global_targets = {
|
| 2183 |
+
"cursor": Path("~/.cursor/skills"),
|
| 2184 |
+
"claude": CLAUDE_GLOBAL,
|
| 2185 |
+
"codex": Path("~/.codex/skills"),
|
| 2186 |
+
"opencode": Path("~/.opencode/skills"),
|
| 2187 |
+
"pi": Path("~/.pi/agent/skills"),
|
| 2188 |
+
}
|
| 2189 |
+
local_targets = {
|
| 2190 |
+
"cursor": Path(".cursor/skills"),
|
| 2191 |
+
"claude": CLAUDE_LOCAL,
|
| 2192 |
+
"codex": Path(".codex/skills"),
|
| 2193 |
+
"opencode": Path(".opencode/skills"),
|
| 2194 |
+
"pi": Path(".pi/skills"),
|
| 2195 |
+
}
|
| 2196 |
+
targets_dict = global_targets if args.global_ else local_targets
|
| 2197 |
+
|
| 2198 |
+
command_targets_global = {
|
| 2199 |
+
"cursor": Path("~/.cursor/commands"),
|
| 2200 |
+
"claude": Path("~/.claude/commands"),
|
| 2201 |
+
"codex": Path("~/.codex/prompts"),
|
| 2202 |
+
"opencode": Path("~/.config/opencode/commands"),
|
| 2203 |
+
"pi": Path("~/.pi/agent/prompts"),
|
| 2204 |
+
}
|
| 2205 |
+
command_targets_local = {
|
| 2206 |
+
"cursor": Path(".cursor/commands"),
|
| 2207 |
+
"claude": Path(".claude/commands"),
|
| 2208 |
+
"codex": Path("~/.codex/prompts"),
|
| 2209 |
+
"opencode": Path(".opencode/commands"),
|
| 2210 |
+
"pi": Path(".pi/prompts"),
|
| 2211 |
+
}
|
| 2212 |
+
command_dict = command_targets_global if args.global_ else command_targets_local
|
| 2213 |
+
COMMAND_NOTES = {
|
| 2214 |
+
"codex": " (Codex only reads ~/.codex/prompts; restart Codex to pick it up)"
|
| 2215 |
+
}
|
| 2216 |
+
STRIP_FRONTMATTER = {"pi"}
|
| 2217 |
+
|
| 2218 |
+
if args.dest:
|
| 2219 |
+
if (
|
| 2220 |
+
args.cursor
|
| 2221 |
+
or args.claude
|
| 2222 |
+
or args.codex
|
| 2223 |
+
or args.opencode
|
| 2224 |
+
or args.pi
|
| 2225 |
+
or args.global_
|
| 2226 |
+
):
|
| 2227 |
+
error_exit("--dest cannot be combined with agent flags or --global.")
|
| 2228 |
+
skill_dest = install_to(Path(args.dest), args.force)
|
| 2229 |
+
print(f"Installed '{SKILL_ID}' to {skill_dest}")
|
| 2230 |
+
return
|
| 2231 |
+
|
| 2232 |
+
selected = [
|
| 2233 |
+
name
|
| 2234 |
+
for name, flag in (
|
| 2235 |
+
("cursor", args.cursor),
|
| 2236 |
+
("claude", args.claude),
|
| 2237 |
+
("codex", args.codex),
|
| 2238 |
+
("opencode", args.opencode),
|
| 2239 |
+
("pi", args.pi),
|
| 2240 |
+
)
|
| 2241 |
+
if flag
|
| 2242 |
+
]
|
| 2243 |
+
|
| 2244 |
+
central_path = CENTRAL_GLOBAL if args.global_ else CENTRAL_LOCAL
|
| 2245 |
+
central_skill_path = install_to(central_path, args.force)
|
| 2246 |
+
print(f"Installed '{SKILL_ID}' to central location: {central_skill_path}")
|
| 2247 |
+
|
| 2248 |
+
for name in selected:
|
| 2249 |
+
link_path = create_symlink(targets_dict[name], central_skill_path, args.force)
|
| 2250 |
+
print(f"Created symlink: {link_path}")
|
| 2251 |
+
|
| 2252 |
+
if not args.no_command:
|
| 2253 |
+
for name in selected:
|
| 2254 |
+
if name in command_dict:
|
| 2255 |
+
cmd_path = install_command(
|
| 2256 |
+
command_dict[name],
|
| 2257 |
+
args.force,
|
| 2258 |
+
strip_frontmatter=name in STRIP_FRONTMATTER,
|
| 2259 |
+
)
|
| 2260 |
+
note = COMMAND_NOTES.get(name, "")
|
| 2261 |
+
print(f"Installed '/logbook' command: {cmd_path}{note}")
|
| 2262 |
+
else:
|
| 2263 |
+
print(
|
| 2264 |
+
f"Skipped '/logbook' command for {name} "
|
| 2265 |
+
"(no slash-command directory convention)."
|
| 2266 |
+
)
|
| 2267 |
+
|
| 2268 |
+
if args.claude and not args.no_hook:
|
| 2269 |
+
hook_path = _install_claude_logbook_hook(args.global_)
|
| 2270 |
+
if hook_path:
|
| 2271 |
+
print(f"Installed logbook todo-sync hook: {hook_path}")
|
| 2272 |
+
|
| 2273 |
+
|
| 2274 |
+
def _install_claude_logbook_hook(global_: bool):
|
| 2275 |
+
import json
|
| 2276 |
+
from pathlib import Path
|
| 2277 |
+
|
| 2278 |
+
settings = (
|
| 2279 |
+
Path("~/.claude/settings.json") if global_ else Path(".claude/settings.json")
|
| 2280 |
+
).expanduser()
|
| 2281 |
+
settings.parent.mkdir(parents=True, exist_ok=True)
|
| 2282 |
+
data = {}
|
| 2283 |
+
if settings.exists():
|
| 2284 |
+
try:
|
| 2285 |
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
| 2286 |
+
except Exception:
|
| 2287 |
+
print(f"Could not parse {settings}; skipping hook install.")
|
| 2288 |
+
return None
|
| 2289 |
+
command = "trackio logbook sync-todos"
|
| 2290 |
+
hooks = data.setdefault("hooks", {})
|
| 2291 |
+
post = hooks.setdefault("PostToolUse", [])
|
| 2292 |
+
for entry in post:
|
| 2293 |
+
for h in entry.get("hooks", []):
|
| 2294 |
+
if h.get("command") == command:
|
| 2295 |
+
return settings
|
| 2296 |
+
post.append(
|
| 2297 |
+
{
|
| 2298 |
+
"matcher": "TodoWrite",
|
| 2299 |
+
"hooks": [{"type": "command", "command": command}],
|
| 2300 |
+
}
|
| 2301 |
+
)
|
| 2302 |
+
settings.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 2303 |
+
return settings
|
| 2304 |
+
|
| 2305 |
+
|
| 2306 |
+
if __name__ == "__main__":
|
| 2307 |
+
main()
|
trackio/cli_helpers.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import sys
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from trackio import references
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def format_json(data: Any) -> str:
|
| 9 |
+
"""Format data as JSON."""
|
| 10 |
+
return json.dumps(data, indent=2)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def format_list(items: list[str], title: str | None = None) -> str:
|
| 14 |
+
"""Format a list of items in human-readable format."""
|
| 15 |
+
if not items:
|
| 16 |
+
return f"No {title.lower() if title else 'items'} found."
|
| 17 |
+
|
| 18 |
+
output = []
|
| 19 |
+
if title:
|
| 20 |
+
output.append(f"{title}:")
|
| 21 |
+
|
| 22 |
+
for item in items:
|
| 23 |
+
output.append(f" - {item}")
|
| 24 |
+
|
| 25 |
+
return "\n".join(output)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def format_artifacts(artifacts: list[dict], project: str | None = None) -> str:
|
| 29 |
+
"""Format a project's artifacts in human-readable format."""
|
| 30 |
+
if not artifacts:
|
| 31 |
+
return "No artifacts found."
|
| 32 |
+
|
| 33 |
+
title = f"Artifacts in '{project}'" if project else "Artifacts"
|
| 34 |
+
output = [f"{title}:"]
|
| 35 |
+
for art in artifacts:
|
| 36 |
+
latest = art.get("latest_version")
|
| 37 |
+
version = f"v{latest}" if latest is not None else "(no versions)"
|
| 38 |
+
aliases = art.get("aliases") or []
|
| 39 |
+
alias_str = f" [{', '.join(aliases)}]" if aliases else ""
|
| 40 |
+
output.append(
|
| 41 |
+
f" - {art['name']} ({art.get('type', '?')}) "
|
| 42 |
+
f"latest={version}{alias_str} "
|
| 43 |
+
f"versions={art.get('num_versions', 0)}"
|
| 44 |
+
)
|
| 45 |
+
if art.get("description"):
|
| 46 |
+
output.append(f" {art['description']}")
|
| 47 |
+
return "\n".join(output)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def format_artifact(record: dict) -> str:
|
| 51 |
+
"""Format a single resolved artifact version in human-readable format."""
|
| 52 |
+
lines = [
|
| 53 |
+
f"Artifact: {record['name']} (v{record['version']})",
|
| 54 |
+
f" type: {record.get('type')}",
|
| 55 |
+
]
|
| 56 |
+
if record.get("description"):
|
| 57 |
+
lines.append(f" description: {record['description']}")
|
| 58 |
+
aliases = record.get("aliases") or []
|
| 59 |
+
if aliases:
|
| 60 |
+
lines.append(f" aliases: {', '.join(aliases)}")
|
| 61 |
+
if record.get("metadata"):
|
| 62 |
+
lines.append(f" metadata: {record['metadata']}")
|
| 63 |
+
lines.append(f" size: {record.get('size_bytes')} bytes")
|
| 64 |
+
lines.append(f" digest: {record.get('manifest_digest')}")
|
| 65 |
+
manifest = record.get("manifest") or []
|
| 66 |
+
lines.append(f" files ({len(manifest)}):")
|
| 67 |
+
for entry in manifest:
|
| 68 |
+
if references.is_reference_entry(entry):
|
| 69 |
+
lines.append(f" - {entry['path']} -> {entry['ref']} (reference)")
|
| 70 |
+
else:
|
| 71 |
+
lines.append(f" - {entry['path']} ({entry['size']} bytes)")
|
| 72 |
+
return "\n".join(lines)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def format_spaces(spaces: list[dict]) -> str:
|
| 76 |
+
"""Format HF Spaces in human-readable format."""
|
| 77 |
+
if not spaces:
|
| 78 |
+
return "No Trackio Spaces found."
|
| 79 |
+
|
| 80 |
+
output = ["Trackio Spaces:"]
|
| 81 |
+
for space in spaces:
|
| 82 |
+
visibility = "private" if space.get("private") else "public"
|
| 83 |
+
output.append(f" - {space['id']} ({visibility})")
|
| 84 |
+
if space.get("url"):
|
| 85 |
+
output.append(f" {space['url']}")
|
| 86 |
+
|
| 87 |
+
return "\n".join(output)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def format_project_summary(summary: dict) -> str:
|
| 91 |
+
"""Format project summary in human-readable format."""
|
| 92 |
+
output = [f"Project: {summary['project']}"]
|
| 93 |
+
output.append(f"Number of runs: {summary['num_runs']}")
|
| 94 |
+
|
| 95 |
+
if summary["runs"]:
|
| 96 |
+
output.append("\nRuns:")
|
| 97 |
+
for run in summary["runs"]:
|
| 98 |
+
output.append(f" - {run}")
|
| 99 |
+
else:
|
| 100 |
+
output.append("\nNo runs found.")
|
| 101 |
+
|
| 102 |
+
if summary.get("last_activity"):
|
| 103 |
+
output.append(f"\nLast activity (max step): {summary['last_activity']}")
|
| 104 |
+
|
| 105 |
+
return "\n".join(output)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def format_run_summary(summary: dict) -> str:
|
| 109 |
+
"""Format run summary in human-readable format."""
|
| 110 |
+
output = [f"Project: {summary['project']}"]
|
| 111 |
+
output.append(f"Run: {summary['run']}")
|
| 112 |
+
output.append(f"Number of logs: {summary['num_logs']}")
|
| 113 |
+
|
| 114 |
+
if summary.get("last_step") is not None:
|
| 115 |
+
output.append(f"Last step: {summary['last_step']}")
|
| 116 |
+
|
| 117 |
+
if summary.get("metrics"):
|
| 118 |
+
output.append("\nMetrics:")
|
| 119 |
+
for metric in summary["metrics"]:
|
| 120 |
+
output.append(f" - {metric}")
|
| 121 |
+
else:
|
| 122 |
+
output.append("\nNo metrics found.")
|
| 123 |
+
|
| 124 |
+
config = summary.get("config")
|
| 125 |
+
if config:
|
| 126 |
+
output.append("\nConfig:")
|
| 127 |
+
config_display = {k: v for k, v in config.items() if not k.startswith("_")}
|
| 128 |
+
if config_display:
|
| 129 |
+
for key, value in config_display.items():
|
| 130 |
+
output.append(f" {key}: {value}")
|
| 131 |
+
else:
|
| 132 |
+
output.append(" (no config)")
|
| 133 |
+
else:
|
| 134 |
+
output.append("\nConfig: (no config)")
|
| 135 |
+
|
| 136 |
+
return "\n".join(output)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def format_metric_values(values: list[dict]) -> str:
|
| 140 |
+
"""Format metric values in human-readable format."""
|
| 141 |
+
if not values:
|
| 142 |
+
return "No metric values found."
|
| 143 |
+
|
| 144 |
+
output = [f"Found {len(values)} value(s):\n"]
|
| 145 |
+
output.append("Step | Timestamp | Value")
|
| 146 |
+
output.append("-" * 50)
|
| 147 |
+
|
| 148 |
+
for value in values:
|
| 149 |
+
step = value.get("step", "N/A")
|
| 150 |
+
timestamp = value.get("timestamp", "N/A")
|
| 151 |
+
val = value.get("value", "N/A")
|
| 152 |
+
output.append(f"{step} | {timestamp} | {val}")
|
| 153 |
+
|
| 154 |
+
return "\n".join(output)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def format_system_metrics(metrics: list[dict]) -> str:
|
| 158 |
+
"""Format system metrics in human-readable format."""
|
| 159 |
+
if not metrics:
|
| 160 |
+
return "No system metrics found."
|
| 161 |
+
|
| 162 |
+
output = [f"Found {len(metrics)} system metric entry/entries:\n"]
|
| 163 |
+
|
| 164 |
+
for i, entry in enumerate(metrics):
|
| 165 |
+
timestamp = entry.get("timestamp", "N/A")
|
| 166 |
+
output.append(f"\nEntry {i + 1} (Timestamp: {timestamp}):")
|
| 167 |
+
for key, value in entry.items():
|
| 168 |
+
if key != "timestamp":
|
| 169 |
+
output.append(f" {key}: {value}")
|
| 170 |
+
|
| 171 |
+
return "\n".join(output)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def format_system_metric_names(names: list[str]) -> str:
|
| 175 |
+
"""Format system metric names in human-readable format."""
|
| 176 |
+
return format_list(names, "System Metrics")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def format_snapshot(snapshot: dict[str, list[dict]]) -> str:
|
| 180 |
+
"""Format a metrics snapshot in human-readable format."""
|
| 181 |
+
if not snapshot:
|
| 182 |
+
return "No metrics found in the specified range."
|
| 183 |
+
|
| 184 |
+
output = []
|
| 185 |
+
for metric_name, values in sorted(snapshot.items()):
|
| 186 |
+
output.append(f"\n{metric_name}:")
|
| 187 |
+
output.append(" Step | Timestamp | Value")
|
| 188 |
+
output.append(" " + "-" * 48)
|
| 189 |
+
for v in values:
|
| 190 |
+
step = v.get("step", "N/A")
|
| 191 |
+
ts = v.get("timestamp", "N/A")
|
| 192 |
+
val = v.get("value", "N/A")
|
| 193 |
+
output.append(f" {step} | {ts} | {val}")
|
| 194 |
+
|
| 195 |
+
return "\n".join(output)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def format_alerts(alerts: list[dict]) -> str:
|
| 199 |
+
"""Format alerts in human-readable format."""
|
| 200 |
+
if not alerts:
|
| 201 |
+
return "No alerts found."
|
| 202 |
+
|
| 203 |
+
output = [f"Found {len(alerts)} alert(s):\n"]
|
| 204 |
+
output.append("Timestamp | Run | Level | Title | Text | Step")
|
| 205 |
+
output.append("-" * 80)
|
| 206 |
+
|
| 207 |
+
for a in alerts:
|
| 208 |
+
ts = a.get("timestamp", "N/A")
|
| 209 |
+
run = a.get("run", "N/A")
|
| 210 |
+
level = a.get("level", "N/A").upper()
|
| 211 |
+
title = a.get("title", "")
|
| 212 |
+
text = a.get("text", "") or ""
|
| 213 |
+
step = a.get("step", "N/A")
|
| 214 |
+
output.append(f"{ts} | {run} | {level} | {title} | {text} | {step}")
|
| 215 |
+
|
| 216 |
+
return "\n".join(output)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def format_query_result(result: dict[str, Any]) -> str:
|
| 220 |
+
"""Format SQL query results in human-readable format."""
|
| 221 |
+
columns = result.get("columns", [])
|
| 222 |
+
rows = result.get("rows", [])
|
| 223 |
+
row_count = result.get("row_count", 0)
|
| 224 |
+
|
| 225 |
+
if not columns:
|
| 226 |
+
return f"Query returned {row_count} row(s)."
|
| 227 |
+
|
| 228 |
+
rendered_rows = []
|
| 229 |
+
for row in rows:
|
| 230 |
+
rendered_rows.append(
|
| 231 |
+
[
|
| 232 |
+
"" if row.get(column) is None else str(row.get(column))
|
| 233 |
+
for column in columns
|
| 234 |
+
]
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
widths = []
|
| 238 |
+
for idx, column in enumerate(columns):
|
| 239 |
+
cell_width = max(
|
| 240 |
+
(len(rendered_row[idx]) for rendered_row in rendered_rows), default=0
|
| 241 |
+
)
|
| 242 |
+
widths.append(max(len(column), cell_width))
|
| 243 |
+
|
| 244 |
+
header = " | ".join(
|
| 245 |
+
column.ljust(width) for column, width in zip(columns, widths, strict=False)
|
| 246 |
+
)
|
| 247 |
+
separator = "-+-".join("-" * width for width in widths)
|
| 248 |
+
output = [f"Query returned {row_count} row(s).", header, separator]
|
| 249 |
+
|
| 250 |
+
if not rendered_rows:
|
| 251 |
+
output.append("(no rows)")
|
| 252 |
+
return "\n".join(output)
|
| 253 |
+
|
| 254 |
+
for rendered_row in rendered_rows:
|
| 255 |
+
output.append(
|
| 256 |
+
" | ".join(
|
| 257 |
+
value.ljust(width)
|
| 258 |
+
for value, width in zip(rendered_row, widths, strict=False)
|
| 259 |
+
)
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return "\n".join(output)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def error_exit(message: str, code: int = 1) -> None:
|
| 266 |
+
"""Print error message and exit."""
|
| 267 |
+
print(f"Error: {message}", file=sys.stderr)
|
| 268 |
+
sys.exit(code)
|
trackio/commit_scheduler.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Originally copied from https://github.com/huggingface/huggingface_hub/blob/d0a948fc2a32ed6e557042a95ef3e4af97ec4a7c/src/huggingface_hub/_commit_scheduler.py
|
| 2 |
+
|
| 3 |
+
import atexit
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
from concurrent.futures import Future
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from threading import Lock, Thread
|
| 10 |
+
from typing import Callable, Dict, List, Union
|
| 11 |
+
|
| 12 |
+
from huggingface_hub.hf_api import (
|
| 13 |
+
DEFAULT_IGNORE_PATTERNS,
|
| 14 |
+
CommitInfo,
|
| 15 |
+
CommitOperationAdd,
|
| 16 |
+
HfApi,
|
| 17 |
+
)
|
| 18 |
+
from huggingface_hub.utils import filter_repo_objects
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class _FileToUpload:
|
| 25 |
+
"""Temporary dataclass to store info about files to upload. Not meant to be used directly."""
|
| 26 |
+
|
| 27 |
+
local_path: Path
|
| 28 |
+
path_in_repo: str
|
| 29 |
+
size_limit: int
|
| 30 |
+
last_modified: float
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class CommitScheduler:
|
| 34 |
+
"""
|
| 35 |
+
Scheduler to upload a local folder to the Hub at regular intervals (e.g. push to hub every 5 minutes).
|
| 36 |
+
|
| 37 |
+
The recommended way to use the scheduler is to use it as a context manager. This ensures that the scheduler is
|
| 38 |
+
properly stopped and the last commit is triggered when the script ends. The scheduler can also be stopped manually
|
| 39 |
+
with the `stop` method. Checkout the [upload guide](https://huggingface.co/docs/huggingface_hub/guides/upload#scheduled-uploads)
|
| 40 |
+
to learn more about how to use it.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
repo_id (`str`):
|
| 44 |
+
The id of the repo to commit to.
|
| 45 |
+
folder_path (`str` or `Path`):
|
| 46 |
+
Path to the local folder to upload regularly.
|
| 47 |
+
every (`int` or `float`, *optional*):
|
| 48 |
+
The number of minutes between each commit. Defaults to 5 minutes.
|
| 49 |
+
path_in_repo (`str`, *optional*):
|
| 50 |
+
Relative path of the directory in the repo, for example: `"checkpoints/"`. Defaults to the root folder
|
| 51 |
+
of the repository.
|
| 52 |
+
repo_type (`str`, *optional*):
|
| 53 |
+
The type of the repo to commit to. Defaults to `model`.
|
| 54 |
+
revision (`str`, *optional*):
|
| 55 |
+
The revision of the repo to commit to. Defaults to `main`.
|
| 56 |
+
private (`bool`, *optional*):
|
| 57 |
+
Whether to make the repo private. If `None` (default), the repo will be public unless the organization's default is private. This value is ignored if the repo already exists.
|
| 58 |
+
token (`str`, *optional*):
|
| 59 |
+
The token to use to commit to the repo. Defaults to the token saved on the machine.
|
| 60 |
+
allow_patterns (`List[str]` or `str`, *optional*):
|
| 61 |
+
If provided, only files matching at least one pattern are uploaded.
|
| 62 |
+
ignore_patterns (`List[str]` or `str`, *optional*):
|
| 63 |
+
If provided, files matching any of the patterns are not uploaded.
|
| 64 |
+
squash_history (`bool`, *optional*):
|
| 65 |
+
Whether to squash the history of the repo after each commit. Defaults to `False`. Squashing commits is
|
| 66 |
+
useful to avoid degraded performances on the repo when it grows too large.
|
| 67 |
+
hf_api (`HfApi`, *optional*):
|
| 68 |
+
The [`HfApi`] client to use to commit to the Hub. Can be set with custom settings (user agent, token,...).
|
| 69 |
+
on_before_commit (`Callable[[], None]`, *optional*):
|
| 70 |
+
If specified, a function that will be called before the CommitScheduler lists files to create a commit.
|
| 71 |
+
|
| 72 |
+
Example:
|
| 73 |
+
```py
|
| 74 |
+
>>> from pathlib import Path
|
| 75 |
+
>>> from huggingface_hub import CommitScheduler
|
| 76 |
+
|
| 77 |
+
# Scheduler uploads every 10 minutes
|
| 78 |
+
>>> csv_path = Path("watched_folder/data.csv")
|
| 79 |
+
>>> CommitScheduler(repo_id="test_scheduler", repo_type="dataset", folder_path=csv_path.parent, every=10)
|
| 80 |
+
|
| 81 |
+
>>> with csv_path.open("a") as f:
|
| 82 |
+
... f.write("first line")
|
| 83 |
+
|
| 84 |
+
# Some time later (...)
|
| 85 |
+
>>> with csv_path.open("a") as f:
|
| 86 |
+
... f.write("second line")
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Example using a context manager:
|
| 90 |
+
```py
|
| 91 |
+
>>> from pathlib import Path
|
| 92 |
+
>>> from huggingface_hub import CommitScheduler
|
| 93 |
+
|
| 94 |
+
>>> with CommitScheduler(repo_id="test_scheduler", repo_type="dataset", folder_path="watched_folder", every=10) as scheduler:
|
| 95 |
+
... csv_path = Path("watched_folder/data.csv")
|
| 96 |
+
... with csv_path.open("a") as f:
|
| 97 |
+
... f.write("first line")
|
| 98 |
+
... (...)
|
| 99 |
+
... with csv_path.open("a") as f:
|
| 100 |
+
... f.write("second line")
|
| 101 |
+
|
| 102 |
+
# Scheduler is now stopped and last commit have been triggered
|
| 103 |
+
```
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
def __init__(
|
| 107 |
+
self,
|
| 108 |
+
*,
|
| 109 |
+
repo_id: str,
|
| 110 |
+
folder_path: Union[str, Path],
|
| 111 |
+
every: Union[int, float] = 5,
|
| 112 |
+
path_in_repo: str | None = None,
|
| 113 |
+
repo_type: str | None = None,
|
| 114 |
+
revision: str | None = None,
|
| 115 |
+
private: bool | None = None,
|
| 116 |
+
token: str | None = None,
|
| 117 |
+
allow_patterns: list[str] | str | None = None,
|
| 118 |
+
ignore_patterns: list[str] | str | None = None,
|
| 119 |
+
squash_history: bool = False,
|
| 120 |
+
hf_api: HfApi | None = None,
|
| 121 |
+
on_before_commit: Callable[[], None] | None = None,
|
| 122 |
+
) -> None:
|
| 123 |
+
self.api = hf_api or HfApi(token=token)
|
| 124 |
+
self.on_before_commit = on_before_commit
|
| 125 |
+
|
| 126 |
+
# Folder
|
| 127 |
+
self.folder_path = Path(folder_path).expanduser().resolve()
|
| 128 |
+
self.path_in_repo = path_in_repo or ""
|
| 129 |
+
self.allow_patterns = allow_patterns
|
| 130 |
+
|
| 131 |
+
if ignore_patterns is None:
|
| 132 |
+
ignore_patterns = []
|
| 133 |
+
elif isinstance(ignore_patterns, str):
|
| 134 |
+
ignore_patterns = [ignore_patterns]
|
| 135 |
+
self.ignore_patterns = ignore_patterns + DEFAULT_IGNORE_PATTERNS
|
| 136 |
+
|
| 137 |
+
if self.folder_path.is_file():
|
| 138 |
+
raise ValueError(
|
| 139 |
+
f"'folder_path' must be a directory, not a file: '{self.folder_path}'."
|
| 140 |
+
)
|
| 141 |
+
self.folder_path.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
|
| 143 |
+
# Repository
|
| 144 |
+
repo_url = self.api.create_repo(
|
| 145 |
+
repo_id=repo_id, private=private, repo_type=repo_type, exist_ok=True
|
| 146 |
+
)
|
| 147 |
+
self.repo_id = repo_url.repo_id
|
| 148 |
+
self.repo_type = repo_type
|
| 149 |
+
self.revision = revision
|
| 150 |
+
self.token = token
|
| 151 |
+
|
| 152 |
+
self.last_uploaded: Dict[Path, float] = {}
|
| 153 |
+
self.last_push_time: float | None = None
|
| 154 |
+
|
| 155 |
+
if not every > 0:
|
| 156 |
+
raise ValueError(f"'every' must be a positive integer, not '{every}'.")
|
| 157 |
+
self.lock = Lock()
|
| 158 |
+
self.every = every
|
| 159 |
+
self.squash_history = squash_history
|
| 160 |
+
|
| 161 |
+
logger.info(
|
| 162 |
+
f"Scheduled job to push '{self.folder_path}' to '{self.repo_id}' every {self.every} minutes."
|
| 163 |
+
)
|
| 164 |
+
self._scheduler_thread = Thread(target=self._run_scheduler, daemon=True)
|
| 165 |
+
self._scheduler_thread.start()
|
| 166 |
+
atexit.register(self._push_to_hub)
|
| 167 |
+
|
| 168 |
+
self.__stopped = False
|
| 169 |
+
|
| 170 |
+
def stop(self) -> None:
|
| 171 |
+
"""Stop the scheduler.
|
| 172 |
+
|
| 173 |
+
A stopped scheduler cannot be restarted. Mostly for tests purposes.
|
| 174 |
+
"""
|
| 175 |
+
self.__stopped = True
|
| 176 |
+
|
| 177 |
+
def __enter__(self) -> "CommitScheduler":
|
| 178 |
+
return self
|
| 179 |
+
|
| 180 |
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
| 181 |
+
# Upload last changes before exiting
|
| 182 |
+
self.trigger().result()
|
| 183 |
+
self.stop()
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
def _run_scheduler(self) -> None:
|
| 187 |
+
"""Dumb thread waiting between each scheduled push to Hub."""
|
| 188 |
+
while True:
|
| 189 |
+
self.last_future = self.trigger()
|
| 190 |
+
time.sleep(self.every * 60)
|
| 191 |
+
if self.__stopped:
|
| 192 |
+
break
|
| 193 |
+
|
| 194 |
+
def trigger(self) -> Future:
|
| 195 |
+
"""Trigger a `push_to_hub` and return a future.
|
| 196 |
+
|
| 197 |
+
This method is automatically called every `every` minutes. You can also call it manually to trigger a commit
|
| 198 |
+
immediately, without waiting for the next scheduled commit.
|
| 199 |
+
"""
|
| 200 |
+
return self.api.run_as_future(self._push_to_hub)
|
| 201 |
+
|
| 202 |
+
def _push_to_hub(self) -> CommitInfo | None:
|
| 203 |
+
if self.__stopped: # If stopped, already scheduled commits are ignored
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
logger.info("(Background) scheduled commit triggered.")
|
| 207 |
+
try:
|
| 208 |
+
value = self.push_to_hub()
|
| 209 |
+
if self.squash_history:
|
| 210 |
+
logger.info("(Background) squashing repo history.")
|
| 211 |
+
self.api.super_squash_history(
|
| 212 |
+
repo_id=self.repo_id, repo_type=self.repo_type, branch=self.revision
|
| 213 |
+
)
|
| 214 |
+
return value
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.error(
|
| 217 |
+
f"Error while pushing to Hub: {e}"
|
| 218 |
+
) # Depending on the setup, error might be silenced
|
| 219 |
+
raise
|
| 220 |
+
|
| 221 |
+
def push_to_hub(self) -> CommitInfo | None:
|
| 222 |
+
"""
|
| 223 |
+
Push folder to the Hub and return the commit info.
|
| 224 |
+
|
| 225 |
+
<Tip warning={true}>
|
| 226 |
+
|
| 227 |
+
This method is not meant to be called directly. It is run in the background by the scheduler, respecting a
|
| 228 |
+
queue mechanism to avoid concurrent commits. Making a direct call to the method might lead to concurrency
|
| 229 |
+
issues.
|
| 230 |
+
|
| 231 |
+
</Tip>
|
| 232 |
+
|
| 233 |
+
The default behavior of `push_to_hub` is to assume an append-only folder. It lists all files in the folder and
|
| 234 |
+
uploads only changed files. If no changes are found, the method returns without committing anything. If you want
|
| 235 |
+
to change this behavior, you can inherit from [`CommitScheduler`] and override this method. This can be useful
|
| 236 |
+
for example to compress data together in a single file before committing. For more details and examples, check
|
| 237 |
+
out our [integration guide](https://huggingface.co/docs/huggingface_hub/main/en/guides/upload#scheduled-uploads).
|
| 238 |
+
"""
|
| 239 |
+
# Check files to upload (with lock)
|
| 240 |
+
with self.lock:
|
| 241 |
+
if self.on_before_commit is not None:
|
| 242 |
+
self.on_before_commit()
|
| 243 |
+
|
| 244 |
+
logger.debug("Listing files to upload for scheduled commit.")
|
| 245 |
+
|
| 246 |
+
# List files from folder (taken from `_prepare_upload_folder_additions`)
|
| 247 |
+
relpath_to_abspath = {
|
| 248 |
+
path.relative_to(self.folder_path).as_posix(): path
|
| 249 |
+
for path in sorted(
|
| 250 |
+
self.folder_path.glob("**/*")
|
| 251 |
+
) # sorted to be deterministic
|
| 252 |
+
if path.is_file()
|
| 253 |
+
}
|
| 254 |
+
prefix = f"{self.path_in_repo.strip('/')}/" if self.path_in_repo else ""
|
| 255 |
+
|
| 256 |
+
# Filter with pattern + filter out unchanged files + retrieve current file size
|
| 257 |
+
files_to_upload: List[_FileToUpload] = []
|
| 258 |
+
for relpath in filter_repo_objects(
|
| 259 |
+
relpath_to_abspath.keys(),
|
| 260 |
+
allow_patterns=self.allow_patterns,
|
| 261 |
+
ignore_patterns=self.ignore_patterns,
|
| 262 |
+
):
|
| 263 |
+
local_path = relpath_to_abspath[relpath]
|
| 264 |
+
stat = local_path.stat()
|
| 265 |
+
if (
|
| 266 |
+
self.last_uploaded.get(local_path) is None
|
| 267 |
+
or self.last_uploaded[local_path] != stat.st_mtime
|
| 268 |
+
):
|
| 269 |
+
files_to_upload.append(
|
| 270 |
+
_FileToUpload(
|
| 271 |
+
local_path=local_path,
|
| 272 |
+
path_in_repo=prefix + relpath,
|
| 273 |
+
size_limit=stat.st_size,
|
| 274 |
+
last_modified=stat.st_mtime,
|
| 275 |
+
)
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Return if nothing to upload
|
| 279 |
+
if len(files_to_upload) == 0:
|
| 280 |
+
logger.debug("Dropping schedule commit: no changed file to upload.")
|
| 281 |
+
return None
|
| 282 |
+
|
| 283 |
+
# Convert `_FileToUpload` as `CommitOperationAdd` (=> compute file shas + limit to file size)
|
| 284 |
+
logger.debug("Removing unchanged files since previous scheduled commit.")
|
| 285 |
+
add_operations = [
|
| 286 |
+
CommitOperationAdd(
|
| 287 |
+
# TODO: Cap the file to its current size, even if the user append data to it while a scheduled commit is happening
|
| 288 |
+
# (requires an upstream fix for XET-535: `hf_xet` should support `BinaryIO` for upload)
|
| 289 |
+
path_or_fileobj=file_to_upload.local_path,
|
| 290 |
+
path_in_repo=file_to_upload.path_in_repo,
|
| 291 |
+
)
|
| 292 |
+
for file_to_upload in files_to_upload
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
# Upload files (append mode expected - no need for lock)
|
| 296 |
+
logger.debug("Uploading files for scheduled commit.")
|
| 297 |
+
commit_info = self.api.create_commit(
|
| 298 |
+
repo_id=self.repo_id,
|
| 299 |
+
repo_type=self.repo_type,
|
| 300 |
+
operations=add_operations,
|
| 301 |
+
commit_message="Scheduled Commit",
|
| 302 |
+
revision=self.revision,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
for file in files_to_upload:
|
| 306 |
+
self.last_uploaded[file.local_path] = file.last_modified
|
| 307 |
+
|
| 308 |
+
self.last_push_time = time.time()
|
| 309 |
+
|
| 310 |
+
return commit_info
|
trackio/compression.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import gzip
|
| 4 |
+
import io
|
| 5 |
+
from typing import NoReturn
|
| 6 |
+
|
| 7 |
+
from starlette.datastructures import Headers, MutableHeaders
|
| 8 |
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import brotli
|
| 12 |
+
|
| 13 |
+
HAS_BROTLI = True
|
| 14 |
+
except ImportError:
|
| 15 |
+
brotli = None
|
| 16 |
+
HAS_BROTLI = False
|
| 17 |
+
|
| 18 |
+
COMPRESSIBLE_CONTENT_TYPES = (
|
| 19 |
+
"application/json",
|
| 20 |
+
"application/javascript",
|
| 21 |
+
"application/manifest+json",
|
| 22 |
+
"application/xml",
|
| 23 |
+
"application/xhtml+xml",
|
| 24 |
+
"image/svg+xml",
|
| 25 |
+
"text/",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class CompressionMiddleware:
|
| 30 |
+
"""Negotiates Brotli, then gzip, then identity based on Accept-Encoding.
|
| 31 |
+
|
| 32 |
+
Only text-like content types are compressed, so already-compressed media
|
| 33 |
+
(images, audio, video) and zero-copy file sends pass through untouched.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
app: ASGIApp,
|
| 39 |
+
minimum_size: int = 512,
|
| 40 |
+
gzip_level: int = 6,
|
| 41 |
+
brotli_quality: int = 5,
|
| 42 |
+
) -> None:
|
| 43 |
+
self.app = app
|
| 44 |
+
self.minimum_size = minimum_size
|
| 45 |
+
self.gzip_level = gzip_level
|
| 46 |
+
self.brotli_quality = brotli_quality
|
| 47 |
+
|
| 48 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 49 |
+
if scope["type"] != "http":
|
| 50 |
+
await self.app(scope, receive, send)
|
| 51 |
+
return
|
| 52 |
+
|
| 53 |
+
accept_encoding = Headers(scope=scope).get("Accept-Encoding", "")
|
| 54 |
+
responder: _CompressionResponder
|
| 55 |
+
if HAS_BROTLI and "br" in accept_encoding:
|
| 56 |
+
responder = _BrotliResponder(
|
| 57 |
+
self.app, self.minimum_size, self.brotli_quality
|
| 58 |
+
)
|
| 59 |
+
elif "gzip" in accept_encoding:
|
| 60 |
+
responder = _GZipResponder(self.app, self.minimum_size, self.gzip_level)
|
| 61 |
+
else:
|
| 62 |
+
responder = _CompressionResponder(self.app, self.minimum_size)
|
| 63 |
+
|
| 64 |
+
await responder(scope, receive, send)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _CompressionResponder:
|
| 68 |
+
content_encoding: str = ""
|
| 69 |
+
|
| 70 |
+
def __init__(self, app: ASGIApp, minimum_size: int) -> None:
|
| 71 |
+
self.app = app
|
| 72 |
+
self.minimum_size = minimum_size
|
| 73 |
+
self.send: Send = _unattached_send
|
| 74 |
+
self.initial_message: Message = {}
|
| 75 |
+
self.started = False
|
| 76 |
+
self.passthrough = True
|
| 77 |
+
|
| 78 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 79 |
+
self.send = send
|
| 80 |
+
await self.app(scope, receive, self.send_with_compression)
|
| 81 |
+
|
| 82 |
+
async def send_with_compression(self, message: Message) -> None:
|
| 83 |
+
message_type = message["type"]
|
| 84 |
+
if message_type == "http.response.start":
|
| 85 |
+
self.initial_message = message
|
| 86 |
+
headers = Headers(raw=message["headers"])
|
| 87 |
+
content_type = headers.get("content-type", "")
|
| 88 |
+
already_encoded = "content-encoding" in headers
|
| 89 |
+
self.passthrough = already_encoded or not content_type.startswith(
|
| 90 |
+
COMPRESSIBLE_CONTENT_TYPES
|
| 91 |
+
)
|
| 92 |
+
elif message_type == "http.response.body" and self.passthrough:
|
| 93 |
+
if not self.started:
|
| 94 |
+
self.started = True
|
| 95 |
+
await self.send(self.initial_message)
|
| 96 |
+
await self.send(message)
|
| 97 |
+
elif message_type == "http.response.body" and not self.started:
|
| 98 |
+
self.started = True
|
| 99 |
+
body = message.get("body", b"")
|
| 100 |
+
more_body = message.get("more_body", False)
|
| 101 |
+
if len(body) < self.minimum_size and not more_body:
|
| 102 |
+
await self.send(self.initial_message)
|
| 103 |
+
await self.send(message)
|
| 104 |
+
else:
|
| 105 |
+
compressed = self.apply_compression(body, more_body=more_body)
|
| 106 |
+
headers = MutableHeaders(raw=self.initial_message["headers"])
|
| 107 |
+
headers.add_vary_header("Accept-Encoding")
|
| 108 |
+
if compressed != body:
|
| 109 |
+
headers["Content-Encoding"] = self.content_encoding
|
| 110 |
+
if more_body:
|
| 111 |
+
del headers["Content-Length"]
|
| 112 |
+
else:
|
| 113 |
+
headers["Content-Length"] = str(len(compressed))
|
| 114 |
+
message["body"] = compressed
|
| 115 |
+
await self.send(self.initial_message)
|
| 116 |
+
await self.send(message)
|
| 117 |
+
elif message_type == "http.response.body":
|
| 118 |
+
body = message.get("body", b"")
|
| 119 |
+
more_body = message.get("more_body", False)
|
| 120 |
+
message["body"] = self.apply_compression(body, more_body=more_body)
|
| 121 |
+
await self.send(message)
|
| 122 |
+
elif message_type == "http.response.pathsend":
|
| 123 |
+
await self.send(self.initial_message)
|
| 124 |
+
await self.send(message)
|
| 125 |
+
|
| 126 |
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
| 127 |
+
return body
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class _GZipResponder(_CompressionResponder):
|
| 131 |
+
content_encoding = "gzip"
|
| 132 |
+
|
| 133 |
+
def __init__(self, app: ASGIApp, minimum_size: int, compresslevel: int) -> None:
|
| 134 |
+
super().__init__(app, minimum_size)
|
| 135 |
+
self.buffer = io.BytesIO()
|
| 136 |
+
self.file = gzip.GzipFile(
|
| 137 |
+
mode="wb", fileobj=self.buffer, compresslevel=compresslevel
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 141 |
+
with self.buffer, self.file:
|
| 142 |
+
await super().__call__(scope, receive, send)
|
| 143 |
+
|
| 144 |
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
| 145 |
+
self.file.write(body)
|
| 146 |
+
if not more_body:
|
| 147 |
+
self.file.close()
|
| 148 |
+
data = self.buffer.getvalue()
|
| 149 |
+
self.buffer.seek(0)
|
| 150 |
+
self.buffer.truncate()
|
| 151 |
+
return data
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class _BrotliResponder(_CompressionResponder):
|
| 155 |
+
content_encoding = "br"
|
| 156 |
+
|
| 157 |
+
def __init__(self, app: ASGIApp, minimum_size: int, quality: int) -> None:
|
| 158 |
+
super().__init__(app, minimum_size)
|
| 159 |
+
self.compressor = brotli.Compressor(quality=quality)
|
| 160 |
+
|
| 161 |
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
| 162 |
+
data = self.compressor.process(body)
|
| 163 |
+
if not more_body:
|
| 164 |
+
data += self.compressor.finish()
|
| 165 |
+
return data
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
async def _unattached_send(message: Message) -> NoReturn:
|
| 169 |
+
raise RuntimeError("send awaitable not set")
|
trackio/context_vars.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextvars
|
| 2 |
+
from typing import TYPE_CHECKING
|
| 3 |
+
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from trackio.run import Run
|
| 6 |
+
|
| 7 |
+
current_run: contextvars.ContextVar["Run | None"] = contextvars.ContextVar(
|
| 8 |
+
"current_run", default=None
|
| 9 |
+
)
|
| 10 |
+
current_project: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
| 11 |
+
"current_project", default=None
|
| 12 |
+
)
|
| 13 |
+
current_server: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
| 14 |
+
"current_server", default=None
|
| 15 |
+
)
|
| 16 |
+
current_space_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
| 17 |
+
"current_space_id", default=None
|
| 18 |
+
)
|
| 19 |
+
current_server_write_token: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
| 20 |
+
"current_server_write_token", default=None
|
| 21 |
+
)
|
trackio/cpu.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib
|
| 2 |
+
import threading
|
| 3 |
+
import time
|
| 4 |
+
import warnings
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
from trackio import context_vars
|
| 8 |
+
|
| 9 |
+
if TYPE_CHECKING:
|
| 10 |
+
from trackio.run import Run
|
| 11 |
+
|
| 12 |
+
psutil: Any = None
|
| 13 |
+
PSUTIL_AVAILABLE = False
|
| 14 |
+
_psutil_lock = threading.Lock()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _ensure_psutil():
|
| 18 |
+
global PSUTIL_AVAILABLE, psutil
|
| 19 |
+
if PSUTIL_AVAILABLE:
|
| 20 |
+
return psutil
|
| 21 |
+
with _psutil_lock:
|
| 22 |
+
if PSUTIL_AVAILABLE:
|
| 23 |
+
return psutil
|
| 24 |
+
try:
|
| 25 |
+
psutil = importlib.import_module("psutil")
|
| 26 |
+
PSUTIL_AVAILABLE = True
|
| 27 |
+
return psutil
|
| 28 |
+
except ImportError:
|
| 29 |
+
raise ImportError(
|
| 30 |
+
"psutil is required for CPU and RAM monitoring. "
|
| 31 |
+
"Install it with: pip install psutil"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def cpu_available() -> bool:
|
| 36 |
+
"""
|
| 37 |
+
Check if CPU and RAM monitoring is available.
|
| 38 |
+
|
| 39 |
+
Returns True if psutil is installed.
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
_ensure_psutil()
|
| 43 |
+
return True
|
| 44 |
+
except ImportError:
|
| 45 |
+
return False
|
| 46 |
+
except Exception:
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def collect_cpu_metrics(
|
| 51 |
+
prev_disk_counters: Any = None,
|
| 52 |
+
prev_net_counters: Any = None,
|
| 53 |
+
elapsed: float | None = None,
|
| 54 |
+
include_static: bool = True,
|
| 55 |
+
) -> dict:
|
| 56 |
+
"""
|
| 57 |
+
Collect CPU, RAM, disk, network, and sensor metrics using psutil.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
prev_disk_counters: Previous disk I/O counters for computing read/write rates.
|
| 61 |
+
If None, only cumulative values are reported.
|
| 62 |
+
prev_net_counters: Previous network I/O counters for computing send/recv rates.
|
| 63 |
+
If None, only cumulative values are reported.
|
| 64 |
+
elapsed: Seconds since prev_disk_counters and prev_net_counters were captured.
|
| 65 |
+
If None or non-positive, cumulative values are reported instead of rates.
|
| 66 |
+
include_static: Whether to include mostly static metrics such as CPU
|
| 67 |
+
frequency and core counts.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Dictionary of system metrics.
|
| 71 |
+
"""
|
| 72 |
+
if not PSUTIL_AVAILABLE:
|
| 73 |
+
try:
|
| 74 |
+
_ensure_psutil()
|
| 75 |
+
except ImportError:
|
| 76 |
+
return {}
|
| 77 |
+
|
| 78 |
+
metrics = {}
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
per_core = psutil.cpu_percent(interval=0.1, percpu=True)
|
| 82 |
+
for i, pct in enumerate(per_core):
|
| 83 |
+
metrics[f"cpu/{i}/utilization"] = pct
|
| 84 |
+
if per_core:
|
| 85 |
+
metrics["cpu/utilization"] = sum(per_core) / len(per_core)
|
| 86 |
+
except Exception:
|
| 87 |
+
pass
|
| 88 |
+
|
| 89 |
+
if include_static:
|
| 90 |
+
try:
|
| 91 |
+
cpu_freq = psutil.cpu_freq()
|
| 92 |
+
if cpu_freq:
|
| 93 |
+
metrics["cpu/frequency"] = cpu_freq.current
|
| 94 |
+
if cpu_freq.max > 0:
|
| 95 |
+
metrics["cpu/frequency_max"] = cpu_freq.max
|
| 96 |
+
except Exception:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
try:
|
| 100 |
+
cpu_count_logical = psutil.cpu_count(logical=True)
|
| 101 |
+
if cpu_count_logical is not None:
|
| 102 |
+
metrics["cpu/count_logical"] = cpu_count_logical
|
| 103 |
+
cpu_count_physical = psutil.cpu_count(logical=False)
|
| 104 |
+
if cpu_count_physical is not None:
|
| 105 |
+
metrics["cpu/count_physical"] = cpu_count_physical
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
mem = psutil.virtual_memory()
|
| 111 |
+
metrics["memory/used"] = mem.used / (1024**3)
|
| 112 |
+
metrics["memory/total"] = mem.total / (1024**3)
|
| 113 |
+
metrics["memory/available"] = mem.available / (1024**3)
|
| 114 |
+
metrics["memory/percent"] = mem.percent
|
| 115 |
+
except Exception:
|
| 116 |
+
pass
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
swap = psutil.swap_memory()
|
| 120 |
+
metrics["swap/used"] = swap.used / (1024**3)
|
| 121 |
+
metrics["swap/total"] = swap.total / (1024**3)
|
| 122 |
+
metrics["swap/percent"] = swap.percent
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
disk = psutil.disk_io_counters()
|
| 128 |
+
if disk is not None:
|
| 129 |
+
if prev_disk_counters is not None and elapsed and elapsed > 0:
|
| 130 |
+
metrics["disk/read_mb_per_sec"] = (
|
| 131 |
+
(disk.read_bytes - prev_disk_counters.read_bytes)
|
| 132 |
+
/ elapsed
|
| 133 |
+
/ (1024**2)
|
| 134 |
+
)
|
| 135 |
+
metrics["disk/write_mb_per_sec"] = (
|
| 136 |
+
(disk.write_bytes - prev_disk_counters.write_bytes)
|
| 137 |
+
/ elapsed
|
| 138 |
+
/ (1024**2)
|
| 139 |
+
)
|
| 140 |
+
metrics["disk/read_iops"] = (
|
| 141 |
+
disk.read_count - prev_disk_counters.read_count
|
| 142 |
+
) / elapsed
|
| 143 |
+
metrics["disk/write_iops"] = (
|
| 144 |
+
disk.write_count - prev_disk_counters.write_count
|
| 145 |
+
) / elapsed
|
| 146 |
+
else:
|
| 147 |
+
metrics["disk/read_bytes"] = disk.read_bytes
|
| 148 |
+
metrics["disk/write_bytes"] = disk.write_bytes
|
| 149 |
+
except Exception:
|
| 150 |
+
pass
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
net = psutil.net_io_counters()
|
| 154 |
+
if net is not None:
|
| 155 |
+
if prev_net_counters is not None and elapsed and elapsed > 0:
|
| 156 |
+
metrics["network/sent_mb_per_sec"] = (
|
| 157 |
+
(net.bytes_sent - prev_net_counters.bytes_sent)
|
| 158 |
+
/ elapsed
|
| 159 |
+
/ (1024**2)
|
| 160 |
+
)
|
| 161 |
+
metrics["network/recv_mb_per_sec"] = (
|
| 162 |
+
(net.bytes_recv - prev_net_counters.bytes_recv)
|
| 163 |
+
/ elapsed
|
| 164 |
+
/ (1024**2)
|
| 165 |
+
)
|
| 166 |
+
else:
|
| 167 |
+
metrics["network/sent_bytes"] = net.bytes_sent
|
| 168 |
+
metrics["network/recv_bytes"] = net.bytes_recv
|
| 169 |
+
except Exception:
|
| 170 |
+
pass
|
| 171 |
+
|
| 172 |
+
try:
|
| 173 |
+
sensors = psutil.sensors_temperatures()
|
| 174 |
+
if sensors:
|
| 175 |
+
for chip_name, entries in sensors.items():
|
| 176 |
+
for i, entry in enumerate(entries):
|
| 177 |
+
label = (
|
| 178 |
+
entry.label.strip()
|
| 179 |
+
if entry.label and entry.label.strip()
|
| 180 |
+
else f"{chip_name}_{i}"
|
| 181 |
+
)
|
| 182 |
+
metrics[f"temp/{label}"] = entry.current
|
| 183 |
+
except Exception:
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
battery = psutil.sensors_battery()
|
| 188 |
+
if battery is not None:
|
| 189 |
+
metrics["battery/percent"] = battery.percent
|
| 190 |
+
metrics["battery/power_plugged"] = int(battery.power_plugged)
|
| 191 |
+
except Exception:
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
return metrics
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class CpuMonitor:
|
| 198 |
+
def __init__(self, run: "Run", interval: float = 10.0):
|
| 199 |
+
self._run = run
|
| 200 |
+
self._interval = interval
|
| 201 |
+
self._stop_flag = threading.Event()
|
| 202 |
+
self._thread: "threading.Thread | None" = None
|
| 203 |
+
self._last_disk_counters: Any = None
|
| 204 |
+
self._last_net_counters: Any = None
|
| 205 |
+
self._last_time: float | None = None
|
| 206 |
+
self._include_static_next = True
|
| 207 |
+
|
| 208 |
+
def start(self):
|
| 209 |
+
if not PSUTIL_AVAILABLE:
|
| 210 |
+
try:
|
| 211 |
+
_ensure_psutil()
|
| 212 |
+
except ImportError:
|
| 213 |
+
warnings.warn(
|
| 214 |
+
"auto_log_cpu=True but psutil is not installed. "
|
| 215 |
+
"CPU and RAM logging disabled. Install with: pip install psutil"
|
| 216 |
+
)
|
| 217 |
+
return
|
| 218 |
+
|
| 219 |
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
| 220 |
+
self._thread.start()
|
| 221 |
+
|
| 222 |
+
def stop(self):
|
| 223 |
+
self._stop_flag.set()
|
| 224 |
+
if self._thread is not None:
|
| 225 |
+
self._thread.join(timeout=2.0)
|
| 226 |
+
|
| 227 |
+
def _monitor_loop(self):
|
| 228 |
+
try:
|
| 229 |
+
self._last_disk_counters = psutil.disk_io_counters()
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
try:
|
| 233 |
+
self._last_net_counters = psutil.net_io_counters()
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
self._last_time = time.monotonic()
|
| 237 |
+
|
| 238 |
+
while not self._stop_flag.is_set():
|
| 239 |
+
self._stop_flag.wait(timeout=self._interval)
|
| 240 |
+
if self._stop_flag.is_set():
|
| 241 |
+
break
|
| 242 |
+
try:
|
| 243 |
+
now = time.monotonic()
|
| 244 |
+
elapsed = now - self._last_time if self._last_time is not None else None
|
| 245 |
+
metrics = collect_cpu_metrics(
|
| 246 |
+
prev_disk_counters=self._last_disk_counters,
|
| 247 |
+
prev_net_counters=self._last_net_counters,
|
| 248 |
+
elapsed=elapsed,
|
| 249 |
+
include_static=self._include_static_next,
|
| 250 |
+
)
|
| 251 |
+
self._include_static_next = False
|
| 252 |
+
try:
|
| 253 |
+
self._last_disk_counters = psutil.disk_io_counters()
|
| 254 |
+
except Exception:
|
| 255 |
+
self._last_disk_counters = None
|
| 256 |
+
try:
|
| 257 |
+
self._last_net_counters = psutil.net_io_counters()
|
| 258 |
+
except Exception:
|
| 259 |
+
self._last_net_counters = None
|
| 260 |
+
self._last_time = now
|
| 261 |
+
if metrics:
|
| 262 |
+
self._run.log_system(metrics)
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def log_cpu(run: "Run | None" = None) -> dict:
|
| 268 |
+
"""
|
| 269 |
+
Log CPU, RAM, disk, network, and sensor metrics to the current or specified run
|
| 270 |
+
as system metrics.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
run: Optional Run instance. If None, uses current run from context.
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
dict: The system metrics that were logged.
|
| 277 |
+
|
| 278 |
+
Example:
|
| 279 |
+
```python
|
| 280 |
+
import trackio
|
| 281 |
+
|
| 282 |
+
run = trackio.init(project="my-project")
|
| 283 |
+
trackio.log({"loss": 0.5})
|
| 284 |
+
trackio.log_cpu()
|
| 285 |
+
```
|
| 286 |
+
"""
|
| 287 |
+
if run is None:
|
| 288 |
+
run = context_vars.current_run.get()
|
| 289 |
+
if run is None:
|
| 290 |
+
raise RuntimeError("Call trackio.init() before trackio.log_cpu().")
|
| 291 |
+
|
| 292 |
+
try:
|
| 293 |
+
_ensure_psutil()
|
| 294 |
+
except ImportError:
|
| 295 |
+
warnings.warn(
|
| 296 |
+
"trackio.log_cpu() requires psutil. Install it with: pip install trackio[cpu]"
|
| 297 |
+
)
|
| 298 |
+
return {}
|
| 299 |
+
|
| 300 |
+
metrics = collect_cpu_metrics()
|
| 301 |
+
if metrics:
|
| 302 |
+
run.log_system(metrics)
|
| 303 |
+
return metrics
|
trackio/deploy.py
ADDED
|
@@ -0,0 +1,1313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib.metadata
|
| 2 |
+
import io
|
| 3 |
+
import json as json_mod
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
import warnings
|
| 9 |
+
from collections import Counter
|
| 10 |
+
from importlib.resources import files
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
import huggingface_hub
|
| 15 |
+
from gradio_client import handle_file
|
| 16 |
+
from httpx import ReadTimeout
|
| 17 |
+
from huggingface_hub import Volume
|
| 18 |
+
from huggingface_hub.errors import (
|
| 19 |
+
BucketNotFoundError,
|
| 20 |
+
HfHubHTTPError,
|
| 21 |
+
RepositoryNotFoundError,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
import trackio
|
| 25 |
+
from trackio.bucket_storage import (
|
| 26 |
+
create_bucket_if_not_exists,
|
| 27 |
+
export_from_bucket_for_static,
|
| 28 |
+
upload_project_to_bucket,
|
| 29 |
+
upload_project_to_bucket_for_static,
|
| 30 |
+
)
|
| 31 |
+
from trackio.cas import PARTIAL_BLOB_GLOB
|
| 32 |
+
from trackio.frontend_config import resolve_frontend_dir
|
| 33 |
+
from trackio.pending_uploads import replay_pending_uploads
|
| 34 |
+
from trackio.remote_client import RemoteClient
|
| 35 |
+
from trackio.sqlite_storage import SQLiteStorage
|
| 36 |
+
from trackio.utils import (
|
| 37 |
+
get_or_create_project_hash,
|
| 38 |
+
on_spaces,
|
| 39 |
+
preprocess_space_and_dataset_ids,
|
| 40 |
+
project_artifacts_dir,
|
| 41 |
+
project_media_dir,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
SPACE_HOST_URL = "https://{user_name}-{space_name}.hf.space/"
|
| 45 |
+
SPACE_URL = "https://huggingface.co/spaces/{space_id}"
|
| 46 |
+
_BOLD_ORANGE = "\033[1m\033[38;5;208m"
|
| 47 |
+
_RESET = "\033[0m"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def raise_if_space_is_frozen_for_logging(space_id: str) -> None:
|
| 51 |
+
try:
|
| 52 |
+
info = huggingface_hub.HfApi().space_info(space_id)
|
| 53 |
+
except RepositoryNotFoundError:
|
| 54 |
+
return
|
| 55 |
+
if getattr(info, "sdk", None) == "static":
|
| 56 |
+
raise RuntimeError(
|
| 57 |
+
f"Cannot log to Hugging Face Space '{space_id}' because it has been frozen "
|
| 58 |
+
f"(it uses the static SDK: a read-only dashboard with no live Trackio server).\n\n"
|
| 59 |
+
f"Use a different space_id for training, or create a new Gradio Trackio Space. "
|
| 60 |
+
f"Freezing converts a live Gradio Space to static after a run; a frozen Space "
|
| 61 |
+
f'cannot accept new logs. See trackio.sync(..., sdk="static") in the Trackio docs.'
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _readme_linked_hub_yaml(dataset_id: str | None) -> str:
|
| 66 |
+
if dataset_id is not None:
|
| 67 |
+
return f"datasets:\n - {dataset_id}\n"
|
| 68 |
+
return ""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
_CUSTOM_SPACE_FRONTEND_DIR = "trackio_custom_frontend"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _space_app_py(frontend_dir: str | None = None) -> str:
|
| 75 |
+
if frontend_dir is None:
|
| 76 |
+
return "import trackio\ntrackio.show()\n"
|
| 77 |
+
return f'import trackio\ntrackio.show(frontend_dir="{frontend_dir}")\n'
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _upload_frontend_folder(
|
| 81 |
+
hf_api: huggingface_hub.HfApi,
|
| 82 |
+
*,
|
| 83 |
+
repo_id: str,
|
| 84 |
+
repo_type: str,
|
| 85 |
+
folder_path: str | Path,
|
| 86 |
+
path_in_repo: str | None = None,
|
| 87 |
+
) -> None:
|
| 88 |
+
kwargs = {
|
| 89 |
+
"repo_id": repo_id,
|
| 90 |
+
"repo_type": repo_type,
|
| 91 |
+
"folder_path": str(folder_path),
|
| 92 |
+
}
|
| 93 |
+
if path_in_repo is not None:
|
| 94 |
+
kwargs["path_in_repo"] = path_in_repo
|
| 95 |
+
hf_api.upload_folder(**kwargs)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _retry_hf_write(op_name: str, fn, retries: int = 4, initial_delay: float = 1.5):
|
| 99 |
+
delay = initial_delay
|
| 100 |
+
for attempt in range(1, retries + 1):
|
| 101 |
+
try:
|
| 102 |
+
return fn()
|
| 103 |
+
except ReadTimeout:
|
| 104 |
+
if attempt == retries:
|
| 105 |
+
raise
|
| 106 |
+
print(
|
| 107 |
+
f"* {op_name} timed out (attempt {attempt}/{retries}). Retrying in {delay:.1f}s..."
|
| 108 |
+
)
|
| 109 |
+
time.sleep(delay)
|
| 110 |
+
delay = min(delay * 2, 12)
|
| 111 |
+
except HfHubHTTPError as e:
|
| 112 |
+
status = e.response.status_code if e.response is not None else None
|
| 113 |
+
if status is None or status < 500 or attempt == retries:
|
| 114 |
+
raise
|
| 115 |
+
print(
|
| 116 |
+
f"* {op_name} failed with HTTP {status} (attempt {attempt}/{retries}). Retrying in {delay:.1f}s..."
|
| 117 |
+
)
|
| 118 |
+
time.sleep(delay)
|
| 119 |
+
delay = min(delay * 2, 12)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _get_space_volumes(
|
| 123 |
+
space_id: str, hf_api: huggingface_hub.HfApi | None = None
|
| 124 |
+
) -> list[Volume]:
|
| 125 |
+
"""
|
| 126 |
+
Return mounted volumes for a Space.
|
| 127 |
+
|
| 128 |
+
`HfApi.get_space_runtime()` does not always populate `volumes`, even when the
|
| 129 |
+
mount exists. Fall back to `space_info().runtime.volumes`, which currently
|
| 130 |
+
carries the volume metadata for running Spaces.
|
| 131 |
+
"""
|
| 132 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 133 |
+
runtime = hf_api.get_space_runtime(space_id)
|
| 134 |
+
if runtime.volumes:
|
| 135 |
+
return list(runtime.volumes)
|
| 136 |
+
|
| 137 |
+
info = hf_api.space_info(space_id)
|
| 138 |
+
if info.runtime and info.runtime.volumes:
|
| 139 |
+
return list(info.runtime.volumes)
|
| 140 |
+
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _get_space_bucket_at_data_mount(
|
| 145 |
+
space_id: str, hf_api: huggingface_hub.HfApi | None = None
|
| 146 |
+
) -> str | None:
|
| 147 |
+
for volume in _get_space_volumes(space_id, hf_api=hf_api):
|
| 148 |
+
if volume.type == "bucket" and volume.mount_path == "/data":
|
| 149 |
+
return volume.source
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _get_existing_space_bucket(
|
| 154 |
+
space_id: str, hf_api: huggingface_hub.HfApi | None = None
|
| 155 |
+
) -> str | None:
|
| 156 |
+
"""Return the Trackio bucket for a Space, preferring the canonical /data mount."""
|
| 157 |
+
bucket_at_data = _get_space_bucket_at_data_mount(space_id, hf_api=hf_api)
|
| 158 |
+
if bucket_at_data is not None:
|
| 159 |
+
return bucket_at_data
|
| 160 |
+
|
| 161 |
+
for volume in _get_space_volumes(space_id, hf_api=hf_api):
|
| 162 |
+
if volume.type == "bucket":
|
| 163 |
+
return volume.source
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _get_existing_static_space_bucket(
|
| 168 |
+
space_id: str, hf_api: huggingface_hub.HfApi | None = None
|
| 169 |
+
) -> str | None:
|
| 170 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 171 |
+
try:
|
| 172 |
+
config_path = hf_api.hf_hub_download(
|
| 173 |
+
repo_id=space_id,
|
| 174 |
+
repo_type="space",
|
| 175 |
+
filename="config.json",
|
| 176 |
+
)
|
| 177 |
+
except (FileNotFoundError, HfHubHTTPError, OSError, ValueError):
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
with open(config_path, encoding="utf-8") as config_file:
|
| 182 |
+
config = json_mod.load(config_file)
|
| 183 |
+
except (OSError, ValueError, TypeError):
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
bucket_id = config.get("bucket_id")
|
| 187 |
+
if isinstance(bucket_id, str) and bucket_id:
|
| 188 |
+
return bucket_id
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _ensure_bucket_mounted_at_data(
|
| 193 |
+
space_id: str,
|
| 194 |
+
bucket_id: str,
|
| 195 |
+
hf_api: huggingface_hub.HfApi | None = None,
|
| 196 |
+
) -> None:
|
| 197 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 198 |
+
existing = _get_space_volumes(space_id, hf_api=hf_api)
|
| 199 |
+
already_mounted = any(
|
| 200 |
+
v.type == "bucket" and v.source == bucket_id and v.mount_path == "/data"
|
| 201 |
+
for v in existing
|
| 202 |
+
)
|
| 203 |
+
if not already_mounted:
|
| 204 |
+
preserved = [
|
| 205 |
+
v
|
| 206 |
+
for v in existing
|
| 207 |
+
if not (
|
| 208 |
+
v.type == "bucket"
|
| 209 |
+
and (v.source == bucket_id or v.mount_path == "/data")
|
| 210 |
+
)
|
| 211 |
+
]
|
| 212 |
+
hf_api.set_space_volumes(
|
| 213 |
+
space_id,
|
| 214 |
+
preserved + [Volume(type="bucket", source=bucket_id, mount_path="/data")],
|
| 215 |
+
)
|
| 216 |
+
print(f"* Attached bucket {bucket_id} at '/data'")
|
| 217 |
+
|
| 218 |
+
existing_variables = hf_api.get_space_variables(space_id)
|
| 219 |
+
current_trackio_dir = getattr(existing_variables.get("TRACKIO_DIR"), "value", None)
|
| 220 |
+
if current_trackio_dir != "/data/trackio":
|
| 221 |
+
huggingface_hub.add_space_variable(space_id, "TRACKIO_DIR", "/data/trackio")
|
| 222 |
+
current_bucket_id = getattr(
|
| 223 |
+
existing_variables.get("TRACKIO_BUCKET_ID"), "value", None
|
| 224 |
+
)
|
| 225 |
+
if current_bucket_id != bucket_id:
|
| 226 |
+
huggingface_hub.add_space_variable(space_id, "TRACKIO_BUCKET_ID", bucket_id)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _bucket_exists(bucket_id: str, hf_api: huggingface_hub.HfApi | None = None) -> bool:
|
| 230 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 231 |
+
try:
|
| 232 |
+
hf_api.bucket_info(bucket_id)
|
| 233 |
+
return True
|
| 234 |
+
except BucketNotFoundError:
|
| 235 |
+
return False
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _find_available_bucket_id(
|
| 239 |
+
preferred_bucket_id: str, hf_api: huggingface_hub.HfApi | None = None
|
| 240 |
+
) -> str:
|
| 241 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 242 |
+
if not _bucket_exists(preferred_bucket_id, hf_api):
|
| 243 |
+
return preferred_bucket_id
|
| 244 |
+
|
| 245 |
+
suffix = 2
|
| 246 |
+
while True:
|
| 247 |
+
candidate = f"{preferred_bucket_id}-{suffix}"
|
| 248 |
+
if not _bucket_exists(candidate, hf_api):
|
| 249 |
+
return candidate
|
| 250 |
+
suffix += 1
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def resolve_auto_bucket_id(
|
| 254 |
+
space_id: str,
|
| 255 |
+
preferred_bucket_id: str,
|
| 256 |
+
hf_api: huggingface_hub.HfApi | None = None,
|
| 257 |
+
) -> str:
|
| 258 |
+
"""
|
| 259 |
+
Resolve the bucket to use for an auto-generated bucket ID.
|
| 260 |
+
|
| 261 |
+
Rules:
|
| 262 |
+
- Existing Space with a bucket mounted at /data -> reuse that bucket.
|
| 263 |
+
- Existing static Space with a bucket_id in config.json -> reuse that bucket.
|
| 264 |
+
- Otherwise -> use the preferred auto bucket ID if free, or a suffixed variant.
|
| 265 |
+
"""
|
| 266 |
+
hf_api = hf_api or huggingface_hub.HfApi()
|
| 267 |
+
try:
|
| 268 |
+
info = hf_api.space_info(space_id)
|
| 269 |
+
except RepositoryNotFoundError:
|
| 270 |
+
pass
|
| 271 |
+
else:
|
| 272 |
+
existing_bucket_id = _get_existing_space_bucket(space_id, hf_api=hf_api)
|
| 273 |
+
if existing_bucket_id is None and getattr(info, "sdk", None) == "static":
|
| 274 |
+
existing_bucket_id = _get_existing_static_space_bucket(
|
| 275 |
+
space_id, hf_api=hf_api
|
| 276 |
+
)
|
| 277 |
+
if existing_bucket_id is not None:
|
| 278 |
+
return existing_bucket_id
|
| 279 |
+
|
| 280 |
+
bucket_id = _find_available_bucket_id(preferred_bucket_id, hf_api)
|
| 281 |
+
if bucket_id != preferred_bucket_id:
|
| 282 |
+
print(
|
| 283 |
+
f"* Auto-generated bucket {preferred_bucket_id} already exists; "
|
| 284 |
+
f"using {bucket_id} instead"
|
| 285 |
+
)
|
| 286 |
+
return bucket_id
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _get_source_install_dependencies() -> str:
|
| 290 |
+
"""Get trackio dependencies from pyproject.toml for source installs."""
|
| 291 |
+
try:
|
| 292 |
+
import tomllib # noqa: PLC0415
|
| 293 |
+
except ModuleNotFoundError:
|
| 294 |
+
import tomli as tomllib # noqa: PLC0415
|
| 295 |
+
|
| 296 |
+
trackio_path = files("trackio")
|
| 297 |
+
pyproject_path = Path(trackio_path).parent / "pyproject.toml"
|
| 298 |
+
with open(pyproject_path, "rb") as f:
|
| 299 |
+
pyproject = tomllib.load(f)
|
| 300 |
+
deps = pyproject["project"]["dependencies"]
|
| 301 |
+
spaces_deps = (
|
| 302 |
+
pyproject["project"].get("optional-dependencies", {}).get("spaces", [])
|
| 303 |
+
)
|
| 304 |
+
mcp_deps = pyproject["project"].get("optional-dependencies", {}).get("mcp", [])
|
| 305 |
+
return "\n".join(deps + spaces_deps + mcp_deps)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _get_space_install_requirement() -> str:
|
| 309 |
+
return f"trackio[spaces,mcp]=={trackio.__version__}"
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _is_trackio_installed_from_source() -> bool:
|
| 313 |
+
"""Check if trackio is installed from source/editable install vs PyPI."""
|
| 314 |
+
try:
|
| 315 |
+
trackio_file = trackio.__file__
|
| 316 |
+
if "site-packages" not in trackio_file and "dist-packages" not in trackio_file:
|
| 317 |
+
return True
|
| 318 |
+
|
| 319 |
+
dist = importlib.metadata.distribution("trackio")
|
| 320 |
+
if dist.files:
|
| 321 |
+
files = list(dist.files)
|
| 322 |
+
has_pth = any(".pth" in str(f) for f in files)
|
| 323 |
+
if has_pth:
|
| 324 |
+
return True
|
| 325 |
+
|
| 326 |
+
return False
|
| 327 |
+
except (
|
| 328 |
+
AttributeError,
|
| 329 |
+
importlib.metadata.PackageNotFoundError,
|
| 330 |
+
importlib.metadata.MetadataError,
|
| 331 |
+
ValueError,
|
| 332 |
+
TypeError,
|
| 333 |
+
):
|
| 334 |
+
return True
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def deploy_as_space(
|
| 338 |
+
space_id: str,
|
| 339 |
+
space_storage: huggingface_hub.SpaceStorage | None = None,
|
| 340 |
+
dataset_id: str | None = None,
|
| 341 |
+
bucket_id: str | None = None,
|
| 342 |
+
private: bool | None = None,
|
| 343 |
+
frontend_dir: str | Path | None = None,
|
| 344 |
+
):
|
| 345 |
+
if on_spaces(): # in case a repo with this function is uploaded to spaces
|
| 346 |
+
return
|
| 347 |
+
|
| 348 |
+
if dataset_id is not None and bucket_id is not None:
|
| 349 |
+
raise ValueError(
|
| 350 |
+
"Cannot use bucket volume options together with dataset_id; use one persistence mode."
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
trackio_path = files("trackio")
|
| 354 |
+
|
| 355 |
+
hf_api = huggingface_hub.HfApi()
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
huggingface_hub.create_repo(
|
| 359 |
+
space_id,
|
| 360 |
+
private=private,
|
| 361 |
+
space_sdk="gradio",
|
| 362 |
+
space_storage=space_storage,
|
| 363 |
+
repo_type="space",
|
| 364 |
+
exist_ok=True,
|
| 365 |
+
)
|
| 366 |
+
except HfHubHTTPError as e:
|
| 367 |
+
if e.response.status_code in [401, 403]: # unauthorized or forbidden
|
| 368 |
+
print("Need 'write' access token to create a Spaces repo.")
|
| 369 |
+
huggingface_hub.login(add_to_git_credential=False)
|
| 370 |
+
huggingface_hub.create_repo(
|
| 371 |
+
space_id,
|
| 372 |
+
private=private,
|
| 373 |
+
space_sdk="gradio",
|
| 374 |
+
space_storage=space_storage,
|
| 375 |
+
repo_type="space",
|
| 376 |
+
exist_ok=True,
|
| 377 |
+
)
|
| 378 |
+
else:
|
| 379 |
+
raise ValueError(f"Failed to create Space: {e}")
|
| 380 |
+
|
| 381 |
+
# We can assume huggingface-hub is available; requirements.txt pins trackio.
|
| 382 |
+
# Make sure necessary dependencies are installed by creating a requirements.txt.
|
| 383 |
+
is_source_install = _is_trackio_installed_from_source()
|
| 384 |
+
resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
|
| 385 |
+
|
| 386 |
+
if bucket_id is not None:
|
| 387 |
+
create_bucket_if_not_exists(bucket_id, private=private)
|
| 388 |
+
|
| 389 |
+
with open(Path(trackio_path, "README.md"), "r", encoding="utf-8") as f:
|
| 390 |
+
readme_content = f.read()
|
| 391 |
+
readme_content = readme_content.replace("sdk_version: {GRADIO_VERSION}\n", "")
|
| 392 |
+
readme_content = readme_content.replace("{APP_FILE}", "app.py")
|
| 393 |
+
readme_content = readme_content.replace(
|
| 394 |
+
"{LINKED_HUB_METADATA}", _readme_linked_hub_yaml(dataset_id)
|
| 395 |
+
)
|
| 396 |
+
readme_buffer = io.BytesIO(readme_content.encode("utf-8"))
|
| 397 |
+
hf_api.upload_file(
|
| 398 |
+
path_or_fileobj=readme_buffer,
|
| 399 |
+
path_in_repo="README.md",
|
| 400 |
+
repo_id=space_id,
|
| 401 |
+
repo_type="space",
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
if is_source_install:
|
| 405 |
+
requirements_content = _get_source_install_dependencies()
|
| 406 |
+
else:
|
| 407 |
+
requirements_content = _get_space_install_requirement()
|
| 408 |
+
|
| 409 |
+
requirements_buffer = io.BytesIO(requirements_content.encode("utf-8"))
|
| 410 |
+
hf_api.upload_file(
|
| 411 |
+
path_or_fileobj=requirements_buffer,
|
| 412 |
+
path_in_repo="requirements.txt",
|
| 413 |
+
repo_id=space_id,
|
| 414 |
+
repo_type="space",
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
huggingface_hub.utils.disable_progress_bars()
|
| 418 |
+
|
| 419 |
+
if is_source_install:
|
| 420 |
+
dist_index = (
|
| 421 |
+
Path(trackio.__file__).resolve().parent / "frontend" / "dist" / "index.html"
|
| 422 |
+
)
|
| 423 |
+
if not dist_index.is_file() and not resolved_frontend.is_custom:
|
| 424 |
+
raise ValueError(
|
| 425 |
+
"The Trackio frontend build is missing. From the repository root run "
|
| 426 |
+
"`cd trackio/frontend && npm ci && npm run build`, then deploy again."
|
| 427 |
+
)
|
| 428 |
+
hf_api.upload_folder(
|
| 429 |
+
repo_id=space_id,
|
| 430 |
+
repo_type="space",
|
| 431 |
+
folder_path=trackio_path,
|
| 432 |
+
path_in_repo="trackio",
|
| 433 |
+
ignore_patterns=[
|
| 434 |
+
"README.md",
|
| 435 |
+
"frontend/node_modules/**",
|
| 436 |
+
"frontend/src/**",
|
| 437 |
+
"frontend/.gitignore",
|
| 438 |
+
"frontend/package.json",
|
| 439 |
+
"frontend/package-lock.json",
|
| 440 |
+
"frontend/vite.config.js",
|
| 441 |
+
"frontend/svelte.config.js",
|
| 442 |
+
"**/__pycache__/**",
|
| 443 |
+
"*.pyc",
|
| 444 |
+
],
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
if resolved_frontend.is_custom:
|
| 448 |
+
_upload_frontend_folder(
|
| 449 |
+
hf_api,
|
| 450 |
+
repo_id=space_id,
|
| 451 |
+
repo_type="space",
|
| 452 |
+
folder_path=resolved_frontend.path,
|
| 453 |
+
path_in_repo=_CUSTOM_SPACE_FRONTEND_DIR,
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
app_file_content = _space_app_py(
|
| 457 |
+
_CUSTOM_SPACE_FRONTEND_DIR if resolved_frontend.is_custom else None
|
| 458 |
+
)
|
| 459 |
+
app_file_buffer = io.BytesIO(app_file_content.encode("utf-8"))
|
| 460 |
+
hf_api.upload_file(
|
| 461 |
+
path_or_fileobj=app_file_buffer,
|
| 462 |
+
path_in_repo="app.py",
|
| 463 |
+
repo_id=space_id,
|
| 464 |
+
repo_type="space",
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
if hf_token := huggingface_hub.utils.get_token():
|
| 468 |
+
huggingface_hub.add_space_secret(space_id, "HF_TOKEN", hf_token)
|
| 469 |
+
if bucket_id is not None:
|
| 470 |
+
_ensure_bucket_mounted_at_data(space_id, bucket_id, hf_api)
|
| 471 |
+
elif dataset_id is not None:
|
| 472 |
+
huggingface_hub.add_space_variable(space_id, "TRACKIO_DATASET_ID", dataset_id)
|
| 473 |
+
if logo_light_url := os.environ.get("TRACKIO_LOGO_LIGHT_URL"):
|
| 474 |
+
huggingface_hub.add_space_variable(
|
| 475 |
+
space_id, "TRACKIO_LOGO_LIGHT_URL", logo_light_url
|
| 476 |
+
)
|
| 477 |
+
if logo_dark_url := os.environ.get("TRACKIO_LOGO_DARK_URL"):
|
| 478 |
+
huggingface_hub.add_space_variable(
|
| 479 |
+
space_id, "TRACKIO_LOGO_DARK_URL", logo_dark_url
|
| 480 |
+
)
|
| 481 |
+
if plot_order := os.environ.get("TRACKIO_PLOT_ORDER"):
|
| 482 |
+
huggingface_hub.add_space_variable(space_id, "TRACKIO_PLOT_ORDER", plot_order)
|
| 483 |
+
if theme := os.environ.get("TRACKIO_THEME"):
|
| 484 |
+
huggingface_hub.add_space_variable(space_id, "TRACKIO_THEME", theme)
|
| 485 |
+
huggingface_hub.add_space_variable(space_id, "GRADIO_MCP_SERVER", "True")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def create_space_if_not_exists(
|
| 489 |
+
space_id: str,
|
| 490 |
+
space_storage: huggingface_hub.SpaceStorage | None = None,
|
| 491 |
+
dataset_id: str | None = None,
|
| 492 |
+
bucket_id: str | None = None,
|
| 493 |
+
private: bool | None = None,
|
| 494 |
+
frontend_dir: str | Path | None = None,
|
| 495 |
+
) -> bool:
|
| 496 |
+
"""
|
| 497 |
+
Creates a new Hugging Face Space if it does not exist. Returns True if a
|
| 498 |
+
new Space was created, False if an existing Space was found.
|
| 499 |
+
|
| 500 |
+
Args:
|
| 501 |
+
space_id (`str`):
|
| 502 |
+
The ID of the Space to create.
|
| 503 |
+
space_storage ([`~huggingface_hub.SpaceStorage`], *optional*):
|
| 504 |
+
Choice of persistent storage tier for the Space.
|
| 505 |
+
dataset_id (`str`, *optional*):
|
| 506 |
+
Deprecated. Use `bucket_id` instead.
|
| 507 |
+
bucket_id (`str`, *optional*):
|
| 508 |
+
Full Hub bucket id (`namespace/name`) to attach via the Hub volumes API (platform mount).
|
| 509 |
+
Sets `TRACKIO_DIR` to the mount path.
|
| 510 |
+
private (`bool`, *optional*):
|
| 511 |
+
Whether to make the Space private. If `None` (default), the repo will be
|
| 512 |
+
public unless the organization's default is private. This value is ignored
|
| 513 |
+
if the repo already exists.
|
| 514 |
+
"""
|
| 515 |
+
if "/" not in space_id:
|
| 516 |
+
raise ValueError(
|
| 517 |
+
f"Invalid space ID: {space_id}. Must be in the format: username/reponame or orgname/reponame."
|
| 518 |
+
)
|
| 519 |
+
if dataset_id is not None and "/" not in dataset_id:
|
| 520 |
+
raise ValueError(
|
| 521 |
+
f"Invalid dataset ID: {dataset_id}. Must be in the format: username/datasetname or orgname/datasetname."
|
| 522 |
+
)
|
| 523 |
+
if bucket_id is not None and "/" not in bucket_id:
|
| 524 |
+
raise ValueError(
|
| 525 |
+
f"Invalid bucket ID: {bucket_id}. Must be in the format: username/bucketname or orgname/bucketname."
|
| 526 |
+
)
|
| 527 |
+
try:
|
| 528 |
+
huggingface_hub.repo_info(space_id, repo_type="space")
|
| 529 |
+
print(
|
| 530 |
+
f"* Found existing space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
|
| 531 |
+
)
|
| 532 |
+
if bucket_id is not None:
|
| 533 |
+
create_bucket_if_not_exists(bucket_id, private=private)
|
| 534 |
+
_ensure_bucket_mounted_at_data(space_id, bucket_id)
|
| 535 |
+
elif dataset_id is not None:
|
| 536 |
+
huggingface_hub.add_space_variable(
|
| 537 |
+
space_id, "TRACKIO_DATASET_ID", dataset_id
|
| 538 |
+
)
|
| 539 |
+
resolved_frontend = resolve_frontend_dir(frontend_dir, announce=False)
|
| 540 |
+
if resolved_frontend.is_custom:
|
| 541 |
+
deploy_as_space(
|
| 542 |
+
space_id,
|
| 543 |
+
space_storage,
|
| 544 |
+
dataset_id,
|
| 545 |
+
bucket_id,
|
| 546 |
+
private,
|
| 547 |
+
frontend_dir=frontend_dir,
|
| 548 |
+
)
|
| 549 |
+
return False
|
| 550 |
+
except RepositoryNotFoundError:
|
| 551 |
+
pass
|
| 552 |
+
except HfHubHTTPError as e:
|
| 553 |
+
if e.response.status_code in [401, 403]: # unauthorized or forbidden
|
| 554 |
+
print("Need 'write' access token to create a Spaces repo.")
|
| 555 |
+
huggingface_hub.login(add_to_git_credential=False)
|
| 556 |
+
else:
|
| 557 |
+
raise ValueError(f"Failed to create Space: {e}")
|
| 558 |
+
|
| 559 |
+
print(
|
| 560 |
+
f"* Creating new space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
|
| 561 |
+
)
|
| 562 |
+
deploy_as_space(
|
| 563 |
+
space_id,
|
| 564 |
+
space_storage,
|
| 565 |
+
dataset_id,
|
| 566 |
+
bucket_id,
|
| 567 |
+
private,
|
| 568 |
+
frontend_dir=frontend_dir,
|
| 569 |
+
)
|
| 570 |
+
threading.Thread(
|
| 571 |
+
target=_warn_if_space_build_fails, args=(space_id,), daemon=True
|
| 572 |
+
).start()
|
| 573 |
+
return True
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def space_is_running(space_id: str) -> bool:
|
| 577 |
+
try:
|
| 578 |
+
info = huggingface_hub.HfApi().space_info(space_id, timeout=30)
|
| 579 |
+
return bool(info.runtime) and str(info.runtime.stage) == "RUNNING"
|
| 580 |
+
except Exception:
|
| 581 |
+
return False
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
def _warn_if_space_build_fails(space_id: str, timeout: int = 600) -> None:
|
| 585 |
+
hf_api = huggingface_hub.HfApi()
|
| 586 |
+
failure_stages = frozenset(
|
| 587 |
+
("NO_APP_FILE", "CONFIG_ERROR", "BUILD_ERROR", "RUNTIME_ERROR")
|
| 588 |
+
)
|
| 589 |
+
start = time.time()
|
| 590 |
+
while time.time() - start < timeout:
|
| 591 |
+
try:
|
| 592 |
+
info = hf_api.space_info(space_id, timeout=30)
|
| 593 |
+
if info.runtime:
|
| 594 |
+
stage = str(info.runtime.stage)
|
| 595 |
+
if stage in failure_stages:
|
| 596 |
+
print(
|
| 597 |
+
f"\n* WARNING: Space {space_id} failed to build (stage: {stage}). "
|
| 598 |
+
f"Logs are being saved to the Bucket and will appear once the Space "
|
| 599 |
+
f"is fixed; see the build logs at {SPACE_URL.format(space_id=space_id)}"
|
| 600 |
+
)
|
| 601 |
+
return
|
| 602 |
+
if stage == "RUNNING":
|
| 603 |
+
return
|
| 604 |
+
except Exception:
|
| 605 |
+
pass
|
| 606 |
+
time.sleep(10)
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _wait_until_space_running(space_id: str, timeout: int = 300) -> None:
|
| 610 |
+
hf_api = huggingface_hub.HfApi()
|
| 611 |
+
start = time.time()
|
| 612 |
+
delay = 2
|
| 613 |
+
request_timeout = 45.0
|
| 614 |
+
failure_stages = frozenset(
|
| 615 |
+
("NO_APP_FILE", "CONFIG_ERROR", "BUILD_ERROR", "RUNTIME_ERROR")
|
| 616 |
+
)
|
| 617 |
+
while time.time() - start < timeout:
|
| 618 |
+
try:
|
| 619 |
+
info = hf_api.space_info(space_id, timeout=request_timeout)
|
| 620 |
+
if info.runtime:
|
| 621 |
+
stage = str(info.runtime.stage)
|
| 622 |
+
if stage in failure_stages:
|
| 623 |
+
raise RuntimeError(
|
| 624 |
+
f"Space {space_id} entered terminal stage {stage}. "
|
| 625 |
+
"Fix README.md or app files; see build logs on the Hub."
|
| 626 |
+
)
|
| 627 |
+
if stage == "RUNNING":
|
| 628 |
+
return
|
| 629 |
+
except RuntimeError:
|
| 630 |
+
raise
|
| 631 |
+
except (huggingface_hub.utils.HfHubHTTPError, httpx.RequestError):
|
| 632 |
+
pass
|
| 633 |
+
time.sleep(delay)
|
| 634 |
+
delay = min(delay * 1.5, 15)
|
| 635 |
+
raise TimeoutError(
|
| 636 |
+
f"Space {space_id} did not reach RUNNING within {timeout}s. "
|
| 637 |
+
"Check status and build logs on the Hub."
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def wait_until_space_exists(
|
| 642 |
+
space_id: str,
|
| 643 |
+
) -> None:
|
| 644 |
+
"""
|
| 645 |
+
Blocks the current thread until the Space exists.
|
| 646 |
+
|
| 647 |
+
Args:
|
| 648 |
+
space_id (`str`):
|
| 649 |
+
The ID of the Space to wait for.
|
| 650 |
+
|
| 651 |
+
Raises:
|
| 652 |
+
`TimeoutError`: If waiting for the Space takes longer than expected.
|
| 653 |
+
"""
|
| 654 |
+
hf_api = huggingface_hub.HfApi()
|
| 655 |
+
delay = 1
|
| 656 |
+
for _ in range(30):
|
| 657 |
+
try:
|
| 658 |
+
hf_api.space_info(space_id)
|
| 659 |
+
return
|
| 660 |
+
except (huggingface_hub.utils.HfHubHTTPError, httpx.RequestError):
|
| 661 |
+
time.sleep(delay)
|
| 662 |
+
delay = min(delay * 2, 60)
|
| 663 |
+
raise TimeoutError("Waiting for space to exist took longer than expected")
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def upload_db_to_space(project: str, space_id: str, force: bool = False) -> None:
|
| 667 |
+
"""
|
| 668 |
+
Uploads the database of a local Trackio project to a Hugging Face Space.
|
| 669 |
+
|
| 670 |
+
This uses the Trackio remote client so newer Trackio Spaces can speak the direct
|
| 671 |
+
HTTP API while older Gradio-based Spaces still work through `gradio_client`.
|
| 672 |
+
|
| 673 |
+
Args:
|
| 674 |
+
project (`str`):
|
| 675 |
+
The name of the project to upload.
|
| 676 |
+
space_id (`str`):
|
| 677 |
+
The ID of the Space to upload to.
|
| 678 |
+
force (`bool`, *optional*, defaults to `False`):
|
| 679 |
+
If `True`, overwrites the existing database without prompting. If `False`,
|
| 680 |
+
prompts for confirmation.
|
| 681 |
+
"""
|
| 682 |
+
db_path = SQLiteStorage.get_project_db_path(project)
|
| 683 |
+
client = RemoteClient(
|
| 684 |
+
space_id,
|
| 685 |
+
hf_token=huggingface_hub.utils.get_token(),
|
| 686 |
+
httpx_kwargs={"timeout": 90},
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
if not force:
|
| 690 |
+
try:
|
| 691 |
+
existing_projects = client.predict(api_name="/get_all_projects")
|
| 692 |
+
if project in existing_projects:
|
| 693 |
+
response = input(
|
| 694 |
+
f"Database for project '{project}' already exists on Space '{space_id}'. "
|
| 695 |
+
f"Overwrite it? (y/N): "
|
| 696 |
+
)
|
| 697 |
+
if response.lower() not in ["y", "yes"]:
|
| 698 |
+
print("* Upload cancelled.")
|
| 699 |
+
return
|
| 700 |
+
except Exception as e:
|
| 701 |
+
print(f"* Warning: Could not check if project exists on Space: {e}")
|
| 702 |
+
print("* Proceeding with upload...")
|
| 703 |
+
|
| 704 |
+
client.predict(
|
| 705 |
+
api_name="/upload_db_to_space",
|
| 706 |
+
project=project,
|
| 707 |
+
uploaded_db=handle_file(db_path),
|
| 708 |
+
hf_token=huggingface_hub.utils.get_token(),
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
SYNC_BATCH_SIZE = 500
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _replay_pending_uploads(
|
| 716 |
+
project: str,
|
| 717 |
+
client: "RemoteClient",
|
| 718 |
+
hf_token: str | None,
|
| 719 |
+
) -> None:
|
| 720 |
+
"""Replay queued `pending_uploads` rows for `project` via `client` — the
|
| 721 |
+
out-of-Run sync path (`trackio sync`); see `replay_pending_uploads` for the
|
| 722 |
+
per-kind routing and clearing semantics.
|
| 723 |
+
"""
|
| 724 |
+
pending_uploads = SQLiteStorage.get_pending_uploads(project)
|
| 725 |
+
if not pending_uploads:
|
| 726 |
+
return
|
| 727 |
+
|
| 728 |
+
def _warn_missing(count: int, sample: str) -> None:
|
| 729 |
+
print(
|
| 730 |
+
f" Warning: dropping {count} pending upload(s) whose local files "
|
| 731 |
+
f"no longer exist (e.g. {sample!r})."
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
replay_pending_uploads(
|
| 735 |
+
pending_uploads,
|
| 736 |
+
project,
|
| 737 |
+
predict=client.predict,
|
| 738 |
+
hf_token=hf_token,
|
| 739 |
+
warn_missing=_warn_missing,
|
| 740 |
+
verbose=True,
|
| 741 |
+
)
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def sync_incremental(
|
| 745 |
+
project: str,
|
| 746 |
+
space_id: str,
|
| 747 |
+
private: bool | None = None,
|
| 748 |
+
pending_only: bool = False,
|
| 749 |
+
frontend_dir: str | Path | None = None,
|
| 750 |
+
) -> None:
|
| 751 |
+
"""
|
| 752 |
+
Syncs a local Trackio project to a Space via the bulk_log API endpoints
|
| 753 |
+
instead of uploading the entire DB file. Supports incremental sync.
|
| 754 |
+
|
| 755 |
+
Args:
|
| 756 |
+
project: The name of the project to sync.
|
| 757 |
+
space_id: The HF Space ID to sync to.
|
| 758 |
+
private: Whether to make the Space private if creating.
|
| 759 |
+
pending_only: If True, only sync rows tagged with space_id (pending data).
|
| 760 |
+
"""
|
| 761 |
+
print(
|
| 762 |
+
f"* Syncing project '{project}' to: {SPACE_URL.format(space_id=space_id)} (please wait...)"
|
| 763 |
+
)
|
| 764 |
+
create_space_if_not_exists(space_id, private=private, frontend_dir=frontend_dir)
|
| 765 |
+
wait_until_space_exists(space_id)
|
| 766 |
+
hf_token = huggingface_hub.utils.get_token()
|
| 767 |
+
expected_run_counts: Counter[str] = Counter()
|
| 768 |
+
|
| 769 |
+
client = RemoteClient(
|
| 770 |
+
space_id,
|
| 771 |
+
hf_token=hf_token,
|
| 772 |
+
httpx_kwargs={"timeout": 90},
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
if pending_only:
|
| 776 |
+
pending_logs = SQLiteStorage.get_pending_logs(project)
|
| 777 |
+
if pending_logs:
|
| 778 |
+
logs = pending_logs["logs"]
|
| 779 |
+
expected_run_counts.update(log["run"] for log in logs)
|
| 780 |
+
for i in range(0, len(logs), SYNC_BATCH_SIZE):
|
| 781 |
+
batch = logs[i : i + SYNC_BATCH_SIZE]
|
| 782 |
+
print(
|
| 783 |
+
f" Syncing metrics: {min(i + SYNC_BATCH_SIZE, len(logs))}/{len(logs)}..."
|
| 784 |
+
)
|
| 785 |
+
client.predict(api_name="/bulk_log", logs=batch, hf_token=hf_token)
|
| 786 |
+
SQLiteStorage.clear_pending_logs(project, pending_logs["ids"])
|
| 787 |
+
|
| 788 |
+
pending_sys = SQLiteStorage.get_pending_system_logs(project)
|
| 789 |
+
if pending_sys:
|
| 790 |
+
logs = pending_sys["logs"]
|
| 791 |
+
for i in range(0, len(logs), SYNC_BATCH_SIZE):
|
| 792 |
+
batch = logs[i : i + SYNC_BATCH_SIZE]
|
| 793 |
+
print(
|
| 794 |
+
f" Syncing system metrics: {min(i + SYNC_BATCH_SIZE, len(logs))}/{len(logs)}..."
|
| 795 |
+
)
|
| 796 |
+
client.predict(
|
| 797 |
+
api_name="/bulk_log_system", logs=batch, hf_token=hf_token
|
| 798 |
+
)
|
| 799 |
+
SQLiteStorage.clear_pending_system_logs(project, pending_sys["ids"])
|
| 800 |
+
|
| 801 |
+
_replay_pending_uploads(project, client, hf_token)
|
| 802 |
+
else:
|
| 803 |
+
all_logs = SQLiteStorage.get_all_logs_for_sync(project)
|
| 804 |
+
if all_logs:
|
| 805 |
+
expected_run_counts.update(log["run"] for log in all_logs)
|
| 806 |
+
for i in range(0, len(all_logs), SYNC_BATCH_SIZE):
|
| 807 |
+
batch = all_logs[i : i + SYNC_BATCH_SIZE]
|
| 808 |
+
print(
|
| 809 |
+
f" Syncing metrics: {min(i + SYNC_BATCH_SIZE, len(all_logs))}/{len(all_logs)}..."
|
| 810 |
+
)
|
| 811 |
+
client.predict(api_name="/bulk_log", logs=batch, hf_token=hf_token)
|
| 812 |
+
|
| 813 |
+
all_sys_logs = SQLiteStorage.get_all_system_logs_for_sync(project)
|
| 814 |
+
if all_sys_logs:
|
| 815 |
+
for i in range(0, len(all_sys_logs), SYNC_BATCH_SIZE):
|
| 816 |
+
batch = all_sys_logs[i : i + SYNC_BATCH_SIZE]
|
| 817 |
+
print(
|
| 818 |
+
f" Syncing system metrics: {min(i + SYNC_BATCH_SIZE, len(all_sys_logs))}/{len(all_sys_logs)}..."
|
| 819 |
+
)
|
| 820 |
+
client.predict(
|
| 821 |
+
api_name="/bulk_log_system", logs=batch, hf_token=hf_token
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
_wait_for_remote_sync(client, project, expected_run_counts)
|
| 825 |
+
SQLiteStorage.set_project_metadata(project, "space_id", space_id)
|
| 826 |
+
print(
|
| 827 |
+
f"* Synced successfully to space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
def _build_remote_client_with_retry(
|
| 832 |
+
space_id: str,
|
| 833 |
+
timeout: int = 360,
|
| 834 |
+
verbose: bool = False,
|
| 835 |
+
) -> RemoteClient:
|
| 836 |
+
deadline = time.time() + timeout
|
| 837 |
+
delay = 2
|
| 838 |
+
last_error: Exception | None = None
|
| 839 |
+
while time.time() < deadline:
|
| 840 |
+
try:
|
| 841 |
+
return RemoteClient(
|
| 842 |
+
space_id,
|
| 843 |
+
hf_token=huggingface_hub.utils.get_token(),
|
| 844 |
+
verbose=verbose,
|
| 845 |
+
httpx_kwargs={"timeout": 90},
|
| 846 |
+
)
|
| 847 |
+
except (ValueError, ConnectionError) as e:
|
| 848 |
+
last_error = e
|
| 849 |
+
time.sleep(delay)
|
| 850 |
+
delay = min(delay * 1.5, 15)
|
| 851 |
+
raise ConnectionError(
|
| 852 |
+
f"Could not connect to Space '{space_id}' within {timeout}s: {last_error}"
|
| 853 |
+
)
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def _wait_for_remote_sync(
|
| 857 |
+
client: RemoteClient,
|
| 858 |
+
project: str,
|
| 859 |
+
expected_run_counts: Counter[str],
|
| 860 |
+
timeout: int = 180,
|
| 861 |
+
) -> None:
|
| 862 |
+
if not expected_run_counts:
|
| 863 |
+
return
|
| 864 |
+
|
| 865 |
+
deadline = time.time() + timeout
|
| 866 |
+
delay = 2
|
| 867 |
+
last_error: Exception | None = None
|
| 868 |
+
pending = dict(expected_run_counts)
|
| 869 |
+
|
| 870 |
+
while time.time() < deadline and pending:
|
| 871 |
+
completed = []
|
| 872 |
+
for run_name, expected_num_logs in pending.items():
|
| 873 |
+
try:
|
| 874 |
+
summary = client.predict(
|
| 875 |
+
project=project, run=run_name, api_name="/get_run_summary"
|
| 876 |
+
)
|
| 877 |
+
if summary.get("num_logs") == expected_num_logs:
|
| 878 |
+
completed.append(run_name)
|
| 879 |
+
except Exception as e:
|
| 880 |
+
last_error = e
|
| 881 |
+
for run_name in completed:
|
| 882 |
+
pending.pop(run_name, None)
|
| 883 |
+
if pending:
|
| 884 |
+
time.sleep(delay)
|
| 885 |
+
delay = min(delay * 1.5, 15)
|
| 886 |
+
|
| 887 |
+
if pending:
|
| 888 |
+
raise TimeoutError(
|
| 889 |
+
f"Remote sync for project '{project}' did not become visible for runs "
|
| 890 |
+
f"{sorted(pending.items())} within {timeout}s. "
|
| 891 |
+
f"Last error: {last_error!r}"
|
| 892 |
+
)
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def upload_dataset_for_static(
|
| 896 |
+
project: str,
|
| 897 |
+
dataset_id: str,
|
| 898 |
+
private: bool | None = None,
|
| 899 |
+
) -> None:
|
| 900 |
+
hf_api = huggingface_hub.HfApi()
|
| 901 |
+
|
| 902 |
+
try:
|
| 903 |
+
huggingface_hub.create_repo(
|
| 904 |
+
dataset_id,
|
| 905 |
+
private=private,
|
| 906 |
+
repo_type="dataset",
|
| 907 |
+
exist_ok=True,
|
| 908 |
+
)
|
| 909 |
+
except HfHubHTTPError as e:
|
| 910 |
+
if e.response.status_code in [401, 403]:
|
| 911 |
+
print("Need 'write' access token to create a Dataset repo.")
|
| 912 |
+
huggingface_hub.login(add_to_git_credential=False)
|
| 913 |
+
huggingface_hub.create_repo(
|
| 914 |
+
dataset_id,
|
| 915 |
+
private=private,
|
| 916 |
+
repo_type="dataset",
|
| 917 |
+
exist_ok=True,
|
| 918 |
+
)
|
| 919 |
+
else:
|
| 920 |
+
raise ValueError(f"Failed to create Dataset: {e}")
|
| 921 |
+
|
| 922 |
+
def _upload_folder(folder: Path, path_in_repo: str, ignore_patterns=None) -> None:
|
| 923 |
+
if not folder.exists():
|
| 924 |
+
return
|
| 925 |
+
_retry_hf_write(
|
| 926 |
+
f"Dataset {path_in_repo} upload",
|
| 927 |
+
lambda: hf_api.upload_folder(
|
| 928 |
+
repo_id=dataset_id,
|
| 929 |
+
repo_type="dataset",
|
| 930 |
+
folder_path=str(folder),
|
| 931 |
+
path_in_repo=path_in_repo,
|
| 932 |
+
ignore_patterns=ignore_patterns,
|
| 933 |
+
),
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 937 |
+
output_dir = Path(tmp_dir)
|
| 938 |
+
SQLiteStorage.export_for_static_space(project, output_dir)
|
| 939 |
+
|
| 940 |
+
_upload_folder(project_media_dir(project), "media")
|
| 941 |
+
_upload_folder(project_artifacts_dir(project), "artifacts", [PARTIAL_BLOB_GLOB])
|
| 942 |
+
|
| 943 |
+
_retry_hf_write(
|
| 944 |
+
"Dataset upload",
|
| 945 |
+
lambda: hf_api.upload_folder(
|
| 946 |
+
repo_id=dataset_id,
|
| 947 |
+
repo_type="dataset",
|
| 948 |
+
folder_path=str(output_dir),
|
| 949 |
+
),
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
+
print(f"* Dataset uploaded: https://huggingface.co/datasets/{dataset_id}")
|
| 953 |
+
|
| 954 |
+
|
| 955 |
+
def deploy_as_static_space(
|
| 956 |
+
space_id: str,
|
| 957 |
+
dataset_id: str | None,
|
| 958 |
+
project: str,
|
| 959 |
+
bucket_id: str | None = None,
|
| 960 |
+
private: bool | None = None,
|
| 961 |
+
hf_token: str | None = None,
|
| 962 |
+
frontend_dir: str | Path | None = None,
|
| 963 |
+
) -> None:
|
| 964 |
+
if on_spaces():
|
| 965 |
+
return
|
| 966 |
+
|
| 967 |
+
if private is True:
|
| 968 |
+
raise ValueError(
|
| 969 |
+
"private=True is not supported for static Trackio Spaces. Static Spaces "
|
| 970 |
+
"run entirely in the browser, so their snapshot data must be public. "
|
| 971 |
+
"Use sdk='gradio' for a private dashboard."
|
| 972 |
+
)
|
| 973 |
+
hf_api = huggingface_hub.HfApi()
|
| 974 |
+
|
| 975 |
+
try:
|
| 976 |
+
huggingface_hub.create_repo(
|
| 977 |
+
space_id,
|
| 978 |
+
private=False,
|
| 979 |
+
space_sdk="static",
|
| 980 |
+
repo_type="space",
|
| 981 |
+
exist_ok=True,
|
| 982 |
+
)
|
| 983 |
+
except HfHubHTTPError as e:
|
| 984 |
+
if e.response.status_code in [401, 403]:
|
| 985 |
+
print("Need 'write' access token to create a Spaces repo.")
|
| 986 |
+
huggingface_hub.login(add_to_git_credential=False)
|
| 987 |
+
huggingface_hub.create_repo(
|
| 988 |
+
space_id,
|
| 989 |
+
private=False,
|
| 990 |
+
space_sdk="static",
|
| 991 |
+
repo_type="space",
|
| 992 |
+
exist_ok=True,
|
| 993 |
+
)
|
| 994 |
+
else:
|
| 995 |
+
raise ValueError(f"Failed to create Space: {e}")
|
| 996 |
+
|
| 997 |
+
linked = _readme_linked_hub_yaml(dataset_id)
|
| 998 |
+
readme_content = (
|
| 999 |
+
f"---\nemoji: 🎯\nsdk: static\npinned: false\ntags:\n - trackio\n{linked}---\n"
|
| 1000 |
+
)
|
| 1001 |
+
_retry_hf_write(
|
| 1002 |
+
"Static Space README upload",
|
| 1003 |
+
lambda: hf_api.upload_file(
|
| 1004 |
+
path_or_fileobj=io.BytesIO(readme_content.encode("utf-8")),
|
| 1005 |
+
path_in_repo="README.md",
|
| 1006 |
+
repo_id=space_id,
|
| 1007 |
+
repo_type="space",
|
| 1008 |
+
),
|
| 1009 |
+
)
|
| 1010 |
+
|
| 1011 |
+
resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
|
| 1012 |
+
|
| 1013 |
+
_retry_hf_write(
|
| 1014 |
+
"Static Space frontend upload",
|
| 1015 |
+
lambda: hf_api.upload_folder(
|
| 1016 |
+
repo_id=space_id,
|
| 1017 |
+
repo_type="space",
|
| 1018 |
+
folder_path=str(resolved_frontend.path),
|
| 1019 |
+
),
|
| 1020 |
+
)
|
| 1021 |
+
|
| 1022 |
+
config = {
|
| 1023 |
+
"mode": "static",
|
| 1024 |
+
"project": project,
|
| 1025 |
+
"private": bool(private),
|
| 1026 |
+
}
|
| 1027 |
+
if bucket_id is not None:
|
| 1028 |
+
config["bucket_id"] = bucket_id
|
| 1029 |
+
if dataset_id is not None:
|
| 1030 |
+
config["dataset_id"] = dataset_id
|
| 1031 |
+
if hf_token is not None:
|
| 1032 |
+
warnings.warn(
|
| 1033 |
+
"`hf_token` is ignored by deploy_as_static_space() for static Space "
|
| 1034 |
+
"deployment and will be removed in a future release.",
|
| 1035 |
+
DeprecationWarning,
|
| 1036 |
+
stacklevel=2,
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
_retry_hf_write(
|
| 1040 |
+
"Static Space config upload",
|
| 1041 |
+
lambda: hf_api.upload_file(
|
| 1042 |
+
path_or_fileobj=io.BytesIO(json_mod.dumps(config).encode("utf-8")),
|
| 1043 |
+
path_in_repo="config.json",
|
| 1044 |
+
repo_id=space_id,
|
| 1045 |
+
repo_type="space",
|
| 1046 |
+
),
|
| 1047 |
+
)
|
| 1048 |
+
|
| 1049 |
+
assets_dir = Path(trackio.__file__).resolve().parent / "assets"
|
| 1050 |
+
if assets_dir.is_dir():
|
| 1051 |
+
_retry_hf_write(
|
| 1052 |
+
"Static Space assets upload",
|
| 1053 |
+
lambda: hf_api.upload_folder(
|
| 1054 |
+
repo_id=space_id,
|
| 1055 |
+
repo_type="space",
|
| 1056 |
+
folder_path=str(assets_dir),
|
| 1057 |
+
path_in_repo="static/trackio",
|
| 1058 |
+
),
|
| 1059 |
+
)
|
| 1060 |
+
|
| 1061 |
+
print(
|
| 1062 |
+
f"* Static Space deployed: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
|
| 1063 |
+
)
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
def sync(
|
| 1067 |
+
project: str,
|
| 1068 |
+
space_id: str | None = None,
|
| 1069 |
+
private: bool | None = None,
|
| 1070 |
+
force: bool = False,
|
| 1071 |
+
run_in_background: bool = False,
|
| 1072 |
+
sdk: str = "gradio",
|
| 1073 |
+
dataset_id: str | None = None,
|
| 1074 |
+
bucket_id: str | None = None,
|
| 1075 |
+
frontend_dir: str | Path | None = None,
|
| 1076 |
+
) -> str:
|
| 1077 |
+
"""
|
| 1078 |
+
Syncs a local Trackio project's database to a Hugging Face Space.
|
| 1079 |
+
If the Space does not exist, it will be created. Local data is never deleted.
|
| 1080 |
+
|
| 1081 |
+
**Freezing:** Passing ``sdk="static"`` deploys a static Space backed by an HF Bucket
|
| 1082 |
+
(read-only dashboard, no Gradio server). You can sync the same project again later to
|
| 1083 |
+
refresh that static Space. If you want a one-time snapshot of an existing Gradio Space,
|
| 1084 |
+
use ``freeze()`` instead.
|
| 1085 |
+
|
| 1086 |
+
Args:
|
| 1087 |
+
project (`str`): The name of the project to upload.
|
| 1088 |
+
space_id (`str`, *optional*): The ID of the Space to upload to (e.g., `"username/space_id"`).
|
| 1089 |
+
If not provided, checks project metadata first, then generates a random space_id.
|
| 1090 |
+
private (`bool`, *optional*):
|
| 1091 |
+
Whether to make the Space private. If None (default), the repo will be
|
| 1092 |
+
public unless the organization's default is private. This value is ignored
|
| 1093 |
+
if the repo already exists. Not supported with ``sdk="static"`` because
|
| 1094 |
+
static Trackio dashboards read snapshot data directly from the browser.
|
| 1095 |
+
force (`bool`, *optional*, defaults to `False`):
|
| 1096 |
+
If `True`, overwrite the existing database without prompting for confirmation.
|
| 1097 |
+
If `False`, prompt the user before overwriting an existing database.
|
| 1098 |
+
run_in_background (`bool`, *optional*, defaults to `False`):
|
| 1099 |
+
If `True`, the Space creation and database upload will be run in a background thread.
|
| 1100 |
+
If `False`, all the steps will be run synchronously.
|
| 1101 |
+
sdk (`str`, *optional*, defaults to `"gradio"`):
|
| 1102 |
+
The type of Space to deploy. `"gradio"` deploys a Gradio Space with a live
|
| 1103 |
+
server. `"static"` freezes the Space: deploys a static Space that reads from an HF Bucket
|
| 1104 |
+
(no server needed).
|
| 1105 |
+
dataset_id (`str`, *optional*):
|
| 1106 |
+
Deprecated. Use `bucket_id` instead.
|
| 1107 |
+
bucket_id (`str`, *optional*):
|
| 1108 |
+
The ID of the HF Bucket to sync to. By default, a bucket is auto-generated
|
| 1109 |
+
from the space_id.
|
| 1110 |
+
Returns:
|
| 1111 |
+
`str`: The Space ID of the synced project.
|
| 1112 |
+
"""
|
| 1113 |
+
if sdk not in ("gradio", "static"):
|
| 1114 |
+
raise ValueError(f"sdk must be 'gradio' or 'static', got '{sdk}'")
|
| 1115 |
+
if sdk == "static" and private is True:
|
| 1116 |
+
raise ValueError(
|
| 1117 |
+
"private=True is not supported for static Trackio Spaces. Static Spaces "
|
| 1118 |
+
"run entirely in the browser, so their snapshot data must be public. "
|
| 1119 |
+
"Use sdk='gradio' for a private dashboard."
|
| 1120 |
+
)
|
| 1121 |
+
bucket_id_was_explicit = bucket_id is not None
|
| 1122 |
+
|
| 1123 |
+
if space_id is None:
|
| 1124 |
+
space_id = SQLiteStorage.get_space_id(project)
|
| 1125 |
+
if space_id is None:
|
| 1126 |
+
space_id = f"{project}-{get_or_create_project_hash(project)}"
|
| 1127 |
+
space_id, dataset_id, bucket_id = preprocess_space_and_dataset_ids(
|
| 1128 |
+
space_id, dataset_id, bucket_id
|
| 1129 |
+
)
|
| 1130 |
+
if dataset_id is None and bucket_id is not None and not bucket_id_was_explicit:
|
| 1131 |
+
bucket_id = resolve_auto_bucket_id(space_id, bucket_id)
|
| 1132 |
+
|
| 1133 |
+
def _do_sync():
|
| 1134 |
+
try:
|
| 1135 |
+
info = huggingface_hub.HfApi().space_info(space_id)
|
| 1136 |
+
existing_sdk = info.sdk
|
| 1137 |
+
if existing_sdk and existing_sdk != sdk:
|
| 1138 |
+
raise ValueError(
|
| 1139 |
+
f"Space '{space_id}' is a '{existing_sdk}' Space but sdk='{sdk}' was requested. "
|
| 1140 |
+
f"The sdk must match the existing Space type."
|
| 1141 |
+
)
|
| 1142 |
+
except RepositoryNotFoundError:
|
| 1143 |
+
pass
|
| 1144 |
+
|
| 1145 |
+
if sdk == "static":
|
| 1146 |
+
if dataset_id is not None:
|
| 1147 |
+
upload_dataset_for_static(project, dataset_id, private=False)
|
| 1148 |
+
deploy_as_static_space(
|
| 1149 |
+
space_id,
|
| 1150 |
+
dataset_id,
|
| 1151 |
+
project,
|
| 1152 |
+
private=False,
|
| 1153 |
+
frontend_dir=frontend_dir,
|
| 1154 |
+
)
|
| 1155 |
+
elif bucket_id is not None:
|
| 1156 |
+
create_bucket_if_not_exists(bucket_id, private=False)
|
| 1157 |
+
upload_project_to_bucket_for_static(project, bucket_id)
|
| 1158 |
+
print(
|
| 1159 |
+
f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
|
| 1160 |
+
)
|
| 1161 |
+
deploy_as_static_space(
|
| 1162 |
+
space_id,
|
| 1163 |
+
None,
|
| 1164 |
+
project,
|
| 1165 |
+
bucket_id=bucket_id,
|
| 1166 |
+
private=False,
|
| 1167 |
+
frontend_dir=frontend_dir,
|
| 1168 |
+
)
|
| 1169 |
+
else:
|
| 1170 |
+
if bucket_id is not None:
|
| 1171 |
+
create_bucket_if_not_exists(bucket_id, private=private)
|
| 1172 |
+
upload_project_to_bucket(project, bucket_id)
|
| 1173 |
+
print(
|
| 1174 |
+
f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
|
| 1175 |
+
)
|
| 1176 |
+
create_space_if_not_exists(
|
| 1177 |
+
space_id,
|
| 1178 |
+
bucket_id=bucket_id,
|
| 1179 |
+
private=private,
|
| 1180 |
+
frontend_dir=frontend_dir,
|
| 1181 |
+
)
|
| 1182 |
+
_wait_until_space_running(space_id)
|
| 1183 |
+
_wait_for_remote_sync(
|
| 1184 |
+
_build_remote_client_with_retry(space_id),
|
| 1185 |
+
project,
|
| 1186 |
+
Counter(
|
| 1187 |
+
log["run"]
|
| 1188 |
+
for log in SQLiteStorage.get_all_logs_for_sync(project)
|
| 1189 |
+
),
|
| 1190 |
+
)
|
| 1191 |
+
else:
|
| 1192 |
+
sync_incremental(
|
| 1193 |
+
project,
|
| 1194 |
+
space_id,
|
| 1195 |
+
private=private,
|
| 1196 |
+
pending_only=False,
|
| 1197 |
+
frontend_dir=frontend_dir,
|
| 1198 |
+
)
|
| 1199 |
+
SQLiteStorage.set_project_metadata(project, "space_id", space_id)
|
| 1200 |
+
|
| 1201 |
+
if run_in_background:
|
| 1202 |
+
threading.Thread(target=_do_sync).start()
|
| 1203 |
+
else:
|
| 1204 |
+
_do_sync()
|
| 1205 |
+
return space_id
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
def _get_source_bucket(space_id: str) -> str:
|
| 1209 |
+
bucket_id = _get_existing_space_bucket(space_id)
|
| 1210 |
+
if bucket_id is not None:
|
| 1211 |
+
_ensure_bucket_mounted_at_data(space_id, bucket_id)
|
| 1212 |
+
return bucket_id
|
| 1213 |
+
raise ValueError(
|
| 1214 |
+
f"Space '{space_id}' has no bucket mounted at '/data'. "
|
| 1215 |
+
f"freeze() requires the source Space to use bucket storage."
|
| 1216 |
+
)
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
def freeze(
|
| 1220 |
+
space_id: str,
|
| 1221 |
+
project: str,
|
| 1222 |
+
new_space_id: str | None = None,
|
| 1223 |
+
private: bool | None = None,
|
| 1224 |
+
bucket_id: str | None = None,
|
| 1225 |
+
frontend_dir: str | Path | None = None,
|
| 1226 |
+
) -> str:
|
| 1227 |
+
"""
|
| 1228 |
+
Creates a new static Hugging Face Space containing a read-only snapshot of
|
| 1229 |
+
the data for the specified project from the source Gradio Space. The data is
|
| 1230 |
+
read from the bucket attached to the source Space at freeze time. The original
|
| 1231 |
+
Space is not modified, and the new static Space does not automatically reflect
|
| 1232 |
+
metrics uploaded to the original Gradio Space after the freeze completes.
|
| 1233 |
+
|
| 1234 |
+
Args:
|
| 1235 |
+
space_id (`str`):
|
| 1236 |
+
The ID of the source Gradio Space (e.g., `"username/my-space"` or a
|
| 1237 |
+
short repo name with the logged-in namespace inferred, like `init()`).
|
| 1238 |
+
Must be a Gradio Space with a bucket mounted at `/data`.
|
| 1239 |
+
project (`str`):
|
| 1240 |
+
The name of the project whose data to include in the frozen Space.
|
| 1241 |
+
new_space_id (`str`, *optional*):
|
| 1242 |
+
The ID for the new static Space. If not provided, defaults to
|
| 1243 |
+
`"{space_id}_static"`.
|
| 1244 |
+
private (`bool`, *optional*):
|
| 1245 |
+
Not supported. Frozen static dashboards read snapshot data directly
|
| 1246 |
+
from the browser, so the destination snapshot must be public.
|
| 1247 |
+
bucket_id (`str`, *optional*):
|
| 1248 |
+
The ID of the HF Bucket for the new static Space's data storage.
|
| 1249 |
+
If not provided, one is auto-generated from the new Space ID.
|
| 1250 |
+
|
| 1251 |
+
Returns:
|
| 1252 |
+
`str`: The Space ID of the newly created static Space.
|
| 1253 |
+
"""
|
| 1254 |
+
if private is True:
|
| 1255 |
+
raise ValueError(
|
| 1256 |
+
"private=True is not supported for frozen static Trackio Spaces. Static "
|
| 1257 |
+
"Spaces run entirely in the browser, so their snapshot data must be "
|
| 1258 |
+
"public. Use a Gradio Space if the frozen dashboard must stay private."
|
| 1259 |
+
)
|
| 1260 |
+
space_id, _, _ = preprocess_space_and_dataset_ids(space_id, None, None)
|
| 1261 |
+
|
| 1262 |
+
try:
|
| 1263 |
+
info = huggingface_hub.HfApi().space_info(space_id)
|
| 1264 |
+
if info.sdk != "gradio":
|
| 1265 |
+
raise ValueError(
|
| 1266 |
+
f"Space '{space_id}' is not a Gradio Space (sdk='{info.sdk}'). "
|
| 1267 |
+
f"freeze() requires a Gradio Space as the source."
|
| 1268 |
+
)
|
| 1269 |
+
except RepositoryNotFoundError:
|
| 1270 |
+
raise ValueError(
|
| 1271 |
+
f"Space '{space_id}' not found. Provide an existing Gradio Space ID."
|
| 1272 |
+
)
|
| 1273 |
+
|
| 1274 |
+
source_bucket_id = _get_source_bucket(space_id)
|
| 1275 |
+
print(f"* Reading project data from bucket: {source_bucket_id}")
|
| 1276 |
+
|
| 1277 |
+
bucket_id_was_explicit = bucket_id is not None
|
| 1278 |
+
|
| 1279 |
+
if new_space_id is None:
|
| 1280 |
+
new_space_id = f"{space_id}_static"
|
| 1281 |
+
new_space_id, _dataset_id, bucket_id = preprocess_space_and_dataset_ids(
|
| 1282 |
+
new_space_id, None, bucket_id
|
| 1283 |
+
)
|
| 1284 |
+
if bucket_id is not None and not bucket_id_was_explicit:
|
| 1285 |
+
bucket_id = resolve_auto_bucket_id(new_space_id, bucket_id)
|
| 1286 |
+
|
| 1287 |
+
hf_api = huggingface_hub.HfApi()
|
| 1288 |
+
try:
|
| 1289 |
+
dest_info = hf_api.space_info(new_space_id)
|
| 1290 |
+
tags = dest_info.tags or []
|
| 1291 |
+
if dest_info.sdk != "static" or "trackio" not in tags:
|
| 1292 |
+
raise ValueError(
|
| 1293 |
+
f"Space '{new_space_id}' already exists and is not a Trackio static Space "
|
| 1294 |
+
f"(sdk='{dest_info.sdk}', tags={tags}). Choose a different new_space_id "
|
| 1295 |
+
f"or delete the existing Space first."
|
| 1296 |
+
)
|
| 1297 |
+
except RepositoryNotFoundError:
|
| 1298 |
+
pass
|
| 1299 |
+
|
| 1300 |
+
create_bucket_if_not_exists(bucket_id, private=False)
|
| 1301 |
+
export_from_bucket_for_static(source_bucket_id, bucket_id, project)
|
| 1302 |
+
print(
|
| 1303 |
+
f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
|
| 1304 |
+
)
|
| 1305 |
+
deploy_as_static_space(
|
| 1306 |
+
new_space_id,
|
| 1307 |
+
None,
|
| 1308 |
+
project,
|
| 1309 |
+
bucket_id=bucket_id,
|
| 1310 |
+
private=False,
|
| 1311 |
+
frontend_dir=frontend_dir,
|
| 1312 |
+
)
|
| 1313 |
+
return new_space_id
|
trackio/dummy_commit_scheduler.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from concurrent.futures import Future
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class DummyCommitSchedulerLock:
|
| 5 |
+
def __enter__(self):
|
| 6 |
+
return None
|
| 7 |
+
|
| 8 |
+
def __exit__(self, exception_type, exception_value, exception_traceback):
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DummyCommitScheduler:
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.lock = DummyCommitSchedulerLock()
|
| 15 |
+
|
| 16 |
+
def trigger(self) -> Future:
|
| 17 |
+
fut: Future = Future()
|
| 18 |
+
fut.set_result(None)
|
| 19 |
+
return fut
|
trackio/exceptions.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class TrackioAPIError(Exception):
|
| 2 |
+
pass
|
trackio/fragments.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Append-only JSONL fragments used as a durable fallback for metric logging.
|
| 2 |
+
|
| 3 |
+
Fragments are immutable JSONL files, one writer (process) per subdirectory, so
|
| 4 |
+
concurrent training processes never contend on a shared file. They are written
|
| 5 |
+
either to a Hugging Face Bucket inbox (when a Space is unreachable) or to a
|
| 6 |
+
local inbox directory (when SQLite is unsafe, e.g. on network filesystems), and
|
| 7 |
+
are later imported into the project SQLite database by the process that owns it
|
| 8 |
+
(the Space or the dashboard server). Records carry the same ``log_id``/
|
| 9 |
+
``alert_id`` UUIDs as the HTTP logging endpoints, and imports use
|
| 10 |
+
``INSERT OR IGNORE``, so importing a fragment is idempotent: fragments are
|
| 11 |
+
deleted only after a successful import, and re-importing after a crash is
|
| 12 |
+
harmless.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import tempfile
|
| 16 |
+
import threading
|
| 17 |
+
import uuid
|
| 18 |
+
from collections.abc import Callable
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
import huggingface_hub
|
| 23 |
+
import orjson
|
| 24 |
+
|
| 25 |
+
from trackio import utils
|
| 26 |
+
from trackio.typehints import AlertEntry, LogEntry, SystemLogEntry
|
| 27 |
+
|
| 28 |
+
FRAGMENT_VERSION = 1
|
| 29 |
+
INBOX_DIR_NAME = "inbox"
|
| 30 |
+
BUCKET_INBOX_PREFIX = "trackio/inbox"
|
| 31 |
+
BUCKET_MEDIA_PREFIX = "trackio/media"
|
| 32 |
+
|
| 33 |
+
METRIC_KIND = "metric"
|
| 34 |
+
SYSTEM_METRIC_KIND = "system_metric"
|
| 35 |
+
ALERT_KIND = "alert"
|
| 36 |
+
KINDS = {METRIC_KIND, SYSTEM_METRIC_KIND, ALERT_KIND}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def local_inbox_dir() -> Path:
|
| 40 |
+
return utils.TRACKIO_DIR / INBOX_DIR_NAME
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def metric_record(entry: LogEntry | dict) -> dict:
|
| 44 |
+
return {
|
| 45 |
+
"v": FRAGMENT_VERSION,
|
| 46 |
+
"kind": METRIC_KIND,
|
| 47 |
+
"project": entry["project"],
|
| 48 |
+
"run": entry["run"],
|
| 49 |
+
"run_id": entry.get("run_id"),
|
| 50 |
+
"metrics": utils.serialize_values(entry.get("metrics") or {}),
|
| 51 |
+
"step": entry.get("step"),
|
| 52 |
+
"timestamp": entry.get("timestamp"),
|
| 53 |
+
"config": utils.serialize_values(entry.get("config"))
|
| 54 |
+
if entry.get("config")
|
| 55 |
+
else None,
|
| 56 |
+
"log_id": entry.get("log_id"),
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def system_metric_record(entry: SystemLogEntry | dict) -> dict:
|
| 61 |
+
return {
|
| 62 |
+
"v": FRAGMENT_VERSION,
|
| 63 |
+
"kind": SYSTEM_METRIC_KIND,
|
| 64 |
+
"project": entry["project"],
|
| 65 |
+
"run": entry["run"],
|
| 66 |
+
"run_id": entry.get("run_id"),
|
| 67 |
+
"metrics": utils.serialize_values(entry.get("metrics") or {}),
|
| 68 |
+
"timestamp": entry.get("timestamp"),
|
| 69 |
+
"log_id": entry.get("log_id"),
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def alert_record(entry: AlertEntry | dict) -> dict:
|
| 74 |
+
return {
|
| 75 |
+
"v": FRAGMENT_VERSION,
|
| 76 |
+
"kind": ALERT_KIND,
|
| 77 |
+
"project": entry["project"],
|
| 78 |
+
"run": entry["run"],
|
| 79 |
+
"run_id": entry.get("run_id"),
|
| 80 |
+
"title": entry["title"],
|
| 81 |
+
"text": entry.get("text"),
|
| 82 |
+
"level": entry.get("level"),
|
| 83 |
+
"step": entry.get("step"),
|
| 84 |
+
"timestamp": entry.get("timestamp"),
|
| 85 |
+
"alert_id": entry.get("alert_id"),
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def bucket_media_path(
|
| 90 |
+
project: str,
|
| 91 |
+
run: str | None,
|
| 92 |
+
step: int | None,
|
| 93 |
+
relative_path: str | None,
|
| 94 |
+
filename: str,
|
| 95 |
+
) -> str:
|
| 96 |
+
parts = [BUCKET_MEDIA_PREFIX, utils.canonical_project_name(project)]
|
| 97 |
+
if run:
|
| 98 |
+
parts.append(run)
|
| 99 |
+
if step is not None:
|
| 100 |
+
parts.append(str(step))
|
| 101 |
+
else:
|
| 102 |
+
parts.append("files")
|
| 103 |
+
if relative_path:
|
| 104 |
+
parts.append(str(relative_path))
|
| 105 |
+
parts.append(filename)
|
| 106 |
+
return "/".join(parts)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class FragmentWriter:
|
| 110 |
+
"""Writes immutable JSONL fragments for a single writer (process)."""
|
| 111 |
+
|
| 112 |
+
def __init__(self, writer_id: str | None = None):
|
| 113 |
+
self.writer_id = writer_id or uuid.uuid4().hex[:16]
|
| 114 |
+
self._seq = 0
|
| 115 |
+
self._lock = threading.Lock()
|
| 116 |
+
|
| 117 |
+
def _next_fragment_name(self) -> str:
|
| 118 |
+
with self._lock:
|
| 119 |
+
name = f"{self._seq:08d}.jsonl"
|
| 120 |
+
self._seq += 1
|
| 121 |
+
return name
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
def serialize_records(records: list[dict]) -> bytes:
|
| 125 |
+
return b"".join(orjson.dumps(record) + b"\n" for record in records)
|
| 126 |
+
|
| 127 |
+
def write_local(
|
| 128 |
+
self, records: list[dict], inbox_dir: Path | None = None
|
| 129 |
+
) -> Path | None:
|
| 130 |
+
if not records:
|
| 131 |
+
return None
|
| 132 |
+
inbox = inbox_dir or local_inbox_dir()
|
| 133 |
+
writer_dir = inbox / self.writer_id
|
| 134 |
+
fragment_path = writer_dir / self._next_fragment_name()
|
| 135 |
+
data = self.serialize_records(records)
|
| 136 |
+
for attempt in range(3):
|
| 137 |
+
writer_dir.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
try:
|
| 139 |
+
with tempfile.NamedTemporaryFile(
|
| 140 |
+
mode="wb", dir=writer_dir, suffix=".tmp", delete=False
|
| 141 |
+
) as tmp:
|
| 142 |
+
tmp.write(data)
|
| 143 |
+
tmp_path = Path(tmp.name)
|
| 144 |
+
tmp_path.replace(fragment_path)
|
| 145 |
+
return fragment_path
|
| 146 |
+
except FileNotFoundError:
|
| 147 |
+
if attempt == 2 or writer_dir.exists():
|
| 148 |
+
raise
|
| 149 |
+
|
| 150 |
+
def write_to_bucket(self, records: list[dict], bucket_id: str) -> str | None:
|
| 151 |
+
if not records:
|
| 152 |
+
return None
|
| 153 |
+
remote_path = (
|
| 154 |
+
f"{BUCKET_INBOX_PREFIX}/{self.writer_id}/{self._next_fragment_name()}"
|
| 155 |
+
)
|
| 156 |
+
huggingface_hub.batch_bucket_files(
|
| 157 |
+
bucket_id,
|
| 158 |
+
add=[(self.serialize_records(records), remote_path)],
|
| 159 |
+
token=huggingface_hub.utils.get_token(),
|
| 160 |
+
)
|
| 161 |
+
return remote_path
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def parse_fragment_bytes(data: bytes) -> list[dict]:
|
| 165 |
+
records = []
|
| 166 |
+
for line in data.splitlines():
|
| 167 |
+
line = line.strip()
|
| 168 |
+
if not line:
|
| 169 |
+
continue
|
| 170 |
+
try:
|
| 171 |
+
record = orjson.loads(line)
|
| 172 |
+
except orjson.JSONDecodeError:
|
| 173 |
+
continue
|
| 174 |
+
if isinstance(record, dict) and record.get("kind") in KINDS:
|
| 175 |
+
records.append(record)
|
| 176 |
+
return records
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _group_by_run(records: list[dict]) -> dict[tuple, list[dict]]:
|
| 180 |
+
grouped: dict[tuple, list[dict]] = {}
|
| 181 |
+
for record in records:
|
| 182 |
+
key = (record.get("project"), record.get("run"), record.get("run_id"))
|
| 183 |
+
grouped.setdefault(key, []).append(record)
|
| 184 |
+
return grouped
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def import_records(records: list[dict]) -> int:
|
| 188 |
+
from trackio.sqlite_storage import SQLiteStorage # noqa: PLC0415
|
| 189 |
+
|
| 190 |
+
metric_records = [r for r in records if r.get("kind") == METRIC_KIND]
|
| 191 |
+
system_records = [r for r in records if r.get("kind") == SYSTEM_METRIC_KIND]
|
| 192 |
+
alert_records = [r for r in records if r.get("kind") == ALERT_KIND]
|
| 193 |
+
imported = 0
|
| 194 |
+
|
| 195 |
+
for (project, run, run_id), group in _group_by_run(metric_records).items():
|
| 196 |
+
if not project or not run:
|
| 197 |
+
continue
|
| 198 |
+
config = next((r["config"] for r in group if r.get("config")), None)
|
| 199 |
+
has_timestamps = all(r.get("timestamp") for r in group)
|
| 200 |
+
SQLiteStorage.bulk_log(
|
| 201 |
+
project=project,
|
| 202 |
+
run=run,
|
| 203 |
+
run_id=run_id,
|
| 204 |
+
metrics_list=[r.get("metrics") or {} for r in group],
|
| 205 |
+
steps=[r.get("step") for r in group],
|
| 206 |
+
timestamps=[r["timestamp"] for r in group] if has_timestamps else None,
|
| 207 |
+
config=config,
|
| 208 |
+
log_ids=[r.get("log_id") for r in group],
|
| 209 |
+
)
|
| 210 |
+
imported += len(group)
|
| 211 |
+
|
| 212 |
+
for (project, run, run_id), group in _group_by_run(system_records).items():
|
| 213 |
+
if not project or not run:
|
| 214 |
+
continue
|
| 215 |
+
has_timestamps = all(r.get("timestamp") for r in group)
|
| 216 |
+
SQLiteStorage.bulk_log_system(
|
| 217 |
+
project=project,
|
| 218 |
+
run=run,
|
| 219 |
+
run_id=run_id,
|
| 220 |
+
metrics_list=[r.get("metrics") or {} for r in group],
|
| 221 |
+
timestamps=[r["timestamp"] for r in group] if has_timestamps else None,
|
| 222 |
+
log_ids=[r.get("log_id") for r in group],
|
| 223 |
+
)
|
| 224 |
+
imported += len(group)
|
| 225 |
+
|
| 226 |
+
for (project, run, run_id), group in _group_by_run(alert_records).items():
|
| 227 |
+
if not project or not run:
|
| 228 |
+
continue
|
| 229 |
+
has_timestamps = all(r.get("timestamp") for r in group)
|
| 230 |
+
SQLiteStorage.bulk_alert(
|
| 231 |
+
project=project,
|
| 232 |
+
run=run,
|
| 233 |
+
run_id=run_id,
|
| 234 |
+
titles=[r.get("title") or "" for r in group],
|
| 235 |
+
texts=[r.get("text") for r in group],
|
| 236 |
+
levels=[r.get("level") or "WARN" for r in group],
|
| 237 |
+
steps=[r.get("step") for r in group],
|
| 238 |
+
timestamps=[r["timestamp"] for r in group] if has_timestamps else None,
|
| 239 |
+
alert_ids=[r.get("alert_id") for r in group],
|
| 240 |
+
)
|
| 241 |
+
imported += len(group)
|
| 242 |
+
|
| 243 |
+
return imported
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def import_inbox_dir(inbox_dir: Path | None = None) -> int:
|
| 247 |
+
inbox = inbox_dir or local_inbox_dir()
|
| 248 |
+
if not inbox.exists():
|
| 249 |
+
return 0
|
| 250 |
+
imported = 0
|
| 251 |
+
for fragment_path in sorted(inbox.rglob("*.jsonl")):
|
| 252 |
+
try:
|
| 253 |
+
data = fragment_path.read_bytes()
|
| 254 |
+
except OSError:
|
| 255 |
+
continue
|
| 256 |
+
records = parse_fragment_bytes(data)
|
| 257 |
+
if records:
|
| 258 |
+
imported += import_records(records)
|
| 259 |
+
try:
|
| 260 |
+
fragment_path.unlink()
|
| 261 |
+
except OSError:
|
| 262 |
+
pass
|
| 263 |
+
for writer_dir in inbox.glob("*"):
|
| 264 |
+
if writer_dir.is_dir():
|
| 265 |
+
try:
|
| 266 |
+
writer_dir.rmdir()
|
| 267 |
+
except OSError:
|
| 268 |
+
pass
|
| 269 |
+
return imported
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def list_bucket_inbox_paths(bucket_id: str) -> list[str]:
|
| 273 |
+
try:
|
| 274 |
+
items = huggingface_hub.list_bucket_tree(
|
| 275 |
+
bucket_id,
|
| 276 |
+
prefix=BUCKET_INBOX_PREFIX,
|
| 277 |
+
recursive=True,
|
| 278 |
+
token=huggingface_hub.utils.get_token(),
|
| 279 |
+
)
|
| 280 |
+
except Exception:
|
| 281 |
+
return []
|
| 282 |
+
return sorted(
|
| 283 |
+
item.path
|
| 284 |
+
for item in items
|
| 285 |
+
if getattr(item, "type", None) == "file"
|
| 286 |
+
and getattr(item, "path", "").endswith(".jsonl")
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def import_inbox_from_bucket(bucket_id: str) -> int:
|
| 291 |
+
paths = list_bucket_inbox_paths(bucket_id)
|
| 292 |
+
if not paths:
|
| 293 |
+
return 0
|
| 294 |
+
imported = 0
|
| 295 |
+
consumed: list[str] = []
|
| 296 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 297 |
+
for i, remote_path in enumerate(paths):
|
| 298 |
+
local_path = Path(tmp_dir) / f"{i}.jsonl"
|
| 299 |
+
try:
|
| 300 |
+
huggingface_hub.download_bucket_files(
|
| 301 |
+
bucket_id,
|
| 302 |
+
files=[(remote_path, str(local_path))],
|
| 303 |
+
token=huggingface_hub.utils.get_token(),
|
| 304 |
+
)
|
| 305 |
+
records = parse_fragment_bytes(local_path.read_bytes())
|
| 306 |
+
except Exception:
|
| 307 |
+
continue
|
| 308 |
+
if records:
|
| 309 |
+
imported += import_records(records)
|
| 310 |
+
consumed.append(remote_path)
|
| 311 |
+
if consumed:
|
| 312 |
+
try:
|
| 313 |
+
huggingface_hub.batch_bucket_files(
|
| 314 |
+
bucket_id,
|
| 315 |
+
delete=consumed,
|
| 316 |
+
token=huggingface_hub.utils.get_token(),
|
| 317 |
+
)
|
| 318 |
+
except Exception:
|
| 319 |
+
pass
|
| 320 |
+
return imported
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _add_files_to_bucket(bucket_id: str, additions: list[tuple[str, str]]) -> None:
|
| 324 |
+
if additions:
|
| 325 |
+
huggingface_hub.batch_bucket_files(
|
| 326 |
+
bucket_id,
|
| 327 |
+
add=additions,
|
| 328 |
+
token=huggingface_hub.utils.get_token(),
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _upload_files_to_bucket(
|
| 333 |
+
bucket_id: str,
|
| 334 |
+
uploads: list[dict[str, Any]],
|
| 335 |
+
remote_path: Callable[[dict[str, Any], Path], str],
|
| 336 |
+
) -> None:
|
| 337 |
+
_add_files_to_bucket(
|
| 338 |
+
bucket_id,
|
| 339 |
+
[
|
| 340 |
+
(str(p), remote_path(upload, p))
|
| 341 |
+
for upload in uploads
|
| 342 |
+
if (p := Path(upload["file_path"])).exists()
|
| 343 |
+
],
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def upload_media_files_to_bucket(bucket_id: str, uploads: list[dict[str, Any]]) -> None:
|
| 348 |
+
_upload_files_to_bucket(
|
| 349 |
+
bucket_id,
|
| 350 |
+
uploads,
|
| 351 |
+
lambda upload, p: bucket_media_path(
|
| 352 |
+
project=upload["project"],
|
| 353 |
+
run=upload.get("run"),
|
| 354 |
+
step=upload.get("step"),
|
| 355 |
+
relative_path=upload.get("relative_path"),
|
| 356 |
+
filename=p.name,
|
| 357 |
+
),
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def upload_artifact_blobs_to_bucket(
|
| 362 |
+
bucket_id: str, uploads: list[dict[str, Any]]
|
| 363 |
+
) -> None:
|
| 364 |
+
_upload_files_to_bucket(
|
| 365 |
+
bucket_id,
|
| 366 |
+
uploads,
|
| 367 |
+
lambda upload, p: f"trackio/{p.relative_to(utils.TRACKIO_DIR).as_posix()}",
|
| 368 |
+
)
|
trackio/frontend/dist/assets/index-m3jgYXoS.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--primary-50: #fff7ed;--primary-100: #ffedd5;--primary-200: #fed7aa;--primary-300: #fdba74;--primary-400: #fb923c;--primary-500: #f97316;--primary-600: #ea580c;--primary-700: #c2410c;--primary-800: #9a3412;--primary-900: #7c2d12;--primary-950: #6c2e12;--secondary-50: #eff6ff;--secondary-100: #dbeafe;--secondary-200: #bfdbfe;--secondary-300: #93c5fd;--secondary-400: #60a5fa;--secondary-500: #3b82f6;--secondary-600: #2563eb;--secondary-700: #1d4ed8;--secondary-800: #1e40af;--secondary-900: #1e3a8a;--secondary-950: #1d3660;--neutral-50: #f9fafb;--neutral-100: #f3f4f6;--neutral-200: #e5e7eb;--neutral-300: #d1d5db;--neutral-400: #9ca3af;--neutral-500: #6b7280;--neutral-600: #4b5563;--neutral-700: #374151;--neutral-800: #1f2937;--neutral-900: #111827;--neutral-950: #0b0f19;--size-0-5: 2px;--size-1: 4px;--size-2: 8px;--size-3: 12px;--size-4: 16px;--size-5: 20px;--size-6: 24px;--size-8: 32px;--size-14: 56px;--size-16: 64px;--size-28: 112px;--size-full: 100%;--spacing-xxs: 1px;--spacing-xs: 2px;--spacing-sm: 4px;--spacing-md: 6px;--spacing-lg: 8px;--spacing-xl: 10px;--spacing-xxl: 16px;--radius-xxs: 1px;--radius-xs: 2px;--radius-sm: 3px;--radius-md: 4px;--radius-lg: 5px;--radius-xl: 8px;--radius-xxl: 12px;--text-xxs: 9px;--text-xs: 10px;--text-sm: 12px;--text-md: 14px;--text-lg: 16px;--text-xl: 22px;--text-xxl: 26px;--line-sm: 1.4;--background-fill-primary: white;--background-fill-secondary: var(--neutral-50);--body-text-color: var(--neutral-900);--body-text-color-subdued: var(--neutral-600);--border-color-primary: var(--neutral-200);--color-accent: var(--primary-500);--color-accent-soft: var(--primary-50);--shadow-drop: rgba(0, 0, 0, .05) 0px 1px 2px 0px;--shadow-drop-lg: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--shadow-inset: rgba(0, 0, 0, .05) 0px 2px 4px 0px inset;--shadow-spread: 3px;--block-title-text-color: var(--neutral-500);--block-title-text-size: var(--text-md);--block-title-text-weight: 400;--block-info-text-color: var(--body-text-color-subdued);--block-info-text-size: var(--text-sm);--input-background-fill: white;--input-background-fill-focus: var(--primary-500);--input-border-color: var(--border-color-primary);--input-border-color-focus: var(--primary-300);--input-border-width: 1px;--input-padding: var(--spacing-xl);--input-placeholder-color: var(--neutral-400);--input-radius: var(--radius-lg);--input-shadow: 0 0 0 var(--shadow-spread) transparent, var(--shadow-inset);--input-shadow-focus: 0 0 0 var(--shadow-spread) var(--primary-50), var(--shadow-inset);--input-text-size: var(--text-md);--checkbox-background-color: var(--background-fill-primary);--checkbox-background-color-focus: var(--checkbox-background-color);--checkbox-background-color-hover: var(--checkbox-background-color);--checkbox-background-color-selected: var(--primary-600);--checkbox-border-color: var(--neutral-300);--checkbox-border-color-focus: var(--primary-500);--checkbox-border-color-hover: var(--neutral-300);--checkbox-border-color-selected: var(--primary-600);--checkbox-border-radius: var(--radius-sm);--checkbox-border-width: var(--input-border-width);--checkbox-label-gap: var(--spacing-lg);--checkbox-label-padding: var(--spacing-md) calc(2 * var(--spacing-md));--checkbox-label-text-size: var(--text-md);--checkbox-shadow: var(--input-shadow);--checkbox-check: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");--slider-color: var(--primary-500);--container-radius: var(--radius-lg);--layer-top: 9999}.navbar.svelte-d8j1hi{display:flex;align-items:stretch;border-bottom:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, white);padding:0;flex-shrink:0;min-height:44px}.nav-spacer.svelte-d8j1hi{flex:1 1 0;min-width:0}.nav-tabs.svelte-d8j1hi{display:flex;gap:0;flex-shrink:0;padding-right:8px}.nav-link.svelte-d8j1hi{padding:10px 16px;border:none;background:none;color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-md, 14px);cursor:pointer;white-space:nowrap;border-bottom:2px solid transparent;transition:color .15s;font-weight:400}.nav-link.empty.svelte-d8j1hi:not(.active){color:var(--body-text-color-subdued, #9ca3af);opacity:.48}.nav-link.svelte-d8j1hi:hover{color:var(--body-text-color, #1f2937);opacity:1}.nav-link.active.svelte-d8j1hi{color:var(--body-text-color, #1f2937);border-bottom-color:var(--body-text-color, #1f2937);font-weight:500}.settings-btn.svelte-d8j1hi{display:flex;align-items:center;gap:6px}.settings-btn.svelte-d8j1hi svg:where(.svelte-d8j1hi){flex-shrink:0}.checkbox-group.svelte-17gmtkf{display:flex;flex-direction:column}.checkbox-item.svelte-17gmtkf{display:flex;align-items:center;gap:8px;padding:3px 0;cursor:pointer;font-size:13px}.checkbox-item.svelte-17gmtkf input[type=checkbox]:where(.svelte-17gmtkf){-moz-appearance:none;appearance:none;-webkit-appearance:none;width:16px;height:16px;margin:0;border:1px solid var(--checkbox-border-color, #d1d5db);border-radius:var(--checkbox-border-radius, 4px);background-color:var(--checkbox-background-color, white);box-shadow:var(--checkbox-shadow);cursor:pointer;flex-shrink:0;transition:background-color .15s,border-color .15s}.checkbox-item.svelte-17gmtkf input[type=checkbox]:where(.svelte-17gmtkf):checked{background-image:var(--checkbox-check);background-color:var(--checkbox-background-color-selected, #f97316);border-color:var(--checkbox-border-color-selected, #f97316)}.checkbox-item.svelte-17gmtkf input[type=checkbox]:where(.svelte-17gmtkf):hover{border-color:var(--checkbox-border-color-hover, #d1d5db)}.color-dot.svelte-17gmtkf{width:10px;height:10px;border-radius:50%;flex-shrink:0}.run-name.svelte-17gmtkf{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--body-text-color, #1f2937)}.dropdown-container.svelte-kgylqb{width:100%;margin-bottom:4px}.label.svelte-kgylqb{display:block;font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280);margin-bottom:6px}.info.svelte-kgylqb{display:block;font-size:12px;color:var(--body-text-color-subdued, #9ca3af);margin-bottom:4px}.wrap.svelte-kgylqb{position:relative;border-radius:var(--input-radius, 8px);background:var(--input-background-fill, white);border:1px solid var(--border-color-primary, #e5e7eb);transition:border-color .15s,box-shadow .15s}.wrap.focused.svelte-kgylqb{border-color:var(--input-border-color-focus, #fdba74);box-shadow:0 0 0 2px var(--primary-50, #fff7ed)}.wrap-inner.svelte-kgylqb{display:flex;position:relative;align-items:center;padding:0 10px}.secondary-wrap.svelte-kgylqb{display:flex;flex:1;align-items:center}input.svelte-kgylqb{margin:0;outline:none;border:none;background:inherit;width:100%;color:var(--body-text-color, #1f2937);font-size:13px;font-family:inherit;padding:7px 0}input.svelte-kgylqb::placeholder{color:var(--input-placeholder-color, #9ca3af)}input[readonly].svelte-kgylqb{cursor:pointer}.icon-wrap.svelte-kgylqb{color:var(--body-text-color-subdued, #9ca3af);width:16px;flex-shrink:0;pointer-events:none}.options.svelte-kgylqb{position:fixed;z-index:var(--layer-top, 9999);margin:0;padding:4px 0;box-shadow:0 4px 12px #0000001f;border-radius:var(--input-radius, 8px);border:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, white);min-width:fit-content;overflow:auto;color:var(--body-text-color, #1f2937);list-style:none}.item.svelte-kgylqb{display:flex;cursor:pointer;padding:6px 10px;font-size:13px;word-break:break-word}.item.svelte-kgylqb:hover,.item.active.svelte-kgylqb{background:var(--background-fill-secondary, #f9fafb)}.item.selected.svelte-kgylqb{font-weight:500}.check-mark.svelte-kgylqb{padding-right:6px;min-width:16px;font-size:12px}.check-mark.hide.svelte-kgylqb{visibility:hidden}.section-label.svelte-1kanzy0{font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280)}.locked-project.svelte-1kanzy0{margin-top:4px;font-size:13px;font-weight:500;color:var(--body-text-color, #1f2937);padding:8px 10px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);background:var(--background-fill-secondary, #f9fafb)}.logo-section.svelte-tssf2h{margin-bottom:20px}.logo.svelte-tssf2h{width:80%;max-width:200px}.checkbox-container.svelte-oj84db{display:flex;align-items:center;gap:8px;cursor:pointer;padding:3px 0}.label-text.svelte-oj84db{color:var(--body-text-color, #1f2937);font-size:13px;line-height:1.4}input[type=checkbox].svelte-oj84db{--ring-color: transparent;position:relative;-moz-appearance:none;appearance:none;-webkit-appearance:none;width:16px;height:16px;box-shadow:var(--checkbox-shadow);border:1px solid var(--checkbox-border-color, #d1d5db);border-radius:var(--checkbox-border-radius, 4px);background-color:var(--checkbox-background-color, white);flex-shrink:0;cursor:pointer;transition:background-color .15s,border-color .15s}input[type=checkbox].svelte-oj84db:checked,input[type=checkbox].svelte-oj84db:checked:hover,input[type=checkbox].svelte-oj84db:checked:focus{background-image:var(--checkbox-check);background-color:var(--checkbox-background-color-selected, #f97316);border-color:var(--checkbox-border-color-selected, #f97316)}input[type=checkbox].svelte-oj84db:hover{border-color:var(--checkbox-border-color-hover, #d1d5db);background-color:var(--checkbox-background-color-hover, white)}input[type=checkbox].svelte-oj84db:focus{border-color:var(--checkbox-border-color-focus, #f97316);background-color:var(--checkbox-background-color-focus, white);outline:none}.slider-wrap.svelte-wei6ev{display:flex;flex-direction:column;width:100%}.head.svelte-wei6ev{margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;width:100%}.label.svelte-wei6ev{flex:1;font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280)}.info.svelte-wei6ev{display:block;font-size:12px;color:var(--body-text-color-subdued, #9ca3af);margin-bottom:4px}.slider-input-container.svelte-wei6ev{display:flex;align-items:center;gap:6px}input[type=range].svelte-wei6ev{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;cursor:pointer;outline:none;border-radius:var(--radius-xl, 12px);min-width:var(--size-28, 112px);background:transparent}input[type=range].svelte-wei6ev::-webkit-slider-runnable-track{height:6px;border-radius:var(--radius-xl, 12px);background:linear-gradient(to right,var(--slider-color, #f97316) var(--range_progress, 50%),var(--neutral-200, #e5e7eb) var(--range_progress, 50%))}input[type=range].svelte-wei6ev::-webkit-slider-thumb{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:16px;width:16px;background-color:var(--slider-color, #f97316);border:2px solid var(--background-fill-primary, white);border-radius:50%;margin-top:-5px;box-shadow:0 0 0 1px var(--border-color-primary, rgba(0, 0, 0, .08)),0 1px 3px #0003}input[type=range].svelte-wei6ev::-moz-range-track{height:6px;background:var(--neutral-200, #e5e7eb);border-radius:var(--radius-xl, 12px)}input[type=range].svelte-wei6ev::-moz-range-thumb{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:16px;width:16px;background-color:var(--slider-color, #f97316);border:2px solid var(--background-fill-primary, white);border-radius:50%;box-shadow:0 0 0 1px var(--border-color-primary, rgba(0, 0, 0, .08)),0 1px 3px #0003}input[type=range].svelte-wei6ev::-moz-range-progress{height:6px;background-color:var(--slider-color, #f97316);border-radius:var(--radius-xl, 12px)}.bound.svelte-wei6ev{font-size:11px;color:var(--body-text-color-subdued, #9ca3af);min-width:12px;text-align:center}.textbox-container.svelte-6yncpg{width:100%}.label.svelte-6yncpg{display:block;font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280);margin-bottom:6px}.info.svelte-6yncpg{display:block;font-size:12px;color:var(--body-text-color-subdued, #9ca3af);margin-bottom:4px}.input-wrap.svelte-6yncpg{border-radius:var(--input-radius, 8px);background:var(--input-background-fill, white);border:1px solid var(--border-color-primary, #e5e7eb);transition:border-color .15s,box-shadow .15s}.input-wrap.svelte-6yncpg:focus-within{border-color:var(--input-border-color-focus, #fdba74);box-shadow:0 0 0 2px var(--primary-50, #fff7ed)}input.svelte-6yncpg{width:100%;padding:7px 10px;outline:none;border:none;background:transparent;color:var(--body-text-color, #1f2937);font-size:13px;font-family:inherit;border-radius:var(--input-radius, 8px)}input.svelte-6yncpg::placeholder{color:var(--input-placeholder-color, #9ca3af)}.sidebar.svelte-181dlmc{width:290px;min-width:290px;background:var(--background-fill-primary, white);border-right:1px solid var(--border-color-primary, #e5e7eb);display:flex;flex-direction:column;position:relative;overflow:hidden;transition:width .2s,min-width .2s}.sidebar.collapsed.svelte-181dlmc{width:40px;min-width:40px}.toggle-btn.svelte-181dlmc{position:absolute;top:12px;right:8px;z-index:10;border:none;background:none;color:var(--body-text-color-subdued, #9ca3af);cursor:pointer;padding:4px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm, 4px);transition:color .15s,background-color .15s}.toggle-btn.svelte-181dlmc:hover{color:var(--body-text-color, #1f2937);background-color:var(--background-fill-secondary, #f9fafb)}.sidebar-content.svelte-181dlmc{padding:16px;flex:1;min-height:0;display:flex;flex-direction:column}.sidebar-scroll.svelte-181dlmc{overflow-y:auto;flex:1;min-height:0}.oauth-footer.svelte-181dlmc{flex-shrink:0;margin-top:12px;padding-top:12px;border-top:1px solid var(--border-color-primary, #e5e7eb)}.readonly-footer.svelte-181dlmc{flex-shrink:0;margin-top:12px;padding-top:12px;border-top:1px solid var(--border-color-primary, #e5e7eb);display:flex;align-items:center;gap:8px;flex-wrap:wrap}.readonly-badge.svelte-181dlmc{display:inline-flex;align-items:center;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:999px;padding:2px 8px;font-size:10px;letter-spacing:.06em;font-weight:600;color:var(--body-text-color-subdued, #6b7280);background:var(--background-fill-secondary, #f9fafb)}.readonly-link.svelte-181dlmc{font-size:12px;color:var(--body-text-color-subdued, #6b7280);text-decoration:none;max-width:100%;overflow-wrap:anywhere}.readonly-link.svelte-181dlmc:hover{color:var(--body-text-color, #1f2937);text-decoration:underline}.oauth-line.svelte-181dlmc{margin:0;font-size:12px;line-height:1.4;color:var(--body-text-color-subdued, #6b7280)}.oauth-warn.svelte-181dlmc{color:var(--body-text-color, #92400e)}.hf-login-btn.svelte-181dlmc{display:inline-flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:8px 12px;font-size:13px;font-weight:600;color:#fff;background:#141c2e;border-radius:var(--radius-lg, 8px);text-decoration:none;border:none;cursor:pointer;box-sizing:border-box}.hf-login-btn.svelte-181dlmc:hover{background:#283042}.hf-logo.svelte-181dlmc{width:20px;height:20px;flex-shrink:0}.oauth-hint.svelte-181dlmc{margin:8px 0 0;font-size:11px;line-height:1.35;color:var(--body-text-color-subdued, #9ca3af)}.oauth-signed-in.svelte-181dlmc{margin:0;font-size:12px;color:var(--body-text-color-subdued, #6b7280)}.oauth-logout.svelte-181dlmc{font-size:12px;color:var(--body-text-color-subdued, #9ca3af);text-decoration:none;cursor:pointer}.oauth-logout.svelte-181dlmc:hover{text-decoration:underline;color:var(--body-text-color, #1f2937)}.section.svelte-181dlmc{margin-top:2px;margin-bottom:18px}.share-tabs.svelte-181dlmc{display:flex;gap:6px;margin-bottom:8px}.share-tab-btn.svelte-181dlmc{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);padding:4px 8px;font-size:12px;color:var(--body-text-color-subdued, #6b7280);background:var(--background-fill-primary, white);cursor:pointer}.share-tab-btn.active.svelte-181dlmc{color:var(--body-text-color, #1f2937);background:var(--background-fill-secondary, #f9fafb)}.share-field.svelte-181dlmc{display:flex;flex-direction:column;gap:6px}.share-input-row.svelte-181dlmc{display:flex;gap:6px;align-items:stretch}.share-input-row.svelte-181dlmc input:where(.svelte-181dlmc),.share-input-row.svelte-181dlmc textarea:where(.svelte-181dlmc){width:100%;min-width:0;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);padding:6px 8px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;color:var(--body-text-color, #1f2937);background:var(--background-fill-secondary, #f9fafb);resize:vertical}.copy-btn.svelte-181dlmc{box-sizing:border-box;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);padding:6px 10px;min-width:3.25rem;display:inline-flex;align-items:center;justify-content:center;font-size:12px;line-height:1;color:var(--body-text-color, #1f2937);background:var(--background-fill-primary, white);cursor:pointer;flex-shrink:0}.copy-btn-check.svelte-181dlmc{display:block;color:var(--color-accent, #f97316)}.share-hint.svelte-181dlmc{margin:0;font-size:12px;line-height:1.4;color:var(--body-text-color-subdued, #9ca3af)}.section-label.svelte-181dlmc{font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280)}.runs-header.svelte-181dlmc{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.select-all-label.svelte-181dlmc{display:flex;align-items:center;gap:6px;cursor:pointer}.select-all-cb.svelte-181dlmc{-moz-appearance:none;appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid var(--checkbox-border-color, #d1d5db);border-radius:var(--checkbox-border-radius, 4px);background-color:var(--checkbox-background-color, white);cursor:pointer;flex-shrink:0;position:relative;transition:background-color .15s,border-color .15s}.select-all-cb.svelte-181dlmc:checked{background-color:var(--checkbox-background-color-selected, var(--color-accent, #f97316));border-color:var(--checkbox-background-color-selected, var(--color-accent, #f97316));background-image:var(--checkbox-check)}.select-all-cb.svelte-181dlmc:indeterminate{background-color:var(--checkbox-background-color-selected, var(--color-accent, #f97316));border-color:var(--checkbox-background-color-selected, var(--color-accent, #f97316));background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='3' y='7' width='10' height='2' rx='1'/%3E%3C/svg%3E");background-size:12px;background-position:center;background-repeat:no-repeat}.latest-toggle.svelte-181dlmc{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--body-text-color-subdued, #6b7280);cursor:pointer}.latest-toggle.svelte-181dlmc input[type=checkbox]:where(.svelte-181dlmc){-moz-appearance:none;appearance:none;-webkit-appearance:none;width:16px;height:16px;margin:0;border:1px solid var(--checkbox-border-color, #d1d5db);border-radius:var(--checkbox-border-radius, 4px);background-color:var(--checkbox-background-color, white);box-shadow:var(--checkbox-shadow);cursor:pointer;flex-shrink:0;transition:background-color .15s,border-color .15s}.latest-toggle.svelte-181dlmc input[type=checkbox]:where(.svelte-181dlmc):checked{background-image:var(--checkbox-check);background-color:var(--checkbox-background-color-selected, #f97316);border-color:var(--checkbox-border-color-selected, #f97316)}.checkbox-list.svelte-181dlmc{max-height:300px;overflow-y:auto;margin-top:8px}.device-group.svelte-181dlmc{margin-top:14px;padding-top:12px;border-top:1px solid var(--border-color-primary, #e5e7eb)}.section-sublabel.svelte-181dlmc{font-size:12px;font-weight:600;color:var(--body-text-color-subdued, #6b7280)}.checkbox-group.svelte-181dlmc{display:flex;flex-direction:column;margin-top:8px}.checkbox-item.svelte-181dlmc{display:flex;align-items:center;gap:8px;padding:3px 0;cursor:pointer;font-size:13px}.checkbox-item.svelte-181dlmc input[type=checkbox]:where(.svelte-181dlmc){-moz-appearance:none;appearance:none;-webkit-appearance:none;width:16px;height:16px;margin:0;border:1px solid var(--checkbox-border-color, #d1d5db);border-radius:var(--checkbox-border-radius, 4px);background-color:var(--checkbox-background-color, white);box-shadow:var(--checkbox-shadow);cursor:pointer;flex-shrink:0;transition:background-color .15s,border-color .15s}.checkbox-item.svelte-181dlmc input[type=checkbox]:where(.svelte-181dlmc):checked{background-image:var(--checkbox-check);background-color:var(--checkbox-background-color-selected, #f97316);border-color:var(--checkbox-border-color-selected, #f97316)}.checkbox-item.svelte-181dlmc input[type=checkbox]:where(.svelte-181dlmc):hover{border-color:var(--checkbox-border-color-hover, #d1d5db)}.run-name.svelte-181dlmc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--body-text-color, #1f2937)}.group-by-row.svelte-181dlmc{margin-top:8px}.grouped-runs.svelte-181dlmc{margin-top:8px;display:flex;flex-direction:column;gap:6px}.run-group-section.svelte-181dlmc{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);overflow:hidden}.run-group-header.svelte-181dlmc{display:flex;align-items:center;padding:6px 10px;background:var(--background-fill-secondary, #f9fafb);border-bottom:1px solid var(--border-color-primary, #e5e7eb)}.run-group-label.svelte-181dlmc{font-size:12px;font-weight:600;color:var(--body-text-color, #1f2937);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:160px}.run-group-count.svelte-181dlmc{font-size:11px;color:var(--body-text-color-subdued, #9ca3af);margin-left:4px;flex-shrink:0}.group-checkbox-list.svelte-181dlmc{padding:4px 10px;max-height:none}.alert-panel.svelte-x5aqew{position:fixed;bottom:16px;right:16px;width:380px;max-height:400px;background:var(--background-fill-primary, white);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);box-shadow:var(--shadow-drop-lg);z-index:1000;overflow:hidden;display:flex;flex-direction:column}.alert-panel.collapsed.svelte-x5aqew{max-height:none}.alert-header.svelte-x5aqew{padding:10px 12px;border:none;border-bottom:1px solid var(--border-color-primary, #e5e7eb);background:none;width:100%;display:flex;align-items:center;justify-content:space-between;cursor:pointer;gap:8px}.alert-panel.collapsed.svelte-x5aqew .alert-header:where(.svelte-x5aqew){border-bottom:none}.collapse-icon.svelte-x5aqew{color:var(--body-text-color-subdued, #9ca3af);flex-shrink:0;transition:transform .15s}.collapse-icon.rotated.svelte-x5aqew{transform:rotate(-90deg)}.alert-title.svelte-x5aqew{font-size:13px;font-weight:600;color:var(--body-text-color, #1f2937)}.filter-pills.svelte-x5aqew{display:flex;gap:4px}.pill.svelte-x5aqew{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-xxl, 22px);padding:2px 8px;font-size:11px;background:var(--background-fill-secondary, #f9fafb);color:var(--body-text-color-subdued, #6b7280);cursor:pointer}.pill.active.svelte-x5aqew{background:var(--color-accent, #f97316);color:#fff;border-color:var(--color-accent, #f97316)}.alert-list.svelte-x5aqew{overflow-y:auto;flex:1}.alert-item.svelte-x5aqew{border-bottom:1px solid var(--neutral-100, #f3f4f6)}.alert-row.svelte-x5aqew{display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;border:none;background:none;text-align:left;cursor:pointer;font-size:var(--text-sm, 12px)}.alert-row.svelte-x5aqew:hover{background:var(--background-fill-secondary, #f9fafb)}.alert-text.svelte-x5aqew{flex:1;color:var(--body-text-color, #1f2937)}.alert-meta.svelte-x5aqew{font-size:var(--text-xs, 10px);color:var(--body-text-color-subdued, #9ca3af);white-space:nowrap}.alert-detail.svelte-x5aqew{padding:4px 12px 8px 32px;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.plot-container.svelte-9thu1j{min-width:350px;flex:1;background:var(--background-fill-primary, white);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);padding:12px;overflow:hidden;position:relative}.plot-container[draggable=true].svelte-9thu1j{cursor:grab}.plot-container[draggable=true].svelte-9thu1j:active{cursor:grabbing}.hidden-plot.svelte-9thu1j{visibility:hidden;height:0;padding:0;margin:0;border:none;overflow:hidden;pointer-events:none}.drag-handle.svelte-9thu1j{position:absolute;top:8px;left:8px;color:var(--body-text-color-subdued, #9ca3af);opacity:0;transition:opacity .15s;z-index:5}.plot-container.svelte-9thu1j:hover .drag-handle:where(.svelte-9thu1j){opacity:.5}.drag-handle.svelte-9thu1j:hover{opacity:1!important}.plot-toolbar.svelte-9thu1j{position:absolute;top:8px;right:8px;display:flex;gap:4px;z-index:5;opacity:0;transition:opacity .15s}.plot-container.svelte-9thu1j:hover .plot-toolbar:where(.svelte-9thu1j){opacity:1}.toolbar-btn.svelte-9thu1j{border:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, white);color:var(--body-text-color-subdued, #6b7280);cursor:pointer;padding:4px 6px;border-radius:var(--radius-sm, 4px);display:flex;align-items:center;justify-content:center}.toolbar-btn.svelte-9thu1j:hover{background:var(--neutral-100, #f3f4f6);color:var(--body-text-color, #1f2937)}.plot-chart-wrap.svelte-9thu1j{position:relative;width:100%}.plot-chart-wrap--fs.svelte-9thu1j{flex:1;min-height:0;display:flex;flex-direction:column}.reset-zoom-btn.svelte-9thu1j{position:absolute;bottom:1px;right:1px;z-index:6;display:inline-flex;align-items:center;justify-content:center;margin:0;min-width:52px;padding:5px 12px 5px 10px;border:none;border-radius:4px;background:transparent;color:var(--body-text-color-subdued, #334155);cursor:pointer;opacity:.92;transform:translateY(6px);transition:opacity .15s ease,color .15s ease,background .15s ease;box-shadow:none}.reset-zoom-btn.svelte-9thu1j:hover{opacity:1;color:var(--body-text-color, #0f172a);background:var(--background-fill-secondary, rgba(226, 232, 240, .85));transform:translateY(6px)}.reset-zoom-btn.svelte-9thu1j svg:where(.svelte-9thu1j){display:block;flex-shrink:0;filter:drop-shadow(0 0 .5px rgba(255,255,255,.95))}.plot-title.svelte-9thu1j{font-size:13px;font-weight:600;color:var(--body-text-color, #374151);text-align:center;padding:0 0 6px;word-break:break-word}.plot-title--fs.svelte-9thu1j{flex-shrink:0}.plot.svelte-9thu1j{width:100%}.plot.svelte-9thu1j .vega-embed{width:100%!important}.plot.svelte-9thu1j .vega-embed summary{display:none}.fullscreen-host.svelte-9thu1j{position:fixed;top:0;right:0;bottom:0;left:0;z-index:10000;box-sizing:border-box;display:flex;flex-direction:column;background:var(--background-fill-primary, white);padding:12px;gap:8px;pointer-events:auto}.fullscreen-host.svelte-9thu1j:fullscreen{width:100%;height:100%}.fullscreen-host.svelte-9thu1j:-webkit-full-screen{width:100%;height:100%}.fullscreen-toolbar.svelte-9thu1j{flex-shrink:0;display:flex;justify-content:flex-end;gap:4px;z-index:5}.fullscreen-chart-wrap.svelte-9thu1j{flex:1;min-height:0;display:flex;flex-direction:column}.fullscreen-legend.svelte-9thu1j{flex-shrink:0}.fullscreen-plot.svelte-9thu1j{flex:1;min-height:0;width:100%;overflow:hidden}.fullscreen-plot.svelte-9thu1j .vega-embed{width:100%!important;height:100%!important;min-height:0;display:flex;flex-direction:column}.fullscreen-plot.svelte-9thu1j .vega-embed .vega-view{flex:1;min-height:0}.fullscreen-plot.svelte-9thu1j .vega-embed summary{display:none}.custom-legend.svelte-9thu1j{display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 0 0;flex-wrap:wrap}.legend-title.svelte-9thu1j{font-size:11px;color:var(--body-text-color-subdued, #6b7280);font-weight:600}.legend-item.svelte-9thu1j{display:flex;align-items:center;gap:4px}.legend-dot.svelte-9thu1j{width:10px;height:10px;border-radius:50%;flex-shrink:0}.legend-line-swatch.svelte-9thu1j{width:24px;height:10px;flex-shrink:0;color:var(--body-text-color, #1f2937)}.legend-label.svelte-9thu1j{font-size:11px;color:var(--body-text-color-subdued, #6b7280)}.legend-toggle.svelte-9thu1j{font-size:11px;color:var(--body-text-color-subdued, #6b7280);background:none;border:none;padding:0 4px;cursor:pointer;text-decoration:underline}.legend-toggle.svelte-9thu1j:hover{color:var(--body-text-color, #1f2937)}.plot-container.svelte-1swghqy{min-width:350px;flex:1;background:var(--background-fill-primary, white);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);padding:12px;overflow:hidden;position:relative}.plot-container[draggable=true].svelte-1swghqy{cursor:grab}.plot-container[draggable=true].svelte-1swghqy:active{cursor:grabbing}.hidden-plot.svelte-1swghqy{visibility:hidden;height:0;padding:0;margin:0;border:none;overflow:hidden;pointer-events:none}.drag-handle.svelte-1swghqy{position:absolute;top:8px;left:8px;color:var(--body-text-color-subdued, #9ca3af);opacity:0;transition:opacity .15s;z-index:5}.plot-container.svelte-1swghqy:hover .drag-handle:where(.svelte-1swghqy){opacity:.5}.drag-handle.svelte-1swghqy:hover{opacity:1!important}.plot-toolbar.svelte-1swghqy{position:absolute;top:8px;right:8px;display:flex;gap:4px;z-index:5;opacity:0;transition:opacity .15s}.plot-container.svelte-1swghqy:hover .plot-toolbar:where(.svelte-1swghqy){opacity:1}.toolbar-btn.svelte-1swghqy{border:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, white);color:var(--body-text-color-subdued, #6b7280);cursor:pointer;padding:4px 6px;border-radius:var(--radius-sm, 4px);display:flex;align-items:center;justify-content:center}.toolbar-btn.svelte-1swghqy:hover{background:var(--neutral-100, #f3f4f6);color:var(--body-text-color, #1f2937)}.plot-chart-wrap.svelte-1swghqy{position:relative;width:100%}.plot-chart-wrap--fs.svelte-1swghqy{flex:1;min-height:0;display:flex;flex-direction:column}.plot-title.svelte-1swghqy{font-size:13px;font-weight:600;color:var(--body-text-color, #374151);text-align:center;padding:0 0 6px;word-break:break-word}.plot-title--fs.svelte-1swghqy{flex-shrink:0}.plot.svelte-1swghqy{width:100%}.plot.svelte-1swghqy .vega-embed{width:100%!important}.plot.svelte-1swghqy .vega-embed summary{display:none}.fullscreen-host.svelte-1swghqy{position:fixed;top:0;right:0;bottom:0;left:0;z-index:10000;box-sizing:border-box;display:flex;flex-direction:column;background:var(--background-fill-primary, white);padding:12px;gap:8px;pointer-events:auto}.fullscreen-host.svelte-1swghqy:fullscreen{width:100%;height:100%}.fullscreen-host.svelte-1swghqy:-webkit-full-screen{width:100%;height:100%}.fullscreen-toolbar.svelte-1swghqy{flex-shrink:0;display:flex;justify-content:flex-end;gap:4px;z-index:5}.fullscreen-chart-wrap.svelte-1swghqy{flex:1;min-height:0;display:flex;flex-direction:column}.fullscreen-legend.svelte-1swghqy{flex-shrink:0}.fullscreen-plot.svelte-1swghqy{flex:1;min-height:0;width:100%;overflow:hidden}.fullscreen-plot.svelte-1swghqy .vega-embed{width:100%!important;height:100%!important;min-height:0;display:flex;flex-direction:column}.fullscreen-plot.svelte-1swghqy .vega-embed .vega-view{flex:1;min-height:0}.fullscreen-plot.svelte-1swghqy .vega-embed summary{display:none}.custom-legend.svelte-1swghqy{display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 0 0;flex-wrap:wrap}.legend-item.svelte-1swghqy{display:flex;align-items:center;gap:4px}.legend-dot.svelte-1swghqy{width:10px;height:10px;border-radius:50%;flex-shrink:0}.legend-label.svelte-1swghqy{font-size:11px;color:var(--body-text-color-subdued, #6b7280)}.legend-toggle.svelte-1swghqy{font-size:11px;color:var(--body-text-color-subdued, #6b7280);background:none;border:none;padding:0 4px;cursor:pointer;text-decoration:underline}.legend-toggle.svelte-1swghqy:hover{color:var(--body-text-color, #1f2937)}.accordion.svelte-1jep0a{margin-bottom:12px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);background:var(--background-fill-primary, white);overflow:hidden}.accordion-hidden.svelte-1jep0a{margin-bottom:8px}.accordion-header.svelte-1jep0a{display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;border:none;background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:var(--text-md, 14px);font-weight:600;cursor:pointer;text-align:left}.accordion-header.svelte-1jep0a:hover{background:var(--background-fill-secondary, #f9fafb)}.arrow.svelte-1jep0a{font-size:14px;transition:transform .15s;color:var(--body-text-color, #1f2937);display:inline-block}.arrow.svelte-1jep0a:not(.rotated){transform:rotate(-90deg)}.accordion-body.svelte-1jep0a{padding:0 14px 14px}.trackio-loading.svelte-1kc6b2l{display:flex;align-items:center;justify-content:center;width:100%;min-height:min(70vh,640px);padding:32px 24px;box-sizing:border-box;background:transparent}.logo-stack.svelte-1kc6b2l{position:relative;width:min(100%,200px);max-width:min(92vw,200px);line-height:0;background:transparent;isolation:isolate}.logo-base.svelte-1kc6b2l{display:block;background:transparent}.logo-img.svelte-1kc6b2l{width:100%;height:auto;display:block;background:transparent}.logo-overlay.svelte-1kc6b2l{position:absolute;left:0;top:0;width:100%;animation:svelte-1kc6b2l-trackio-logo-sweep 4s linear infinite;pointer-events:none;background:transparent}.logo-overlay.svelte-1kc6b2l .logo-img:where(.svelte-1kc6b2l){width:100%;height:auto;object-position:left center}.logo-img--gray.svelte-1kc6b2l{filter:grayscale(1)}@keyframes svelte-1kc6b2l-trackio-logo-sweep{0%{clip-path:inset(0 0 0 0)}50%{clip-path:inset(0 0 0 100%)}to{clip-path:inset(0 0 0 0)}}@media(prefers-reduced-motion:reduce){.logo-overlay.svelte-1kc6b2l{display:none}}.sr-only.svelte-1kc6b2l{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.metrics-page.svelte-2bul55{padding:20px 24px;overflow-y:auto;flex:1;min-height:0}.plot-grid.svelte-2bul55{display:flex;flex-wrap:wrap;gap:16px}.subgroup-list.svelte-2bul55{margin-top:16px}.empty-state.svelte-2bul55{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-2bul55 h2:where(.svelte-2bul55){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-2bul55 p:where(.svelte-2bul55){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-2bul55 pre:where(.svelte-2bul55){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-2bul55 code:where(.svelte-2bul55){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-2bul55 pre:where(.svelte-2bul55) code:where(.svelte-2bul55){background:none;padding:0}.traces-page.svelte-1s32ifo{padding:20px 24px;overflow-y:auto;flex:1;background:var(--background-fill-primary, white)}.toolbar.svelte-1s32ifo{display:flex;align-items:center;gap:12px;margin-bottom:16px}.search-wrap.svelte-1s32ifo{flex:1}.search-wrap.svelte-1s32ifo input:where(.svelte-1s32ifo),.filter-wrap.svelte-1s32ifo select:where(.svelte-1s32ifo){width:100%;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:14px;padding:10px 12px;font-family:inherit}.filter-wrap.svelte-1s32ifo{display:flex;align-items:center;gap:8px;color:var(--body-text-color, #1f2937);font-size:14px;white-space:nowrap}.count.svelte-1s32ifo{margin-left:auto;color:var(--body-text-color-subdued, #6b7280);font-size:14px;white-space:nowrap}.traces-table-wrap.svelte-1s32ifo{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);overflow:hidden;transition:opacity .15s ease}.traces-table-wrap.dim.svelte-1s32ifo{opacity:.55}.traces-table.svelte-1s32ifo{width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed}.trace-id-col.svelte-1s32ifo{width:140px}.request-col.svelte-1s32ifo{width:auto}.run-col.svelte-1s32ifo{width:180px}.step-col.svelte-1s32ifo{width:76px}.request-time-col.svelte-1s32ifo{width:150px}.traces-table.svelte-1s32ifo th:where(.svelte-1s32ifo){text-align:left;padding:10px 12px;border-bottom:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.05em;background:var(--background-fill-primary, white)}.traces-table.svelte-1s32ifo td:where(.svelte-1s32ifo){padding:10px 12px;border-bottom:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color, #1f2937);vertical-align:top}.trace-row.svelte-1s32ifo{cursor:pointer}.trace-row.svelte-1s32ifo:hover{background:var(--background-fill-secondary, #f9fafb)}.trace-id-chip.svelte-1s32ifo{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:var(--body-text-color, #1f2937)}.request.svelte-1s32ifo{font-weight:500;margin-bottom:4px}.preview.svelte-1s32ifo{color:var(--body-text-color-subdued, #6b7280);font-size:13px;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.expanded-row.svelte-1s32ifo td:where(.svelte-1s32ifo){padding:0;background:var(--background-fill-secondary, #fafafa)}.trace-detail.svelte-1s32ifo{padding:16px 18px 18px}.detail-meta.svelte-1s32ifo{display:flex;gap:16px;flex-wrap:wrap;color:var(--body-text-color-subdued, #6b7280);font-size:13px;margin-bottom:14px}.conversation.svelte-1s32ifo{display:flex;flex-direction:column;gap:12px}.message.svelte-1s32ifo{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);background:var(--background-fill-primary, white);padding:12px}.message-role.svelte-1s32ifo{margin-bottom:8px;color:var(--body-text-color-subdued, #6b7280);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.message-tag.svelte-1s32ifo{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:999px;padding:2px 8px;font-size:11px;font-weight:500;text-transform:none;letter-spacing:0}.message-content.svelte-1s32ifo,.tool-block.svelte-1s32ifo{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;color:var(--body-text-color, #1f2937);line-height:1.5}.message-details.svelte-1s32ifo summary:where(.svelte-1s32ifo){cursor:pointer;margin-bottom:8px;color:var(--body-text-color-subdued, #6b7280)}.tool-blocks.svelte-1s32ifo{display:flex;flex-direction:column;gap:8px;margin-top:10px}.tool-block.svelte-1s32ifo{background:var(--background-fill-secondary, #f9fafb);border-radius:var(--radius-md, 6px);padding:10px;overflow-x:auto}.message-parts.svelte-1s32ifo{display:flex;flex-direction:column;gap:10px}.trace-image.svelte-1s32ifo{max-width:100%;max-height:360px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px)}.empty-state.svelte-1s32ifo{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-1s32ifo h2:where(.svelte-1s32ifo){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-1s32ifo p:where(.svelte-1s32ifo){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.pagination.svelte-1s32ifo{display:flex;align-items:center;justify-content:center;gap:12px;padding:16px 0 4px}.pagination.svelte-1s32ifo button:where(.svelte-1s32ifo){border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:14px;padding:8px 14px;cursor:pointer;font-family:inherit}.pagination.svelte-1s32ifo button:where(.svelte-1s32ifo):hover:not(:disabled){background:var(--background-fill-secondary, #f9fafb)}.pagination.svelte-1s32ifo button:where(.svelte-1s32ifo):disabled{opacity:.5;cursor:not-allowed}.page-info.svelte-1s32ifo{color:var(--body-text-color-subdued, #6b7280);font-size:14px;min-width:120px;text-align:center}@media(max-width:1100px){.toolbar.svelte-1s32ifo{flex-wrap:wrap}.count.svelte-1s32ifo{margin-left:0}}.system-page.svelte-nv5os4{padding:20px 24px;overflow-y:auto;flex:1;min-height:0}.plot-grid.svelte-nv5os4{display:flex;flex-wrap:wrap;gap:12px}.subgroup-list.svelte-nv5os4{margin-top:16px}.empty-state.svelte-nv5os4{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-nv5os4 h2:where(.svelte-nv5os4){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-nv5os4 p:where(.svelte-nv5os4){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-nv5os4 pre:where(.svelte-nv5os4){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-nv5os4 ul:where(.svelte-nv5os4){list-style:disc;padding-left:20px;margin:4px 0 0}.empty-state.svelte-nv5os4 li:where(.svelte-nv5os4){margin:4px 0;color:var(--body-text-color, #1f2937)}.empty-state.svelte-nv5os4 code:where(.svelte-nv5os4){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-nv5os4 pre:where(.svelte-nv5os4) code:where(.svelte-nv5os4){background:none;padding:0}.wave-wrap.svelte-19wbodf{--wave-base: var(--body-text-color-subdued, #9ca3af);--wave-played: var(--color-accent, #f97316);display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--background-fill-secondary, #f9fafb);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);color:var(--body-text-color, #1f2937)}.play-btn.svelte-19wbodf{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:var(--color-accent, #f97316);color:#fff;border:none;cursor:pointer;flex-shrink:0;padding:0}.play-btn.svelte-19wbodf:disabled{opacity:.4;cursor:not-allowed}.play-btn.svelte-19wbodf:hover:not(:disabled){filter:brightness(1.1)}.wave.svelte-19wbodf{flex:1;height:32px;min-width:0;cursor:pointer}.time.svelte-19wbodf{font-size:11px;color:var(--body-text-color-subdued, #6b7280);font-variant-numeric:tabular-nums;flex-shrink:0}audio.svelte-19wbodf{display:none}.media-page.svelte-outb32{padding:20px 24px;overflow-y:auto;flex:1}.section.svelte-outb32{margin:16px 0}.section-heading.svelte-outb32,.image-section-controls.svelte-outb32{display:flex;align-items:center;gap:8px}.image-section-summary.svelte-outb32{justify-content:space-between;gap:12px}.image-section-controls.svelte-outb32{flex:1;justify-content:flex-end}.media-control.svelte-outb32{display:flex;align-items:center;gap:8px;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.media-control.svelte-outb32 select:where(.svelte-outb32){height:30px;border:1px solid var(--border-color-primary, #d1d5db);border-radius:var(--radius-sm, 4px);background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:var(--text-sm, 12px);padding:0 28px 0 8px}.section-summary.svelte-outb32{display:flex;align-items:center;gap:6px;cursor:pointer;list-style:none;-webkit-user-select:none;user-select:none;padding:4px 0;margin-bottom:8px}.section-summary.svelte-outb32::-webkit-details-marker{display:none}.chevron.svelte-outb32{color:var(--body-text-color-subdued, #6b7280);transform:rotate(-90deg);transition:transform .15s ease;flex-shrink:0}details[open].svelte-outb32>.section-summary:where(.svelte-outb32) .chevron:where(.svelte-outb32){transform:rotate(0)}.section-title.svelte-outb32{font-size:var(--text-lg, 16px);font-weight:600;color:var(--body-text-color, #1f2937)}.media-label.svelte-outb32{font-size:var(--text-sm, 12px);font-weight:500;color:var(--body-text-color, #1f2937);word-break:break-word}.meta.svelte-outb32{display:flex;align-items:center;gap:3px;font-size:var(--text-xs, 11px);color:var(--body-text-color-subdued, #9ca3af);font-variant-numeric:tabular-nums}.meta.svelte-outb32 .run-dot:where(.svelte-outb32){width:8px;height:8px;border-radius:50%;flex-shrink:0;margin:0 2px}.meta-text.svelte-outb32{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.table-header.svelte-outb32{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:6px}.runs-table.svelte-outb32{width:100%;border-collapse:collapse;font-size:var(--text-md, 14px)}.runs-table.svelte-outb32 th:where(.svelte-outb32){text-align:left;padding:8px 12px;border-bottom:2px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);font-weight:600;font-size:var(--text-sm, 12px);text-transform:uppercase;letter-spacing:.05em}.runs-table.svelte-outb32 td:where(.svelte-outb32){padding:8px 12px;border-bottom:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color, #1f2937)}.runs-table.svelte-outb32 tbody:where(.svelte-outb32) tr:where(.svelte-outb32):nth-child(odd){background:var(--table-odd-background-fill, var(--background-fill-primary, white))}.runs-table.svelte-outb32 tbody:where(.svelte-outb32) tr:where(.svelte-outb32):nth-child(2n){background:var(--table-even-background-fill, var(--background-fill-secondary, #f9fafb))}.runs-table.svelte-outb32 tr:where(.svelte-outb32):hover{background:var(--background-fill-secondary, #f3f4f6)}.table-image-frame.svelte-outb32{position:relative;display:inline-flex;width:120px;height:80px;align-items:center;justify-content:center;overflow:hidden;border:0;border-radius:var(--radius-sm, 4px);background:var(--background-fill-secondary, #f3f4f6);color:var(--body-text-color-subdued, #6b7280);cursor:zoom-in;font:inherit;padding:0;text-decoration:none;vertical-align:top}.table-image-placeholder.svelte-outb32{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;font-size:var(--text-xs, 11px)}.table-image-frame.svelte-outb32.image-loaded .table-image-placeholder:where(.svelte-outb32){display:none}.table-image-frame.svelte-outb32.image-failed .table-image-placeholder:where(.svelte-outb32){color:var(--error-text-color, #b91c1c)}.table-image.svelte-outb32{position:relative;z-index:1;max-height:80px;max-width:120px;border-radius:var(--radius-sm, 4px);display:block;object-fit:contain}.table-image-list.svelte-outb32{display:flex;flex-wrap:wrap;gap:4px}.gallery.svelte-outb32{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.gallery-item.svelte-outb32{display:flex;flex-direction:column;gap:6px;padding:8px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);background:var(--background-fill-secondary, #f9fafb);overflow:hidden}.image-frame.svelte-outb32{position:relative;display:block;width:100%;aspect-ratio:4 / 3;overflow:hidden;border:0;border-radius:var(--radius-sm, 4px);background:var(--background-fill-primary, #f3f4f6);color:var(--body-text-color-subdued, #6b7280);cursor:zoom-in;font:inherit;padding:0;text-decoration:none}.image-placeholder.svelte-outb32{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;font-size:var(--text-sm, 12px)}.image-frame.svelte-outb32.image-loaded .image-placeholder:where(.svelte-outb32){display:none}.image-frame.svelte-outb32.image-failed .image-placeholder:where(.svelte-outb32){color:var(--error-text-color, #b91c1c)}.image-frame.svelte-outb32 img:where(.svelte-outb32){position:relative;z-index:1;width:100%;height:100%;display:block;object-fit:contain}.gallery-item.svelte-outb32 video:where(.svelte-outb32){width:100%;display:block;border-radius:var(--radius-sm, 4px)}.audio-gallery-item.svelte-outb32{justify-content:space-between}.caption.svelte-outb32{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #9ca3af)}.image-filter.svelte-outb32{width:min(320px,100%)}.image-filter.svelte-outb32 input:where(.svelte-outb32){width:100%;padding:7px 10px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--input-radius, 8px);background:var(--input-background-fill, white);color:var(--body-text-color, #1f2937);font-family:inherit;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}.image-filter.svelte-outb32 input:where(.svelte-outb32):focus{border-color:var(--input-border-color-focus, #fdba74);box-shadow:0 0 0 2px var(--primary-50, #fff7ed)}.image-filter.svelte-outb32 input:where(.svelte-outb32)::placeholder{color:var(--input-placeholder-color, #9ca3af)}.empty-filter-state.svelte-outb32{padding:16px 0;color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 12px)}.image-modal-backdrop.svelte-outb32{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;display:flex;align-items:center;justify-content:center;padding:32px;background:#000000bf}.image-modal.svelte-outb32{position:relative;display:flex;max-width:min(92vw,1200px);max-height:92vh;flex-direction:column;overflow:hidden;border-radius:var(--radius-lg, 8px);background:var(--background-fill-primary, white);box-shadow:0 20px 60px #00000059}.image-modal-close.svelte-outb32{position:absolute;top:8px;right:8px;z-index:2;width:32px;height:32px;border:0;border-radius:999px;background:#0000008c;color:#fff;cursor:pointer;font-size:24px;line-height:1}.image-modal-nav.svelte-outb32{position:absolute;top:50%;z-index:2;width:48px;height:48px;border:0;border-radius:999px;background:#00000059;color:#fff;cursor:pointer;font-size:40px;line-height:1;transform:translateY(-50%)}.image-modal-nav.svelte-outb32:hover{background:#0000008c}.image-modal-prev.svelte-outb32{left:12px}.image-modal-next.svelte-outb32{right:12px}.modal-image-frame.svelte-outb32{position:relative;display:grid;min-width:min(70vw,720px);min-height:min(65vh,520px);place-items:center;overflow:hidden;background:var(--background-fill-secondary, #f3f4f6);color:var(--body-text-color-subdued, #6b7280)}.modal-image-placeholder.svelte-outb32{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;font-size:var(--text-sm, 12px)}.modal-image-frame.svelte-outb32.image-loaded .modal-image-placeholder:where(.svelte-outb32){display:none}.modal-image-frame.svelte-outb32.image-failed .modal-image-placeholder:where(.svelte-outb32){color:var(--error-text-color, #b91c1c)}.modal-image-frame.svelte-outb32 img:where(.svelte-outb32){position:relative;z-index:1;max-width:100%;max-height:calc(92vh - 96px);object-fit:contain}.image-modal-info.svelte-outb32{display:flex;flex-direction:column;gap:4px;padding:10px 14px 12px;text-align:center}.image-modal-title.svelte-outb32{font-size:var(--text-md, 14px);font-weight:600;color:var(--body-text-color, #1f2937)}.image-modal-caption.svelte-outb32{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.image-modal-meta.svelte-outb32{justify-content:center}.image-modal-count.svelte-outb32{margin-left:8px}.table-section.svelte-outb32{margin-bottom:16px;overflow-x:auto}.pagination-row.svelte-outb32{display:flex;justify-content:center;align-items:center;gap:12px;margin-top:12px;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.load-more-button.svelte-outb32{height:30px;border:1px solid var(--border-color-primary, #d1d5db);border-radius:var(--radius-sm, 4px);background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:var(--text-sm, 12px);font-weight:500;padding:0 10px;cursor:pointer}.load-more-button.svelte-outb32:hover{background:var(--background-fill-secondary, #f9fafb)}.empty-state.svelte-outb32{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-outb32 h2:where(.svelte-outb32){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-outb32 p:where(.svelte-outb32){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-outb32 pre:where(.svelte-outb32){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-outb32 code:where(.svelte-outb32){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-outb32 pre:where(.svelte-outb32) code:where(.svelte-outb32){background:none;padding:0}.reports-page.svelte-iufsej{padding:20px 24px;overflow-y:auto;flex:1}.controls.svelte-iufsej{display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap;align-items:flex-end}.control.svelte-iufsej{min-width:200px}.filter-pills.svelte-iufsej{display:flex;gap:4px}.pill.svelte-iufsej{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-xxl, 22px);padding:4px 12px;font-size:var(--text-sm, 12px);background:var(--background-fill-secondary, #f9fafb);color:var(--body-text-color-subdued, #6b7280);cursor:pointer;transition:background-color .15s,color .15s}.pill.svelte-iufsej:hover{background:var(--neutral-100, #f3f4f6)}.pill.active.svelte-iufsej{background:var(--color-accent, #f97316);color:#fff;border-color:var(--color-accent, #f97316)}.empty-state.svelte-iufsej{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-iufsej h2:where(.svelte-iufsej){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-iufsej p:where(.svelte-iufsej){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-iufsej pre:where(.svelte-iufsej){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-iufsej code:where(.svelte-iufsej){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-iufsej pre:where(.svelte-iufsej) code:where(.svelte-iufsej){background:none;padding:0}.alerts-table.svelte-iufsej{width:100%;border-collapse:collapse;font-size:var(--text-md, 14px)}.alerts-table.svelte-iufsej th:where(.svelte-iufsej){text-align:left;padding:8px 12px;border-bottom:2px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);font-weight:600;font-size:var(--text-sm, 12px);text-transform:uppercase;letter-spacing:.05em}.alerts-table.svelte-iufsej td:where(.svelte-iufsej){padding:8px 12px;border-bottom:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color, #1f2937)}.alerts-table.svelte-iufsej tbody:where(.svelte-iufsej) tr:where(.svelte-iufsej):nth-child(odd){background:var(--table-odd-background-fill, var(--background-fill-primary, white))}.alerts-table.svelte-iufsej tbody:where(.svelte-iufsej) tr:where(.svelte-iufsej):nth-child(2n){background:var(--table-even-background-fill, var(--background-fill-secondary, #f9fafb))}.alerts-table.svelte-iufsej tr:where(.svelte-iufsej):hover{background:var(--background-fill-secondary, #f3f4f6)}.section-title.svelte-iufsej{font-size:16px;font-weight:700;margin:0 0 12px;color:var(--body-text-color, #1f2937)}.reports-section.svelte-iufsej,.alerts-section.svelte-iufsej{margin-bottom:32px}.report-card.svelte-iufsej{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);padding:16px 20px;margin-bottom:12px;background:var(--background-fill-primary, white)}.report-meta.svelte-iufsej{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);margin-bottom:8px}.report-content.svelte-iufsej{font-size:var(--text-md, 14px);color:var(--body-text-color, #1f2937);line-height:1.6}.report-content.svelte-iufsej h2{font-size:18px;font-weight:700;margin:0 0 8px}.report-content.svelte-iufsej h3{font-size:16px;font-weight:600;margin:12px 0 6px}.report-content.svelte-iufsej h4{font-size:14px;font-weight:600;margin:10px 0 4px}.report-content.svelte-iufsej code{background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.report-content.svelte-iufsej ul{margin:4px 0;padding-left:20px}.report-content.svelte-iufsej li{margin:2px 0}.report-content.svelte-iufsej p{margin:6px 0}.filter-empty.svelte-iufsej{color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-md, 14px)}.runs-page.svelte-1yb6d54{padding:20px 24px;overflow-y:auto;flex:1}.empty-state.svelte-1yb6d54{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-1yb6d54 h2:where(.svelte-1yb6d54){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-1yb6d54 p:where(.svelte-1yb6d54){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-1yb6d54 pre:where(.svelte-1yb6d54){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-1yb6d54 code:where(.svelte-1yb6d54){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-1yb6d54 pre:where(.svelte-1yb6d54) code:where(.svelte-1yb6d54){background:none;padding:0}.filter-count-row.svelte-1yb6d54{margin-bottom:12px}.filter-count.svelte-1yb6d54{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.runs-table.svelte-1yb6d54{width:100%;border-collapse:collapse;font-size:var(--text-md, 14px)}.runs-table.svelte-1yb6d54 th:where(.svelte-1yb6d54){text-align:left;padding:8px 12px;border-bottom:2px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);font-weight:600;font-size:var(--text-sm, 12px);text-transform:uppercase;letter-spacing:.05em}.runs-table.svelte-1yb6d54 td:where(.svelte-1yb6d54){padding:8px 12px;border-bottom:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color, #1f2937)}.runs-table.svelte-1yb6d54 tbody:where(.svelte-1yb6d54) tr:where(.svelte-1yb6d54):nth-child(odd){background:var(--table-odd-background-fill, var(--background-fill-primary, white))}.runs-table.svelte-1yb6d54 tbody:where(.svelte-1yb6d54) tr:where(.svelte-1yb6d54):nth-child(2n){background:var(--table-even-background-fill, var(--background-fill-secondary, #f9fafb))}.runs-table.svelte-1yb6d54 tr:where(.svelte-1yb6d54):hover{background:var(--background-fill-secondary, #f3f4f6)}.run-name-cell.svelte-1yb6d54{font-weight:500}.run-name-with-dot.svelte-1yb6d54{display:inline-flex;align-items:center;gap:8px;max-width:100%}.run-dot.svelte-1yb6d54{width:10px;height:10px;border-radius:50%;flex-shrink:0}.link-btn.svelte-1yb6d54{background:none;border:none;color:var(--color-accent, #f97316);cursor:pointer;font:inherit;font-weight:500;padding:0;text-align:left}.link-btn.svelte-1yb6d54:hover{text-decoration:underline}.rename-input.svelte-1yb6d54{font:inherit;padding:2px 6px;border:1px solid var(--color-accent, #f97316);border-radius:var(--radius-sm, 4px);outline:none;width:100%}.actions-wrap.svelte-1yb6d54{display:flex;gap:4px;align-items:center}.action-btn.svelte-1yb6d54{background:none;border:1px solid transparent;color:var(--body-text-color-subdued, #6b7280);cursor:pointer;padding:4px;border-radius:var(--radius-sm, 4px);display:flex;align-items:center}.action-btn.svelte-1yb6d54:hover{background:var(--background-fill-secondary, #f9fafb);border-color:var(--border-color-primary, #e5e7eb);color:var(--body-text-color, #1f2937)}.delete-btn.svelte-1yb6d54:hover{color:#dc2626;border-color:#fecaca;background:#fef2f2}.action-btn.svelte-1yb6d54:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.artifact-counts.svelte-1yb6d54{display:inline-flex;gap:6px}.art-count.svelte-1yb6d54{display:inline-flex;align-items:center;gap:3px;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);background:transparent;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:9px;padding:1px 8px;white-space:nowrap}.art-none.svelte-1yb6d54{color:var(--body-text-color-subdued, #9ca3af)}.indent.svelte-1l68cu5{display:flex;align-self:stretch;flex-shrink:0}.indent-guide.svelte-1l68cu5{width:16px;align-self:stretch;position:relative}.indent-guide.svelte-1l68cu5:before{content:"";position:absolute;left:6px;top:0;bottom:0;border-left:1px solid var(--border-color-primary, #e5e7eb)}.version-detail.svelte-pa6cr{border-top:1px solid var(--border-color-primary, #e5e7eb);padding:10px 14px 12px;background:var(--background-fill-secondary, #f9fafb)}.panel.svelte-pa6cr{padding:4px 4px 24px}.panel-header.svelte-pa6cr{border-bottom:1px solid var(--border-color-primary, #e5e7eb);padding-bottom:14px;margin-bottom:16px}.title-row.svelte-pa6cr{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.art-name.svelte-pa6cr{font-size:18px;font-weight:700;color:var(--body-text-color, #1f2937);margin:0;word-break:break-word}.ver-badge.svelte-pa6cr{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--text-sm, 12px);font-weight:600;color:var(--body-text-color-subdued, #6b7280);background:var(--background-fill-secondary, #f3f4f6);border-radius:var(--radius-sm, 4px);padding:2px 7px}.fact-row.svelte-pa6cr{display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px}.fact.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.type-chip.svelte-pa6cr{text-transform:uppercase;letter-spacing:.04em;font-weight:600}.dot.svelte-pa6cr{color:var(--body-text-color-subdued, #9ca3af)}.use-snippet.svelte-pa6cr{display:flex;align-items:center;gap:8px;margin-top:12px;background:var(--background-fill-secondary, #f9fafb);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);padding:6px 6px 6px 10px;width:fit-content;max-width:min(480px,100%);overflow:hidden}.use-snippet.svelte-pa6cr code:where(.svelte-pa6cr){font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--text-sm, 12px);color:var(--body-text-color, #1f2937);white-space:nowrap;overflow-x:auto;flex:1 1 auto}.tok-mod.svelte-pa6cr{color:var(--body-text-color, #1f2937)}.tok-fn.svelte-pa6cr{color:#8957e5}.tok-str.svelte-pa6cr{color:#1a7f37}.tok-punc.svelte-pa6cr{color:var(--body-text-color-subdued, #6b7280)}.copy-btn.svelte-pa6cr{flex-shrink:0;border:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, #fff);color:var(--body-text-color, #374151);border-radius:var(--radius-sm, 4px);padding:3px 10px;font-size:var(--text-xs, 11px);cursor:pointer}.copy-btn.svelte-pa6cr:hover{border-color:var(--color-accent, #f97316);color:var(--color-accent, #f97316)}.copy-btn.small.svelte-pa6cr{padding:1px 8px}.detail-grid.svelte-pa6cr{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin-bottom:10px;align-items:start}.detail-key.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.detail-val.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color, #1f2937);word-break:break-word}.digest-val.svelte-pa6cr{display:flex;align-items:center;gap:8px}.digest-text.svelte-pa6cr{word-break:break-all}.mono.svelte-pa6cr{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.run-link.svelte-pa6cr{background:none;border:none;padding:0;font-size:var(--text-sm, 12px);color:var(--color-accent, #f97316);cursor:pointer;text-align:left}.run-link.svelte-pa6cr:hover{text-decoration:underline}.meta-grid.svelte-pa6cr{display:grid;grid-template-columns:max-content 1fr;column-gap:16px;row-gap:0;align-items:stretch}.meta-key-cell.svelte-pa6cr{display:flex;align-items:stretch;min-width:0}.meta-grid.svelte-pa6cr .detail-val:where(.svelte-pa6cr){align-self:center;padding:3px 0}.meta-chevron-spacer.svelte-pa6cr{display:inline-block;width:16px;flex-shrink:0;align-self:center}.meta-leaf-key.svelte-pa6cr{align-self:center;padding:3px 0}.meta-key-row.svelte-pa6cr{display:inline-flex;align-items:center;align-self:center;background:none;border:none;padding:3px 0;margin:0;cursor:pointer;font:inherit;color:var(--body-text-color, #1f2937);text-align:left}.meta-chevron.svelte-pa6cr{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;margin-right:4px;flex-shrink:0;color:var(--body-text-color-subdued, #9ca3af);transition:transform .15s}.meta-chevron.open.svelte-pa6cr{transform:rotate(90deg)}.meta-key-label.svelte-pa6cr{font-weight:400;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.meta-key-row.svelte-pa6cr:hover .meta-key-label:where(.svelte-pa6cr){color:var(--color-accent, #f97316)}.meta-node-count.svelte-pa6cr{margin-left:8px;font-size:var(--text-xs, 10px);font-weight:500;color:var(--body-text-color-subdued, #9ca3af)}.meta-preview.svelte-pa6cr{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--text-xs, 11px);color:var(--body-text-color-subdued, #9ca3af);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-table.svelte-pa6cr{display:flex;flex-direction:column}.file-entry.svelte-pa6cr{display:flex;align-items:center;gap:12px;padding:6px 8px;margin:0 -8px;border-radius:var(--radius-sm, 4px);border-top:1px solid var(--border-color-primary, #e5e7eb);color:inherit;text-decoration:none;cursor:pointer}.file-entry.svelte-pa6cr:first-child{border-top:none}.file-entry.svelte-pa6cr:hover{background:var(--background-fill-secondary, #f9fafb);border-top-color:transparent}.spacer.svelte-pa6cr{flex:1 1 auto}.file-path.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color, #1f2937);word-break:break-all}.file-digest.svelte-pa6cr{font-size:var(--text-xs, 11px);color:var(--body-text-color-subdued, #9ca3af);white-space:nowrap;flex-shrink:0;text-align:right;min-width:88px}.file-size.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);white-space:nowrap;flex-shrink:0;text-align:right;min-width:56px}.download-icon.svelte-pa6cr{display:flex;align-items:center;justify-content:center;width:20px;height:20px;flex-shrink:0;color:var(--body-text-color-subdued, #9ca3af);transition:color .15s}.file-entry.svelte-pa6cr:hover .download-icon:where(.svelte-pa6cr){color:var(--color-accent, #f97316)}.status.svelte-pa6cr{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);padding:4px 0}.alias-pill.svelte-pa6cr{font-size:var(--text-xs, 11px);padding:1px 7px;border-radius:9px;border:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);background:var(--background-fill-secondary, #f3f4f6);white-space:nowrap}.alias-pill.latest.svelte-pa6cr{color:var(--color-accent, #f97316);border-color:var(--color-accent, #f97316);background:transparent}.run-detail-page.svelte-1bpgsx2{padding:20px 24px;overflow-y:auto;flex:1}.back-link.svelte-1bpgsx2{display:inline-flex;align-items:center;gap:6px;margin:0 0 12px;padding:0;border:0;background:none;color:var(--body-text-color-subdued, #6b7280);font:inherit;font-size:var(--text-sm, 12px);font-weight:500;cursor:pointer}.back-link.svelte-1bpgsx2:hover{color:var(--body-text-color, #1f2937);text-decoration:underline}.detail-card.svelte-1bpgsx2{background:var(--background-fill-primary, white);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);padding:24px;max-width:800px}.detail-card.svelte-1bpgsx2 h2:where(.svelte-1bpgsx2){color:var(--body-text-color, #1f2937);margin:0 0 16px;font-size:var(--text-xl, 22px)}.detail-card.svelte-1bpgsx2 h3:where(.svelte-1bpgsx2){color:var(--body-text-color, #1f2937);margin:20px 0 8px;font-size:var(--text-lg, 16px)}.detail-grid.svelte-1bpgsx2{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.detail-item.svelte-1bpgsx2{display:flex;flex-direction:column;gap:2px}.detail-label.svelte-1bpgsx2{font-size:var(--text-xs, 10px);font-weight:600;color:var(--body-text-color-subdued, #9ca3af);text-transform:uppercase}.detail-value.svelte-1bpgsx2{font-size:var(--text-md, 14px);color:var(--body-text-color, #1f2937)}.config-block.svelte-1bpgsx2{background:var(--background-fill-secondary, #f9fafb);padding:12px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:var(--text-sm, 12px);color:var(--body-text-color, #1f2937);overflow-x:auto}.artifact-links.svelte-1bpgsx2{display:flex;flex-direction:column;gap:4px}.artifact-entry.svelte-1bpgsx2{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);overflow:hidden}.artifact-row.svelte-1bpgsx2{display:flex;align-items:center}.row-toggle.svelte-1bpgsx2{display:flex;align-items:center;gap:12px;flex:1;min-width:0;text-align:left;background:none;border:none;padding:8px 12px;cursor:pointer}.row-toggle.svelte-1bpgsx2:hover{background:var(--background-fill-secondary, #f9fafb)}.open-btn.svelte-1bpgsx2{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:30px;height:30px;margin-right:6px;border:none;background:none;color:var(--body-text-color-subdued, #9ca3af);border-radius:var(--radius-sm, 4px);cursor:pointer;transition:color .15s,background-color .15s}.open-btn.svelte-1bpgsx2:hover{color:var(--color-accent, #f97316);background:var(--background-fill-secondary, #f9fafb)}.chevron.svelte-1bpgsx2{flex-shrink:0;display:inline-block;color:var(--body-text-color, #1f2937);font-size:14px;transition:transform .15s;transform:rotate(-90deg)}.chevron.open.svelte-1bpgsx2{transform:none}.art-name.svelte-1bpgsx2{font-weight:600;color:var(--body-text-color, #1f2937);font-size:var(--text-md, 14px)}.art-ver.svelte-1bpgsx2{font-weight:400;color:var(--body-text-color-subdued, #6b7280);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.art-type.svelte-1bpgsx2{font-size:var(--text-xs, 11px);text-transform:uppercase;letter-spacing:.04em;color:var(--body-text-color-subdued, #6b7280);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:9px;padding:1px 8px}.art-size.svelte-1bpgsx2{margin-left:auto;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-1bpgsx2{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-1bpgsx2 h2:where(.svelte-1bpgsx2){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-1bpgsx2 p:where(.svelte-1bpgsx2){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-1bpgsx2 pre:where(.svelte-1bpgsx2){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-1bpgsx2 code:where(.svelte-1bpgsx2){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-1bpgsx2 pre:where(.svelte-1bpgsx2) code:where(.svelte-1bpgsx2){background:none;padding:0}.files-page.svelte-1xvfk9n{padding:20px 24px;overflow-y:auto;flex:1}.page-title.svelte-1xvfk9n{color:var(--body-text-color, #1f2937);font-size:16px;font-weight:700;margin:0 0 4px}.page-subtitle.svelte-1xvfk9n{color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 12px);margin:0 0 16px}.file-list.svelte-1xvfk9n{display:flex;flex-direction:column;gap:4px}.file-item.svelte-1xvfk9n{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);background:var(--background-fill-primary, white);overflow:hidden}.file-item.expanded.svelte-1xvfk9n{border-color:var(--color-accent, #f97316)}.file-row.svelte-1xvfk9n{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;gap:12px}.file-name.svelte-1xvfk9n{display:flex;align-items:center;gap:8px;background:none;border:none;padding:0;font-size:var(--text-md, 14px);color:var(--body-text-color, #1f2937);cursor:pointer;text-align:left}.file-name.svelte-1xvfk9n:hover{color:var(--color-accent, #f97316)}.file-icon.svelte-1xvfk9n{font-size:14px;flex-shrink:0}.file-actions.svelte-1xvfk9n{display:flex;align-items:center;gap:12px;flex-shrink:0}.file-size.svelte-1xvfk9n{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);white-space:nowrap}.download-btn.svelte-1xvfk9n{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:var(--radius-md, 6px);color:var(--body-text-color-subdued, #6b7280);transition:background-color .15s,color .15s}.download-btn.svelte-1xvfk9n:hover{background:var(--background-fill-secondary, #f3f4f6);color:var(--body-text-color, #1f2937)}.file-preview.svelte-1xvfk9n{border-top:1px solid var(--border-color-primary, #e5e7eb);padding:12px 14px;background:var(--background-fill-secondary, #f9fafb)}.preview-code.svelte-1xvfk9n{margin:0;font-size:12px;line-height:1.5;max-height:400px;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--body-text-color, #1f2937)}.preview-loading.svelte-1xvfk9n,.preview-unavailable.svelte-1xvfk9n{color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 12px);padding:8px 0}.preview-unavailable.svelte-1xvfk9n a:where(.svelte-1xvfk9n){color:var(--color-accent, #f97316);text-decoration:none}.preview-unavailable.svelte-1xvfk9n a:where(.svelte-1xvfk9n):hover{text-decoration:underline}.empty-state.svelte-1xvfk9n{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-1xvfk9n h2:where(.svelte-1xvfk9n){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-1xvfk9n p:where(.svelte-1xvfk9n){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-1xvfk9n pre:where(.svelte-1xvfk9n){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-1xvfk9n code:where(.svelte-1xvfk9n){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-1xvfk9n pre:where(.svelte-1xvfk9n) code:where(.svelte-1xvfk9n){background:none;padding:0}.artifacts-sidebar.svelte-coehlr{width:300px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border-color-primary, #e5e7eb);background:var(--background-fill-primary, #fff);overflow:hidden}.tree-header.svelte-coehlr{padding:16px 16px 10px;flex-shrink:0}.tree-search.svelte-coehlr{margin-top:14px}.search-label.svelte-coehlr{display:block;font-size:13px;font-weight:500;color:var(--body-text-color-subdued, #6b7280);margin-bottom:6px}.search-box.svelte-coehlr{position:relative;display:flex;align-items:center}.search-icon.svelte-coehlr{position:absolute;left:10px;color:var(--body-text-color-subdued, #9ca3af);pointer-events:none}.search-box.svelte-coehlr input:where(.svelte-coehlr){width:100%;padding:6px 26px 6px 30px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 6px);background:var(--background-fill-secondary, #f9fafb);color:var(--body-text-color, #1f2937);font-size:var(--text-sm, 13px);outline:none}.search-box.svelte-coehlr input:where(.svelte-coehlr):focus{border-color:var(--color-accent, #f97316)}.clear-search.svelte-coehlr{position:absolute;right:8px;border:none;background:none;color:var(--body-text-color-subdued, #9ca3af);font-size:18px;line-height:1;cursor:pointer;padding:0 2px}.clear-search.svelte-coehlr:hover{color:var(--body-text-color, #1f2937)}.tree.svelte-coehlr{flex:1;overflow-y:auto;padding:6px 0 12px}.tree-empty.svelte-coehlr{padding:16px 14px;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.tree-row.svelte-coehlr{display:flex;align-items:center;width:100%;min-height:30px;background:none;border:none;cursor:pointer;text-align:left;color:var(--body-text-color, #1f2937);font-size:var(--text-sm, 13px);padding:0 12px;border-radius:0}.tree-row.svelte-coehlr:hover{background:var(--background-fill-secondary, #f3f4f6)}.tree-chevron-spacer.svelte-coehlr{display:inline-block;width:12px;margin-right:6px;flex-shrink:0}.chevron.svelte-coehlr{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;margin-right:6px;color:var(--body-text-color-subdued, #6b7280);width:12px;height:12px;transition:transform .15s}.chevron.open.svelte-coehlr{transform:rotate(90deg)}.type-label.svelte-coehlr{text-transform:uppercase;letter-spacing:.04em;font-size:var(--text-xs, 11px);font-weight:700;color:var(--body-text-color-subdued, #6b7280)}.artifact-label.svelte-coehlr{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.version-label.svelte-coehlr{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:600;flex-shrink:0}.version-row.svelte-coehlr .alias-pill:where(.svelte-coehlr){margin-left:6px}.spacer.svelte-coehlr{flex:1 1 auto}.node-count.svelte-coehlr{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:var(--background-fill-secondary, #f3f4f6);color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-xs, 10px);flex-shrink:0}.version-row.selected.svelte-coehlr{background:var(--background-fill-secondary, #f3f4f6);box-shadow:inset 3px 0 0 var(--color-accent, #f97316)}.version-row.selected.svelte-coehlr .version-label:where(.svelte-coehlr){color:var(--color-accent, #f97316)}.alias-pill.svelte-coehlr{font-size:var(--text-xs, 10px);padding:0 6px;border-radius:9px;border:1px solid var(--border-color-primary, #e5e7eb);color:var(--body-text-color-subdued, #6b7280);background:var(--background-fill-secondary, #f3f4f6);white-space:nowrap;line-height:16px}.alias-pill.latest.svelte-coehlr{color:var(--color-accent, #f97316);border-color:var(--color-accent, #f97316);background:transparent}.detail-pane.svelte-q8gppg{flex:1;min-width:0;overflow-y:auto;padding:20px 28px}.detail-empty.svelte-q8gppg{color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 13px);padding:12px 0}.empty-state.svelte-q8gppg{max-width:640px;padding:40px 24px;color:var(--body-text-color, #1f2937)}.empty-state.svelte-q8gppg h2:where(.svelte-q8gppg){margin:0 0 8px;font-size:20px;font-weight:700}.empty-state.svelte-q8gppg p:where(.svelte-q8gppg){margin:12px 0 8px;color:var(--body-text-color-subdued, #6b7280)}.empty-state.svelte-q8gppg pre:where(.svelte-q8gppg){background:var(--background-fill-secondary, #f9fafb);padding:16px;border-radius:var(--radius-lg, 8px);border:1px solid var(--border-color-primary, #e5e7eb);font-size:13px;overflow-x:auto}.empty-state.svelte-q8gppg code:where(.svelte-q8gppg){background:var(--background-fill-secondary, #f0f0f0);padding:1px 5px;border-radius:var(--radius-sm, 4px);font-size:13px}.empty-state.svelte-q8gppg pre:where(.svelte-q8gppg) code:where(.svelte-q8gppg){background:none;padding:0}.settings-page.svelte-1ozf5k3{padding:24px 32px;overflow-y:auto;flex:1}.page-title.svelte-1ozf5k3{color:var(--body-text-color, #1f2937);font-size:18px;font-weight:700;margin:0 0 24px}.two-col.svelte-1ozf5k3{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;align-items:start}@media(max-width:900px){.two-col.svelte-1ozf5k3{grid-template-columns:1fr}}.settings-section.svelte-1ozf5k3{margin-bottom:32px}.section-title.svelte-1ozf5k3{color:var(--body-text-color, #1f2937);font-size:15px;font-weight:600;margin:0 0 4px}.section-desc.svelte-1ozf5k3{color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 12px);margin:0 0 12px;line-height:1.5}.section-desc.svelte-1ozf5k3 code:where(.svelte-1ozf5k3){background:var(--background-fill-secondary, #f3f4f6);padding:1px 5px;border-radius:var(--radius-sm, 3px);font-size:11px}.section-desc.svelte-1ozf5k3 strong:where(.svelte-1ozf5k3){color:var(--color-accent, #f97316)}.theme-switcher.svelte-1ozf5k3{display:inline-flex;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);overflow:hidden}.theme-option.svelte-1ozf5k3{display:inline-flex;align-items:center;gap:6px;padding:8px 20px;border:none;background:var(--background-fill-primary, white);color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-md, 14px);cursor:pointer;transition:all .15s;border-right:1px solid var(--border-color-primary, #e5e7eb)}.theme-option.svelte-1ozf5k3:last-child{border-right:none}.theme-option.svelte-1ozf5k3:hover{color:var(--body-text-color, #1f2937);background:var(--background-fill-secondary, #f9fafb)}.theme-option.selected.svelte-1ozf5k3{background:var(--color-accent, #f97316);color:#fff;font-weight:500}.project-selector.svelte-1ozf5k3{display:flex;align-items:center;gap:10px;margin-bottom:12px}.selector-label.svelte-1ozf5k3{font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280);flex-shrink:0}.selector-select.svelte-1ozf5k3{padding:6px 10px;border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 4px);background:var(--background-fill-primary, white);color:var(--body-text-color, #1f2937);font-size:var(--text-sm, 12px);min-width:160px;cursor:pointer}.selector-select.svelte-1ozf5k3:focus{outline:none;border-color:var(--color-accent, #f97316)}.commands-table.svelte-1ozf5k3{border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-lg, 8px);overflow:hidden}.command-row.svelte-1ozf5k3{display:flex;align-items:center;gap:16px;padding:10px 14px;border-bottom:1px solid var(--border-color-primary, #e5e7eb)}.command-row.svelte-1ozf5k3:last-child{border-bottom:none}.command-label.svelte-1ozf5k3{width:180px;flex-shrink:0;font-size:var(--text-sm, 12px);color:var(--body-text-color-subdued, #6b7280)}.command-value.svelte-1ozf5k3{flex:1;display:flex;align-items:center;gap:8px;min-width:0}.command-value.svelte-1ozf5k3 code:where(.svelte-1ozf5k3){flex:1;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;color:var(--body-text-color, #1f2937);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy-btn.svelte-1ozf5k3{display:flex;align-items:center;justify-content:center;width:26px;height:26px;flex-shrink:0;border:none;background:none;border-radius:var(--radius-md, 4px);color:var(--body-text-color-subdued, #6b7280);cursor:pointer;transition:background-color .15s,color .15s}.copy-btn.svelte-1ozf5k3:hover{background:var(--background-fill-secondary, #f3f4f6);color:var(--body-text-color, #1f2937)}.copy-btn.copied.svelte-1ozf5k3{color:var(--color-accent, #f97316)}.agent-tabs.svelte-1ozf5k3{display:flex;border-bottom:1px solid var(--border-color-primary, #e5e7eb);gap:0;margin-bottom:0}.agent-tab.svelte-1ozf5k3{padding:8px 16px;border:none;background:none;color:var(--body-text-color-subdued, #6b7280);font-size:var(--text-sm, 12px);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s;white-space:nowrap}.agent-tab.svelte-1ozf5k3:hover{color:var(--body-text-color, #1f2937)}.agent-tab.active.svelte-1ozf5k3{color:var(--color-accent, #f97316);border-bottom-color:var(--color-accent, #f97316);font-weight:500}.agent-panel.svelte-1ozf5k3{border:1px solid var(--border-color-primary, #e5e7eb);border-top:none;border-radius:0 0 var(--radius-lg, 8px) var(--radius-lg, 8px);padding:16px}.install-block.svelte-1ozf5k3{margin-bottom:16px}.install-label.svelte-1ozf5k3{display:block;font-size:11px;font-weight:500;color:var(--body-text-color-subdued, #6b7280);text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px}.install-cmd.svelte-1ozf5k3{display:flex;align-items:center;gap:8px;background:var(--background-fill-secondary, #f3f4f6);border-radius:var(--radius-md, 4px);padding:8px 10px}.install-cmd.svelte-1ozf5k3 code:where(.svelte-1ozf5k3){flex:1;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:12px;color:var(--body-text-color, #1f2937);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.example-block.svelte-1ozf5k3{background:var(--background-fill-secondary, #f9fafb);border:1px solid var(--border-color-primary, #e5e7eb);border-radius:var(--radius-md, 4px);padding:12px}.example-header.svelte-1ozf5k3{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.example-label.svelte-1ozf5k3{font-size:11px;font-weight:500;color:var(--body-text-color-subdued, #6b7280);text-transform:uppercase;letter-spacing:.04em}.example-text.svelte-1ozf5k3{margin:0;font-size:var(--text-sm, 12px);color:var(--body-text-color, #1f2937);line-height:1.6;font-style:italic}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:var(--background-fill-primary, #fff);color:var(--body-text-color, #1f2937);font-size:var(--text-md, 14px);-webkit-font-smoothing:antialiased}.app.svelte-1n46o8q{display:flex;height:100vh;overflow:hidden}.main.svelte-1n46o8q{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}.page-content.svelte-1n46o8q{flex:1;overflow:hidden;display:flex;background:var(--bg-primary)}
|
trackio/frontend/dist/assets/index-nILgi7uv.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
trackio/frontend/dist/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Trackio Dashboard</title>
|
| 7 |
+
<link rel="icon" type="image/png" href="/static/trackio/trackio_logo_light.png" />
|
| 8 |
+
<script type="module" crossorigin src="/assets/index-nILgi7uv.js"></script>
|
| 9 |
+
<link rel="stylesheet" crossorigin href="/assets/index-m3jgYXoS.css">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="app"></div>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
trackio/frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from "@eslint/js";
|
| 2 |
+
import svelte from "eslint-plugin-svelte";
|
| 3 |
+
import svelteParser from "svelte-eslint-parser";
|
| 4 |
+
import globals from "globals";
|
| 5 |
+
|
| 6 |
+
export default [
|
| 7 |
+
{ ignores: ["dist/**", "node_modules/**"] },
|
| 8 |
+
{
|
| 9 |
+
files: ["**/*.js"],
|
| 10 |
+
languageOptions: {
|
| 11 |
+
globals: {
|
| 12 |
+
...globals.browser,
|
| 13 |
+
...globals.es2021,
|
| 14 |
+
$state: "readonly",
|
| 15 |
+
$derived: "readonly",
|
| 16 |
+
$effect: "readonly",
|
| 17 |
+
$props: "readonly",
|
| 18 |
+
$bindable: "readonly",
|
| 19 |
+
$inspect: "readonly",
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
rules: {
|
| 23 |
+
...js.configs.recommended.rules,
|
| 24 |
+
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
| 25 |
+
"no-empty": "off",
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
files: ["**/*.svelte"],
|
| 30 |
+
languageOptions: {
|
| 31 |
+
parser: svelteParser,
|
| 32 |
+
globals: { ...globals.browser, ...globals.es2021 },
|
| 33 |
+
},
|
| 34 |
+
plugins: { svelte },
|
| 35 |
+
rules: {
|
| 36 |
+
...js.configs.recommended.rules,
|
| 37 |
+
...svelte.configs.recommended.rules,
|
| 38 |
+
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^\\$" }],
|
| 39 |
+
"no-empty": "off",
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
];
|
trackio/frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Trackio Dashboard</title>
|
| 7 |
+
<link rel="icon" type="image/png" href="/static/trackio/trackio_logo_light.png" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="app"></div>
|
| 11 |
+
<script type="module" src="/src/main.js"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
trackio/frontend_config.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import shutil
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from huggingface_hub.constants import HF_HOME
|
| 8 |
+
|
| 9 |
+
TRACKIO_USER_HOME = Path(HF_HOME) / "trackio"
|
| 10 |
+
TRACKIO_CONFIG_PATH = TRACKIO_USER_HOME / "config.json"
|
| 11 |
+
BUNDLED_FRONTEND_DIR = Path(__file__).parent / "frontend" / "dist"
|
| 12 |
+
STARTER_FRONTEND_DIR = Path(__file__).parent / "frontend_templates" / "starter"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class ResolvedFrontend:
|
| 17 |
+
path: Path
|
| 18 |
+
source: str
|
| 19 |
+
is_custom: bool
|
| 20 |
+
used_fallback: bool = False
|
| 21 |
+
requested_path: Path | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _normalize_frontend_path(path: str | Path) -> Path:
|
| 25 |
+
return Path(path).expanduser().resolve()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def is_valid_frontend_dir(path: str | Path | None) -> bool:
|
| 29 |
+
if path is None:
|
| 30 |
+
return False
|
| 31 |
+
frontend_dir = _normalize_frontend_path(path)
|
| 32 |
+
return frontend_dir.is_dir() and (frontend_dir / "index.html").is_file()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _is_empty_directory(path: Path) -> bool:
|
| 36 |
+
return path.is_dir() and not any(path.iterdir())
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _copy_starter_template(destination: Path) -> None:
|
| 40 |
+
destination.mkdir(parents=True, exist_ok=True)
|
| 41 |
+
for child in STARTER_FRONTEND_DIR.iterdir():
|
| 42 |
+
target = destination / child.name
|
| 43 |
+
if child.is_dir():
|
| 44 |
+
shutil.copytree(child, target, dirs_exist_ok=True)
|
| 45 |
+
else:
|
| 46 |
+
shutil.copy2(child, target)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _materialize_argument_frontend_dir(candidate: Path) -> bool:
|
| 50 |
+
existed_before = candidate.exists()
|
| 51 |
+
if existed_before and not _is_empty_directory(candidate):
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
_copy_starter_template(candidate)
|
| 55 |
+
state = "did not exist" if not existed_before else "was empty"
|
| 56 |
+
print(
|
| 57 |
+
f"* Trackio frontend directory from argument {state}: {candidate}. "
|
| 58 |
+
"Copied the starter template into it and serving that directory."
|
| 59 |
+
)
|
| 60 |
+
return True
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_trackio_config() -> dict:
|
| 64 |
+
if not TRACKIO_CONFIG_PATH.is_file():
|
| 65 |
+
return {}
|
| 66 |
+
try:
|
| 67 |
+
data = json.loads(TRACKIO_CONFIG_PATH.read_text())
|
| 68 |
+
except (json.JSONDecodeError, OSError):
|
| 69 |
+
return {}
|
| 70 |
+
return data if isinstance(data, dict) else {}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def save_trackio_config(config: dict) -> None:
|
| 74 |
+
TRACKIO_USER_HOME.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
TRACKIO_CONFIG_PATH.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def get_persisted_frontend_dir() -> Path | None:
|
| 79 |
+
frontend_dir = load_trackio_config().get("frontend_dir")
|
| 80 |
+
if not frontend_dir:
|
| 81 |
+
return None
|
| 82 |
+
return _normalize_frontend_path(frontend_dir)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def set_persisted_frontend_dir(path: str | Path) -> Path:
|
| 86 |
+
frontend_dir = _normalize_frontend_path(path)
|
| 87 |
+
if not is_valid_frontend_dir(frontend_dir):
|
| 88 |
+
raise ValueError(
|
| 89 |
+
f"Invalid frontend directory: {frontend_dir}. Expected a directory containing index.html."
|
| 90 |
+
)
|
| 91 |
+
config = load_trackio_config()
|
| 92 |
+
config["frontend_dir"] = str(frontend_dir)
|
| 93 |
+
save_trackio_config(config)
|
| 94 |
+
return frontend_dir
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def unset_persisted_frontend_dir() -> bool:
|
| 98 |
+
config = load_trackio_config()
|
| 99 |
+
if "frontend_dir" not in config:
|
| 100 |
+
return False
|
| 101 |
+
del config["frontend_dir"]
|
| 102 |
+
if config:
|
| 103 |
+
save_trackio_config(config)
|
| 104 |
+
elif TRACKIO_CONFIG_PATH.exists():
|
| 105 |
+
TRACKIO_CONFIG_PATH.unlink()
|
| 106 |
+
return True
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _configured_frontend_candidates(
|
| 110 |
+
frontend_dir: str | Path | None,
|
| 111 |
+
) -> list[tuple[str, Path]]:
|
| 112 |
+
candidates: list[tuple[str, Path]] = []
|
| 113 |
+
if frontend_dir is not None:
|
| 114 |
+
candidates.append(("argument", _normalize_frontend_path(frontend_dir)))
|
| 115 |
+
env_dir = os.environ.get("TRACKIO_FRONTEND_DIR")
|
| 116 |
+
if env_dir:
|
| 117 |
+
candidates.append(("env", _normalize_frontend_path(env_dir)))
|
| 118 |
+
persisted_dir = get_persisted_frontend_dir()
|
| 119 |
+
if persisted_dir is not None:
|
| 120 |
+
candidates.append(("config", persisted_dir))
|
| 121 |
+
return candidates
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _announce_config_frontend(frontend_dir: Path) -> None:
|
| 125 |
+
print(
|
| 126 |
+
f"* Using Trackio custom frontend from config: {frontend_dir}\n"
|
| 127 |
+
" Reset with `trackio config unset frontend`."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def resolve_frontend_dir(
|
| 132 |
+
frontend_dir: str | Path | None = None,
|
| 133 |
+
*,
|
| 134 |
+
announce: bool = False,
|
| 135 |
+
) -> ResolvedFrontend:
|
| 136 |
+
for source, candidate in _configured_frontend_candidates(frontend_dir):
|
| 137 |
+
if source == "argument":
|
| 138 |
+
if not candidate.exists() or _is_empty_directory(candidate):
|
| 139 |
+
_materialize_argument_frontend_dir(candidate)
|
| 140 |
+
|
| 141 |
+
if is_valid_frontend_dir(candidate):
|
| 142 |
+
if source == "config" and announce:
|
| 143 |
+
_announce_config_frontend(candidate)
|
| 144 |
+
return ResolvedFrontend(
|
| 145 |
+
path=candidate,
|
| 146 |
+
source=source,
|
| 147 |
+
is_custom=True,
|
| 148 |
+
)
|
| 149 |
+
if source == "argument":
|
| 150 |
+
print(
|
| 151 |
+
f"* Trackio frontend from {source} is invalid: {candidate}. "
|
| 152 |
+
f"Falling back to starter template at {STARTER_FRONTEND_DIR}."
|
| 153 |
+
)
|
| 154 |
+
return ResolvedFrontend(
|
| 155 |
+
path=STARTER_FRONTEND_DIR,
|
| 156 |
+
source="starter",
|
| 157 |
+
is_custom=True,
|
| 158 |
+
used_fallback=True,
|
| 159 |
+
requested_path=candidate,
|
| 160 |
+
)
|
| 161 |
+
print(f"* Trackio frontend from {source} is invalid: {candidate}. Ignoring it.")
|
| 162 |
+
|
| 163 |
+
if is_valid_frontend_dir(BUNDLED_FRONTEND_DIR):
|
| 164 |
+
return ResolvedFrontend(
|
| 165 |
+
path=BUNDLED_FRONTEND_DIR,
|
| 166 |
+
source="bundled",
|
| 167 |
+
is_custom=False,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
return ResolvedFrontend(
|
| 171 |
+
path=STARTER_FRONTEND_DIR,
|
| 172 |
+
source="starter",
|
| 173 |
+
is_custom=True,
|
| 174 |
+
used_fallback=True,
|
| 175 |
+
)
|
trackio/frontend_server.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serves a static frontend alongside the Trackio HTTP API."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 10 |
+
from starlette.responses import FileResponse, HTMLResponse, JSONResponse
|
| 11 |
+
from starlette.routing import Mount, Route, get_route_path
|
| 12 |
+
from starlette.staticfiles import StaticFiles
|
| 13 |
+
|
| 14 |
+
ASSETS_DIR = Path(__file__).parent / "assets"
|
| 15 |
+
|
| 16 |
+
_logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_ABSOLUTE_REF_RE = re.compile(r'(href|src)="(/(?:assets|static/trackio)/[^"]+)"')
|
| 19 |
+
|
| 20 |
+
_LIVE_RELOAD_SCRIPT = """
|
| 21 |
+
<script>
|
| 22 |
+
(() => {
|
| 23 |
+
const endpoint = "__TRACKIO_ROOT_PATH__/__trackio/frontend_version";
|
| 24 |
+
let currentVersion = null;
|
| 25 |
+
|
| 26 |
+
async function poll() {
|
| 27 |
+
try {
|
| 28 |
+
const response = await fetch(endpoint, { cache: "no-store" });
|
| 29 |
+
const payload = await response.json();
|
| 30 |
+
if (currentVersion === null) {
|
| 31 |
+
currentVersion = payload.version;
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
if (payload.version !== currentVersion) {
|
| 35 |
+
window.location.reload();
|
| 36 |
+
}
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.warn("Trackio live reload poll failed", error);
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
poll();
|
| 43 |
+
setInterval(poll, 1000);
|
| 44 |
+
})();
|
| 45 |
+
</script>
|
| 46 |
+
""".strip()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _frontend_version(frontend_root: Path) -> str:
|
| 50 |
+
digest = hashlib.sha256()
|
| 51 |
+
for path in sorted(frontend_root.rglob("*")):
|
| 52 |
+
if not path.is_file():
|
| 53 |
+
continue
|
| 54 |
+
relative = path.relative_to(frontend_root)
|
| 55 |
+
digest.update(str(relative).encode("utf-8"))
|
| 56 |
+
stat = path.stat()
|
| 57 |
+
digest.update(str(stat.st_mtime_ns).encode("utf-8"))
|
| 58 |
+
digest.update(str(stat.st_size).encode("utf-8"))
|
| 59 |
+
return digest.hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _inject_live_reload(html: str, root_path: str) -> str:
|
| 63 |
+
if "/__trackio/frontend_version" in html:
|
| 64 |
+
return html
|
| 65 |
+
script = _LIVE_RELOAD_SCRIPT.replace("__TRACKIO_ROOT_PATH__", root_path)
|
| 66 |
+
if "</body>" in html:
|
| 67 |
+
return html.replace("</body>", f"{script}\n </body>")
|
| 68 |
+
return html + script
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _prefix_absolute_refs(html: str, root_path: str) -> str:
|
| 72 |
+
if not root_path:
|
| 73 |
+
return html
|
| 74 |
+
return _ABSOLUTE_REF_RE.sub(
|
| 75 |
+
lambda m: f'{m.group(1)}="{root_path}{m.group(2)}"', html
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _inject_base_script(html: str, root_path: str) -> str:
|
| 80 |
+
if not root_path:
|
| 81 |
+
return html
|
| 82 |
+
script = f"<script>window.__trackio_base = {json.dumps(root_path)};</script>"
|
| 83 |
+
if "<head>" in html:
|
| 84 |
+
return html.replace("<head>", f"<head>\n {script}", 1)
|
| 85 |
+
return script + html
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _render_index_html(path: Path, root_path: str = "") -> str:
|
| 89 |
+
html = path.read_text(encoding="utf-8")
|
| 90 |
+
html = _prefix_absolute_refs(html, root_path)
|
| 91 |
+
html = _inject_base_script(html, root_path)
|
| 92 |
+
return _inject_live_reload(html, root_path)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _html_response(path: Path, root_path: str = "") -> HTMLResponse:
|
| 96 |
+
return HTMLResponse(_render_index_html(path, root_path))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class FrontendMiddleware(BaseHTTPMiddleware):
|
| 100 |
+
def __init__(self, app, frontend_root: Path, index_html_path: Path):
|
| 101 |
+
super().__init__(app)
|
| 102 |
+
self.frontend_root = frontend_root
|
| 103 |
+
self.index_html_path = index_html_path
|
| 104 |
+
self.reserved_prefixes = (
|
| 105 |
+
"/api/",
|
| 106 |
+
"/file",
|
| 107 |
+
"/version",
|
| 108 |
+
"/artifact_blob/",
|
| 109 |
+
"/static/trackio",
|
| 110 |
+
"/__trackio/frontend_version",
|
| 111 |
+
"/oauth/",
|
| 112 |
+
"/login/",
|
| 113 |
+
"/mcp",
|
| 114 |
+
)
|
| 115 |
+
self.reserved_exact = {
|
| 116 |
+
"/api",
|
| 117 |
+
"/oauth",
|
| 118 |
+
"/login",
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
async def dispatch(self, request, call_next):
|
| 122 |
+
if request.method not in {"GET", "HEAD"}:
|
| 123 |
+
return await call_next(request)
|
| 124 |
+
|
| 125 |
+
path = get_route_path(request.scope)
|
| 126 |
+
if path in self.reserved_exact or path.startswith(self.reserved_prefixes):
|
| 127 |
+
return await call_next(request)
|
| 128 |
+
|
| 129 |
+
root_path = request.scope.get("root_path", "")
|
| 130 |
+
relative_path = path.lstrip("/")
|
| 131 |
+
requested_path = (self.frontend_root / relative_path).resolve()
|
| 132 |
+
if (
|
| 133 |
+
relative_path
|
| 134 |
+
and requested_path.is_file()
|
| 135 |
+
and requested_path.is_relative_to(self.frontend_root)
|
| 136 |
+
):
|
| 137 |
+
if requested_path.suffix.lower() == ".html":
|
| 138 |
+
return _html_response(requested_path, root_path)
|
| 139 |
+
return FileResponse(requested_path)
|
| 140 |
+
|
| 141 |
+
return _html_response(self.index_html_path, root_path)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def mount_frontend(app, frontend_dir: str | Path):
|
| 145 |
+
frontend_root = Path(frontend_dir).resolve()
|
| 146 |
+
if not frontend_root.exists():
|
| 147 |
+
_logger.warning(
|
| 148 |
+
"Trackio dashboard UI was not mounted: %s is missing. "
|
| 149 |
+
"Build the frontend or provide a custom frontend directory.",
|
| 150 |
+
frontend_root,
|
| 151 |
+
)
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
index_html_path = frontend_root / "index.html"
|
| 155 |
+
if not index_html_path.exists():
|
| 156 |
+
_logger.warning(
|
| 157 |
+
"Trackio dashboard UI was not mounted: %s is missing.",
|
| 158 |
+
index_html_path,
|
| 159 |
+
)
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
async def frontend_version(_request):
|
| 163 |
+
return JSONResponse({"version": _frontend_version(frontend_root)})
|
| 164 |
+
|
| 165 |
+
static_assets = StaticFiles(directory=str(ASSETS_DIR))
|
| 166 |
+
|
| 167 |
+
app.add_middleware(
|
| 168 |
+
FrontendMiddleware,
|
| 169 |
+
frontend_root=frontend_root,
|
| 170 |
+
index_html_path=index_html_path,
|
| 171 |
+
)
|
| 172 |
+
app.routes.append(Mount("/static/trackio", app=static_assets))
|
| 173 |
+
app.routes.append(
|
| 174 |
+
Route("/__trackio/frontend_version", frontend_version, methods=["GET"])
|
| 175 |
+
)
|
trackio/frontend_templates/logbook/bucket-icon.svg
ADDED
|
|
trackio/frontend_templates/logbook/index.html
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Trackio Logbook</title>
|
| 7 |
+
<link rel="stylesheet" href="./logbook.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="app">
|
| 11 |
+
<aside id="sidebar">
|
| 12 |
+
<div id="book-head">
|
| 13 |
+
<img id="book-wordmark" src="./trackio-wordmark-dark.png" alt="" />
|
| 14 |
+
<div id="book-title" class="sr-only">Logbook</div>
|
| 15 |
+
</div>
|
| 16 |
+
<nav id="tree"></nav>
|
| 17 |
+
<div id="sidebar-foot" hidden>
|
| 18 |
+
<button id="connect-btn" type="button">
|
| 19 |
+
<span class="ico">ⓘ</span> Collaborate with your agent
|
| 20 |
+
</button>
|
| 21 |
+
</div>
|
| 22 |
+
</aside>
|
| 23 |
+
<main id="content">
|
| 24 |
+
<div id="page"></div>
|
| 25 |
+
</main>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div id="modal" hidden>
|
| 29 |
+
<div class="modal-backdrop"></div>
|
| 30 |
+
<div class="modal-card" role="dialog" aria-modal="true">
|
| 31 |
+
<div class="modal-head">
|
| 32 |
+
<div class="modal-title">
|
| 33 |
+
<img class="modal-logo" src="./trackio-logo.png" alt="" />
|
| 34 |
+
Collaborate with your agent
|
| 35 |
+
</div>
|
| 36 |
+
<div class="modal-actions">
|
| 37 |
+
<button id="copy-agent" class="btn">Copy for agent</button>
|
| 38 |
+
<button id="modal-close" class="btn icon" aria-label="Close">×</button>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="modal-body">
|
| 42 |
+
<p class="modal-intro">
|
| 43 |
+
Point your coding agent at this logbook. It reads a compact,
|
| 44 |
+
token-efficient version — and if you've given it write access to this
|
| 45 |
+
Space, it can add findings that sync back automatically.
|
| 46 |
+
</p>
|
| 47 |
+
<ol id="connect-steps"></ol>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<script src="./logbook.js"></script>
|
| 53 |
+
</body>
|
| 54 |
+
</html>
|
trackio/frontend_templates/logbook/logbook.css
ADDED
|
@@ -0,0 +1,1602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #ffffff;
|
| 3 |
+
--paper: #fdfcf9;
|
| 4 |
+
--panel: #ffffff;
|
| 5 |
+
--ink: #1f2937;
|
| 6 |
+
--muted: #6b7280;
|
| 7 |
+
--line: #e5e7eb;
|
| 8 |
+
--accent: #f97316;
|
| 9 |
+
--accent-strong: #ea580c;
|
| 10 |
+
--accent-soft: #fff7ed;
|
| 11 |
+
--accent-line: rgba(249, 115, 22, 0.16);
|
| 12 |
+
--grid-line: rgba(31, 41, 55, 0.045);
|
| 13 |
+
--code-bg: #f3f4f6;
|
| 14 |
+
--radius: 12px;
|
| 15 |
+
--serif: ui-serif, "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
| 16 |
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
|
| 17 |
+
sans-serif;
|
| 18 |
+
--mono: "SFMono-Regular", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas,
|
| 19 |
+
ui-monospace, monospace;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* {
|
| 23 |
+
box-sizing: border-box;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
html,
|
| 27 |
+
body {
|
| 28 |
+
margin: 0;
|
| 29 |
+
padding: 0;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
html {
|
| 33 |
+
scroll-behavior: smooth;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
body {
|
| 37 |
+
background: var(--bg);
|
| 38 |
+
color: var(--ink);
|
| 39 |
+
font-family: var(--sans);
|
| 40 |
+
font-size: 13px;
|
| 41 |
+
line-height: 1.65;
|
| 42 |
+
-webkit-font-smoothing: antialiased;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
#app {
|
| 46 |
+
display: flex;
|
| 47 |
+
min-height: 100vh;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* ---- sidebar (composition-book cover) ---- */
|
| 51 |
+
#sidebar {
|
| 52 |
+
width: 280px;
|
| 53 |
+
flex: 0 0 280px;
|
| 54 |
+
background: #17181c;
|
| 55 |
+
color: #e7e7ea;
|
| 56 |
+
position: sticky;
|
| 57 |
+
top: 0;
|
| 58 |
+
height: 100vh;
|
| 59 |
+
overflow-y: auto;
|
| 60 |
+
padding: 22px 16px;
|
| 61 |
+
display: flex;
|
| 62 |
+
flex-direction: column;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#book-head {
|
| 66 |
+
display: flex;
|
| 67 |
+
align-items: center;
|
| 68 |
+
gap: 10px;
|
| 69 |
+
padding: 8px;
|
| 70 |
+
margin-bottom: 12px;
|
| 71 |
+
border-radius: 10px;
|
| 72 |
+
cursor: pointer;
|
| 73 |
+
transition: background 0.12s;
|
| 74 |
+
}
|
| 75 |
+
#book-head:hover {
|
| 76 |
+
background: rgba(255, 255, 255, 0.05);
|
| 77 |
+
}
|
| 78 |
+
#book-wordmark {
|
| 79 |
+
width: 154px;
|
| 80 |
+
height: auto;
|
| 81 |
+
object-fit: contain;
|
| 82 |
+
}
|
| 83 |
+
.sr-only {
|
| 84 |
+
position: absolute;
|
| 85 |
+
width: 1px;
|
| 86 |
+
height: 1px;
|
| 87 |
+
padding: 0;
|
| 88 |
+
margin: -1px;
|
| 89 |
+
overflow: hidden;
|
| 90 |
+
clip: rect(0, 0, 0, 0);
|
| 91 |
+
white-space: nowrap;
|
| 92 |
+
border: 0;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
#tree {
|
| 96 |
+
flex: 1;
|
| 97 |
+
padding-top: 8px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
#tree a {
|
| 101 |
+
display: block;
|
| 102 |
+
padding: 6px 10px;
|
| 103 |
+
border-radius: 8px;
|
| 104 |
+
color: #c3c4cb;
|
| 105 |
+
text-decoration: none;
|
| 106 |
+
font-size: 14px;
|
| 107 |
+
transition: background 0.12s, color 0.12s;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
#tree a:hover {
|
| 111 |
+
background: rgba(255, 255, 255, 0.06);
|
| 112 |
+
color: #ffffff;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
#tree a.active {
|
| 116 |
+
background: rgba(249, 115, 22, 0.16);
|
| 117 |
+
color: #fdba74;
|
| 118 |
+
font-weight: 600;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
#tree a .tree-mark {
|
| 122 |
+
color: #6b6d76;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
#tree a:hover .tree-mark,
|
| 126 |
+
#tree a.active .tree-mark {
|
| 127 |
+
color: inherit;
|
| 128 |
+
opacity: 0.6;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
#tree .depth-1 {
|
| 132 |
+
padding-left: 22px;
|
| 133 |
+
}
|
| 134 |
+
#tree .depth-2 {
|
| 135 |
+
padding-left: 34px;
|
| 136 |
+
}
|
| 137 |
+
#tree .depth-3 {
|
| 138 |
+
padding-left: 46px;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
/* ---- content ---- */
|
| 143 |
+
#content {
|
| 144 |
+
flex: 1;
|
| 145 |
+
min-width: 0;
|
| 146 |
+
padding: 48px 40px 120px;
|
| 147 |
+
background-color: var(--paper);
|
| 148 |
+
background-image:
|
| 149 |
+
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
| 150 |
+
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
| 151 |
+
background-size: 26px 26px;
|
| 152 |
+
background-position: center top;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#page {
|
| 156 |
+
width: 100%;
|
| 157 |
+
min-width: 0;
|
| 158 |
+
max-width: 1052px;
|
| 159 |
+
margin: 0 auto;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.page-section {
|
| 163 |
+
scroll-margin-top: 40px;
|
| 164 |
+
padding: 0 0 35px;
|
| 165 |
+
margin: 0 0 32px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.page-section:last-child {
|
| 169 |
+
margin-bottom: 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.page-layout {
|
| 173 |
+
display: grid;
|
| 174 |
+
grid-template-columns: minmax(0, 760px) 248px;
|
| 175 |
+
gap: 44px;
|
| 176 |
+
align-items: start;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.page-body {
|
| 180 |
+
min-width: 0;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.resource-anchor {
|
| 184 |
+
display: block;
|
| 185 |
+
height: 0;
|
| 186 |
+
overflow: hidden;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/* ---- pinned notes ---- */
|
| 190 |
+
.pinned-notes {
|
| 191 |
+
margin: 30px 0 0;
|
| 192 |
+
}
|
| 193 |
+
.pinned-notes-list .cell {
|
| 194 |
+
margin: 0;
|
| 195 |
+
border-color: rgba(249, 115, 22, 0.55);
|
| 196 |
+
}
|
| 197 |
+
.pinned-notes-list .cell + .cell {
|
| 198 |
+
margin-top: 12px;
|
| 199 |
+
}
|
| 200 |
+
.cell.pinned-source {
|
| 201 |
+
border-color: rgba(249, 115, 22, 0.55);
|
| 202 |
+
}
|
| 203 |
+
.book-intro.has-pinned-notes {
|
| 204 |
+
border-bottom: none;
|
| 205 |
+
padding-bottom: 22px;
|
| 206 |
+
margin-bottom: 30px;
|
| 207 |
+
}
|
| 208 |
+
.book-intro.book-intro-tight {
|
| 209 |
+
border-bottom: none;
|
| 210 |
+
padding-bottom: 4px;
|
| 211 |
+
margin-bottom: 20px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
#page h1 {
|
| 215 |
+
font-family: var(--serif);
|
| 216 |
+
font-size: 34px;
|
| 217 |
+
line-height: 1.15;
|
| 218 |
+
letter-spacing: -0.02em;
|
| 219 |
+
margin: 0 0 8px;
|
| 220 |
+
overflow-wrap: anywhere;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
#page .page-section:not(.book-intro) h1 {
|
| 224 |
+
font-size: 26px;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
#page h2 {
|
| 228 |
+
font-family: var(--serif);
|
| 229 |
+
font-size: 24px;
|
| 230 |
+
margin: 36px 0 10px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
#page h3 {
|
| 234 |
+
font-size: 17px;
|
| 235 |
+
font-weight: 700;
|
| 236 |
+
margin: 26px 0 2px;
|
| 237 |
+
letter-spacing: -0.01em;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
#page h3::before {
|
| 241 |
+
content: "";
|
| 242 |
+
display: inline-block;
|
| 243 |
+
width: 7px;
|
| 244 |
+
height: 7px;
|
| 245 |
+
border-radius: 2px;
|
| 246 |
+
background: var(--accent);
|
| 247 |
+
margin-right: 10px;
|
| 248 |
+
vertical-align: middle;
|
| 249 |
+
transform: translateY(-1px);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
#page p {
|
| 253 |
+
margin: 10px 0;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
#page blockquote {
|
| 257 |
+
margin: 14px 0;
|
| 258 |
+
padding: 2px 16px;
|
| 259 |
+
border-left: 3px solid #fdba74;
|
| 260 |
+
color: var(--muted);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
#page hr {
|
| 264 |
+
display: none;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
#page code {
|
| 268 |
+
font-family: var(--mono);
|
| 269 |
+
font-size: 0.86em;
|
| 270 |
+
background: var(--code-bg);
|
| 271 |
+
padding: 2px 6px;
|
| 272 |
+
border-radius: 6px;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
#page pre {
|
| 276 |
+
max-width: 100%;
|
| 277 |
+
background: var(--code-bg);
|
| 278 |
+
border: 1px solid var(--line);
|
| 279 |
+
border-radius: var(--radius);
|
| 280 |
+
padding: 14px 16px;
|
| 281 |
+
overflow-x: auto;
|
| 282 |
+
}
|
| 283 |
+
#page pre code {
|
| 284 |
+
background: none;
|
| 285 |
+
padding: 0;
|
| 286 |
+
font-size: 11.5px;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
/* ---- code blocks + collapsible accordion ---- */
|
| 290 |
+
#page pre.hl {
|
| 291 |
+
background: #17181c;
|
| 292 |
+
border: none;
|
| 293 |
+
color: #e7e7ea;
|
| 294 |
+
font-size: 13px;
|
| 295 |
+
line-height: 1.58;
|
| 296 |
+
}
|
| 297 |
+
#page pre.hl code {
|
| 298 |
+
color: inherit;
|
| 299 |
+
font-family: var(--mono);
|
| 300 |
+
}
|
| 301 |
+
.code-accordion {
|
| 302 |
+
border: 1px solid rgba(249, 115, 22, 0.2);
|
| 303 |
+
border-radius: 8px;
|
| 304 |
+
overflow: hidden;
|
| 305 |
+
margin: 12px 0;
|
| 306 |
+
background: #17181c;
|
| 307 |
+
}
|
| 308 |
+
.code-accordion summary {
|
| 309 |
+
list-style: none;
|
| 310 |
+
cursor: pointer;
|
| 311 |
+
display: flex;
|
| 312 |
+
align-items: center;
|
| 313 |
+
gap: 9px;
|
| 314 |
+
padding: 9px 12px;
|
| 315 |
+
font-family: var(--mono);
|
| 316 |
+
font-size: 11.5px;
|
| 317 |
+
font-weight: 700;
|
| 318 |
+
color: #e7e7ea;
|
| 319 |
+
background: #1e2027;
|
| 320 |
+
user-select: none;
|
| 321 |
+
overflow-wrap: anywhere;
|
| 322 |
+
}
|
| 323 |
+
.code-accordion summary::-webkit-details-marker {
|
| 324 |
+
display: none;
|
| 325 |
+
}
|
| 326 |
+
.code-accordion summary::after {
|
| 327 |
+
content: "▸";
|
| 328 |
+
margin-left: auto;
|
| 329 |
+
color: var(--accent);
|
| 330 |
+
transition: transform 0.12s;
|
| 331 |
+
transform: rotate(180deg);
|
| 332 |
+
}
|
| 333 |
+
.code-accordion[open] summary::after {
|
| 334 |
+
transform: rotate(90deg);
|
| 335 |
+
}
|
| 336 |
+
.code-accordion .code-ico {
|
| 337 |
+
color: var(--accent);
|
| 338 |
+
font-weight: 700;
|
| 339 |
+
}
|
| 340 |
+
.code-accordion pre.hl {
|
| 341 |
+
margin: 0;
|
| 342 |
+
border-radius: 0;
|
| 343 |
+
border: none;
|
| 344 |
+
border-top: 1px solid rgba(249, 115, 22, 0.16);
|
| 345 |
+
}
|
| 346 |
+
.tok-comment {
|
| 347 |
+
color: #7a7d87;
|
| 348 |
+
font-style: italic;
|
| 349 |
+
}
|
| 350 |
+
.tok-string {
|
| 351 |
+
color: #a5d6a7;
|
| 352 |
+
}
|
| 353 |
+
.tok-keyword {
|
| 354 |
+
color: #fdba74;
|
| 355 |
+
}
|
| 356 |
+
.tok-number {
|
| 357 |
+
color: #7fd0e0;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
#page a {
|
| 361 |
+
color: var(--accent);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
#page ul {
|
| 365 |
+
padding-left: 20px;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.ts {
|
| 369 |
+
font-family: var(--mono);
|
| 370 |
+
font-size: 12px;
|
| 371 |
+
color: var(--muted);
|
| 372 |
+
background: none;
|
| 373 |
+
padding: 0;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/* ---- notebook-style cells ---- */
|
| 377 |
+
.cell {
|
| 378 |
+
max-width: 100%;
|
| 379 |
+
border: 1px solid var(--line);
|
| 380 |
+
border-radius: 10px;
|
| 381 |
+
background: rgba(255, 255, 255, 0.86);
|
| 382 |
+
margin: 18px 0;
|
| 383 |
+
overflow: hidden;
|
| 384 |
+
box-shadow: 0 2px 10px rgba(31, 41, 55, 0.035);
|
| 385 |
+
}
|
| 386 |
+
.cell-head {
|
| 387 |
+
display: flex;
|
| 388 |
+
justify-content: space-between;
|
| 389 |
+
gap: 16px;
|
| 390 |
+
align-items: center;
|
| 391 |
+
padding: 14px 18px;
|
| 392 |
+
background: rgba(255, 255, 255, 0.92);
|
| 393 |
+
border-bottom: 1px solid var(--line);
|
| 394 |
+
}
|
| 395 |
+
.cell-head.no-title {
|
| 396 |
+
justify-content: flex-end;
|
| 397 |
+
padding-top: 10px;
|
| 398 |
+
padding-bottom: 10px;
|
| 399 |
+
}
|
| 400 |
+
.cell-title {
|
| 401 |
+
flex: 1;
|
| 402 |
+
min-width: 0;
|
| 403 |
+
font-size: 13px;
|
| 404 |
+
font-weight: 650;
|
| 405 |
+
color: var(--ink);
|
| 406 |
+
line-height: 1.35;
|
| 407 |
+
overflow-wrap: anywhere;
|
| 408 |
+
}
|
| 409 |
+
.cell-meta {
|
| 410 |
+
flex: 0 0 auto;
|
| 411 |
+
display: flex;
|
| 412 |
+
align-items: center;
|
| 413 |
+
gap: 10px;
|
| 414 |
+
font-family: var(--sans);
|
| 415 |
+
font-size: 13px;
|
| 416 |
+
color: var(--muted);
|
| 417 |
+
}
|
| 418 |
+
.cell-open {
|
| 419 |
+
flex: 0 0 auto;
|
| 420 |
+
font-family: var(--mono);
|
| 421 |
+
font-size: 12px;
|
| 422 |
+
color: var(--accent);
|
| 423 |
+
text-decoration: none;
|
| 424 |
+
}
|
| 425 |
+
.cell-open:hover {
|
| 426 |
+
color: var(--accent-strong);
|
| 427 |
+
}
|
| 428 |
+
.cell-body {
|
| 429 |
+
min-width: 0;
|
| 430 |
+
padding: 14px 18px 18px;
|
| 431 |
+
}
|
| 432 |
+
.cell.dashboard .cell-body {
|
| 433 |
+
padding: 0;
|
| 434 |
+
}
|
| 435 |
+
#page .cell-body h1,
|
| 436 |
+
#page .cell-body h2 {
|
| 437 |
+
font-family: var(--sans);
|
| 438 |
+
font-size: 17px;
|
| 439 |
+
font-weight: 700;
|
| 440 |
+
letter-spacing: -0.01em;
|
| 441 |
+
line-height: 1.35;
|
| 442 |
+
margin: 22px 0 6px;
|
| 443 |
+
}
|
| 444 |
+
#page .cell-body > :first-child {
|
| 445 |
+
margin-top: 0;
|
| 446 |
+
}
|
| 447 |
+
#page .cell-body > :last-child {
|
| 448 |
+
margin-bottom: 0;
|
| 449 |
+
}
|
| 450 |
+
.cell.code .cell-head {
|
| 451 |
+
background: #fbfbfc;
|
| 452 |
+
}
|
| 453 |
+
.figure-fit {
|
| 454 |
+
position: relative;
|
| 455 |
+
overflow: hidden;
|
| 456 |
+
min-height: 160px;
|
| 457 |
+
border: 1px solid var(--line);
|
| 458 |
+
border-radius: 8px;
|
| 459 |
+
background: #fff;
|
| 460 |
+
}
|
| 461 |
+
.figure-fit[hidden] {
|
| 462 |
+
display: none;
|
| 463 |
+
}
|
| 464 |
+
.figure-fit:fullscreen,
|
| 465 |
+
.figure-fit:-webkit-full-screen {
|
| 466 |
+
width: 100%;
|
| 467 |
+
height: 100%;
|
| 468 |
+
border: none;
|
| 469 |
+
border-radius: 0;
|
| 470 |
+
}
|
| 471 |
+
.figure-frame {
|
| 472 |
+
display: block;
|
| 473 |
+
width: 100%;
|
| 474 |
+
min-height: 160px;
|
| 475 |
+
border: none;
|
| 476 |
+
background: #fff;
|
| 477 |
+
}
|
| 478 |
+
.figure-frame[hidden],
|
| 479 |
+
.figure-raw[hidden] {
|
| 480 |
+
display: none;
|
| 481 |
+
}
|
| 482 |
+
.fig-switch {
|
| 483 |
+
position: relative;
|
| 484 |
+
display: inline-flex;
|
| 485 |
+
flex: 0 0 auto;
|
| 486 |
+
border: 1px solid var(--line);
|
| 487 |
+
border-radius: 999px;
|
| 488 |
+
background: var(--code-bg);
|
| 489 |
+
padding: 2px;
|
| 490 |
+
}
|
| 491 |
+
.fig-switch button {
|
| 492 |
+
position: relative;
|
| 493 |
+
z-index: 1;
|
| 494 |
+
flex: 1;
|
| 495 |
+
min-width: 62px;
|
| 496 |
+
border: none;
|
| 497 |
+
background: none;
|
| 498 |
+
font-family: var(--sans);
|
| 499 |
+
font-size: 12px;
|
| 500 |
+
font-weight: 600;
|
| 501 |
+
color: var(--muted);
|
| 502 |
+
padding: 3px 12px;
|
| 503 |
+
border-radius: 999px;
|
| 504 |
+
cursor: pointer;
|
| 505 |
+
transition: color 0.15s;
|
| 506 |
+
}
|
| 507 |
+
.fig-switch button.active {
|
| 508 |
+
color: var(--accent-strong);
|
| 509 |
+
}
|
| 510 |
+
.fig-switch-thumb {
|
| 511 |
+
position: absolute;
|
| 512 |
+
top: 2px;
|
| 513 |
+
bottom: 2px;
|
| 514 |
+
left: 2px;
|
| 515 |
+
width: calc(50% - 2px);
|
| 516 |
+
border-radius: 999px;
|
| 517 |
+
background: var(--panel);
|
| 518 |
+
border: 1px solid rgba(249, 115, 22, 0.35);
|
| 519 |
+
box-shadow: 0 1px 4px rgba(31, 41, 55, 0.08);
|
| 520 |
+
transition: transform 0.18s ease;
|
| 521 |
+
}
|
| 522 |
+
.fig-switch.raw .fig-switch-thumb {
|
| 523 |
+
transform: translateX(100%);
|
| 524 |
+
}
|
| 525 |
+
#page .figure-raw pre {
|
| 526 |
+
margin: 0;
|
| 527 |
+
max-height: 420px;
|
| 528 |
+
overflow: auto;
|
| 529 |
+
font-family: var(--mono);
|
| 530 |
+
font-size: 13px;
|
| 531 |
+
line-height: 1.55;
|
| 532 |
+
background: var(--code-bg);
|
| 533 |
+
border: 1px solid var(--line);
|
| 534 |
+
border-radius: 8px;
|
| 535 |
+
padding: 12px 14px;
|
| 536 |
+
}
|
| 537 |
+
/* ---- figure fullscreen ---- */
|
| 538 |
+
.cell-fullscreen {
|
| 539 |
+
position: relative;
|
| 540 |
+
display: inline-flex;
|
| 541 |
+
flex: 0 0 auto;
|
| 542 |
+
}
|
| 543 |
+
.cell-fullscreen-btn {
|
| 544 |
+
display: inline-flex;
|
| 545 |
+
align-items: center;
|
| 546 |
+
justify-content: center;
|
| 547 |
+
width: 26px;
|
| 548 |
+
height: 26px;
|
| 549 |
+
padding: 0;
|
| 550 |
+
border: 1px solid var(--line);
|
| 551 |
+
border-radius: 999px;
|
| 552 |
+
background: var(--code-bg);
|
| 553 |
+
color: var(--muted);
|
| 554 |
+
cursor: pointer;
|
| 555 |
+
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
| 556 |
+
}
|
| 557 |
+
.cell-fullscreen-btn:hover {
|
| 558 |
+
color: var(--accent-strong);
|
| 559 |
+
border-color: rgba(249, 115, 22, 0.35);
|
| 560 |
+
background: var(--accent-soft);
|
| 561 |
+
}
|
| 562 |
+
.cell-fullscreen-btn svg {
|
| 563 |
+
width: 14px;
|
| 564 |
+
height: 14px;
|
| 565 |
+
}
|
| 566 |
+
/* ---- copyable snippets ---- */
|
| 567 |
+
.snippet {
|
| 568 |
+
position: relative;
|
| 569 |
+
}
|
| 570 |
+
.copy-snippet {
|
| 571 |
+
position: absolute;
|
| 572 |
+
top: 7px;
|
| 573 |
+
right: 8px;
|
| 574 |
+
width: 24px;
|
| 575 |
+
height: 24px;
|
| 576 |
+
border: none;
|
| 577 |
+
border-radius: 6px;
|
| 578 |
+
background: rgba(255, 255, 255, 0.08);
|
| 579 |
+
color: #9a9da8;
|
| 580 |
+
font-size: 12px;
|
| 581 |
+
line-height: 1;
|
| 582 |
+
cursor: pointer;
|
| 583 |
+
opacity: 0;
|
| 584 |
+
transition: opacity 0.12s, color 0.12s, background 0.12s;
|
| 585 |
+
}
|
| 586 |
+
.snippet:hover .copy-snippet,
|
| 587 |
+
.jp-out:hover .copy-snippet,
|
| 588 |
+
.figure-raw:hover .copy-snippet,
|
| 589 |
+
.code-accordion summary:hover .copy-snippet {
|
| 590 |
+
opacity: 1;
|
| 591 |
+
}
|
| 592 |
+
.copy-snippet:hover {
|
| 593 |
+
color: #ffffff;
|
| 594 |
+
background: rgba(255, 255, 255, 0.16);
|
| 595 |
+
}
|
| 596 |
+
.copy-snippet.copied {
|
| 597 |
+
color: #52d08a;
|
| 598 |
+
opacity: 1;
|
| 599 |
+
}
|
| 600 |
+
.code-accordion .code-name {
|
| 601 |
+
user-select: text;
|
| 602 |
+
cursor: text;
|
| 603 |
+
}
|
| 604 |
+
.jp-out,
|
| 605 |
+
.figure-raw {
|
| 606 |
+
position: relative;
|
| 607 |
+
}
|
| 608 |
+
.jp-out .copy-snippet,
|
| 609 |
+
.figure-raw .copy-snippet {
|
| 610 |
+
background: var(--code-bg);
|
| 611 |
+
color: var(--muted);
|
| 612 |
+
border: 1px solid var(--line);
|
| 613 |
+
}
|
| 614 |
+
.jp-out .copy-snippet:hover,
|
| 615 |
+
.figure-raw .copy-snippet:hover {
|
| 616 |
+
color: var(--accent-strong);
|
| 617 |
+
background: var(--panel);
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
/* ---- jupyter-style code cells ---- */
|
| 621 |
+
.jp {
|
| 622 |
+
border: 1px solid var(--line);
|
| 623 |
+
border-radius: 10px;
|
| 624 |
+
overflow: hidden;
|
| 625 |
+
margin: 12px 0;
|
| 626 |
+
background: var(--panel);
|
| 627 |
+
}
|
| 628 |
+
.jp-gutter {
|
| 629 |
+
flex: 0 0 46px;
|
| 630 |
+
padding: 13px 0 0 13px;
|
| 631 |
+
font-family: var(--mono);
|
| 632 |
+
font-size: 10.5px;
|
| 633 |
+
letter-spacing: 0.07em;
|
| 634 |
+
text-transform: uppercase;
|
| 635 |
+
font-weight: 600;
|
| 636 |
+
user-select: none;
|
| 637 |
+
}
|
| 638 |
+
.jp-in {
|
| 639 |
+
display: flex;
|
| 640 |
+
background: #17181c;
|
| 641 |
+
}
|
| 642 |
+
.jp-in .jp-gutter {
|
| 643 |
+
color: #6f727d;
|
| 644 |
+
}
|
| 645 |
+
.jp-in-body {
|
| 646 |
+
flex: 1;
|
| 647 |
+
min-width: 0;
|
| 648 |
+
}
|
| 649 |
+
#page .jp-in-body pre.hl {
|
| 650 |
+
margin: 0;
|
| 651 |
+
border: none;
|
| 652 |
+
border-radius: 0;
|
| 653 |
+
background: none;
|
| 654 |
+
padding: 12px 16px 12px 0;
|
| 655 |
+
}
|
| 656 |
+
.jp-in-body .code-accordion {
|
| 657 |
+
margin: 0;
|
| 658 |
+
border: none;
|
| 659 |
+
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
| 660 |
+
border-radius: 0;
|
| 661 |
+
background: none;
|
| 662 |
+
}
|
| 663 |
+
.jp-in-body .code-accordion summary {
|
| 664 |
+
background: none;
|
| 665 |
+
padding: 9px 16px 9px 0;
|
| 666 |
+
}
|
| 667 |
+
.jp-in-body .code-accordion pre.hl {
|
| 668 |
+
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
| 669 |
+
}
|
| 670 |
+
.jp-meta {
|
| 671 |
+
padding: 5px 14px;
|
| 672 |
+
font-family: var(--mono);
|
| 673 |
+
font-size: 11.5px;
|
| 674 |
+
color: var(--muted);
|
| 675 |
+
background: #fbfbfc;
|
| 676 |
+
border-top: 1px solid var(--line);
|
| 677 |
+
}
|
| 678 |
+
.jp-out {
|
| 679 |
+
display: flex;
|
| 680 |
+
border-top: 1px solid var(--line);
|
| 681 |
+
background: var(--panel);
|
| 682 |
+
}
|
| 683 |
+
.jp-out .jp-gutter {
|
| 684 |
+
color: var(--accent-strong);
|
| 685 |
+
}
|
| 686 |
+
.jp-out-body {
|
| 687 |
+
flex: 1;
|
| 688 |
+
min-width: 0;
|
| 689 |
+
}
|
| 690 |
+
#page .jp-out-pre {
|
| 691 |
+
min-width: 0;
|
| 692 |
+
margin: 0;
|
| 693 |
+
border: none;
|
| 694 |
+
border-radius: 0;
|
| 695 |
+
background: none;
|
| 696 |
+
color: var(--ink);
|
| 697 |
+
font-family: var(--mono);
|
| 698 |
+
font-size: 13px;
|
| 699 |
+
line-height: 1.55;
|
| 700 |
+
padding: 12px 16px 12px 0;
|
| 701 |
+
white-space: pre;
|
| 702 |
+
overflow-x: auto;
|
| 703 |
+
overflow-y: auto;
|
| 704 |
+
max-height: 26em;
|
| 705 |
+
}
|
| 706 |
+
.jp-artifacts {
|
| 707 |
+
display: flex;
|
| 708 |
+
flex-direction: column;
|
| 709 |
+
}
|
| 710 |
+
.jp-out-body .jp-out-pre + .jp-artifacts {
|
| 711 |
+
border-top: 1px solid var(--line);
|
| 712 |
+
}
|
| 713 |
+
.out-artifact {
|
| 714 |
+
display: flex;
|
| 715 |
+
align-items: baseline;
|
| 716 |
+
gap: 8px;
|
| 717 |
+
padding: 9px 16px 9px 0;
|
| 718 |
+
text-decoration: none;
|
| 719 |
+
color: inherit;
|
| 720 |
+
}
|
| 721 |
+
.out-artifact + .out-artifact {
|
| 722 |
+
border-top: 1px solid var(--line);
|
| 723 |
+
}
|
| 724 |
+
a.out-artifact:hover .out-artifact-name {
|
| 725 |
+
color: var(--accent-strong);
|
| 726 |
+
}
|
| 727 |
+
.out-artifact-ico {
|
| 728 |
+
flex: 0 0 auto;
|
| 729 |
+
font-size: 13px;
|
| 730 |
+
}
|
| 731 |
+
.out-artifact-name {
|
| 732 |
+
font-family: var(--mono);
|
| 733 |
+
font-size: 12.5px;
|
| 734 |
+
font-weight: 600;
|
| 735 |
+
color: var(--ink);
|
| 736 |
+
overflow: hidden;
|
| 737 |
+
text-overflow: ellipsis;
|
| 738 |
+
white-space: nowrap;
|
| 739 |
+
}
|
| 740 |
+
.out-artifact-meta {
|
| 741 |
+
flex: 0 0 auto;
|
| 742 |
+
margin-left: auto;
|
| 743 |
+
padding-left: 12px;
|
| 744 |
+
font-size: 12px;
|
| 745 |
+
color: var(--muted);
|
| 746 |
+
white-space: nowrap;
|
| 747 |
+
}
|
| 748 |
+
.out-artifact-state.open {
|
| 749 |
+
color: var(--accent);
|
| 750 |
+
font-weight: 600;
|
| 751 |
+
}
|
| 752 |
+
.trackio-embed {
|
| 753 |
+
border: 1px solid var(--line);
|
| 754 |
+
border-radius: var(--radius);
|
| 755 |
+
overflow: hidden;
|
| 756 |
+
background: var(--panel);
|
| 757 |
+
}
|
| 758 |
+
.trackio-cell-meta {
|
| 759 |
+
display: flex;
|
| 760 |
+
gap: 6px;
|
| 761 |
+
flex-wrap: wrap;
|
| 762 |
+
justify-content: flex-end;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
/* ---- unfurl cards ---- */
|
| 766 |
+
.unfurl {
|
| 767 |
+
display: block;
|
| 768 |
+
border: 1px solid var(--line);
|
| 769 |
+
border-radius: var(--radius);
|
| 770 |
+
background: var(--panel);
|
| 771 |
+
margin: 12px 0;
|
| 772 |
+
overflow: hidden;
|
| 773 |
+
text-decoration: none;
|
| 774 |
+
color: inherit;
|
| 775 |
+
transition: border-color 0.14s, box-shadow 0.14s;
|
| 776 |
+
}
|
| 777 |
+
.unfurl:hover {
|
| 778 |
+
border-color: #cfcbe6;
|
| 779 |
+
box-shadow: 0 4px 18px rgba(30, 20, 80, 0.06);
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.unfurl-body {
|
| 783 |
+
padding: 13px 16px;
|
| 784 |
+
display: flex;
|
| 785 |
+
gap: 12px;
|
| 786 |
+
align-items: flex-start;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
.unfurl-ico {
|
| 790 |
+
font-size: 20px;
|
| 791 |
+
line-height: 1.3;
|
| 792 |
+
flex: 0 0 auto;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.unfurl-main {
|
| 796 |
+
min-width: 0;
|
| 797 |
+
flex: 1;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.unfurl-kind {
|
| 801 |
+
font-family: var(--mono);
|
| 802 |
+
font-size: 10.5px;
|
| 803 |
+
text-transform: uppercase;
|
| 804 |
+
letter-spacing: 0.08em;
|
| 805 |
+
color: var(--accent);
|
| 806 |
+
font-weight: 600;
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
.unfurl-title {
|
| 810 |
+
font-weight: 650;
|
| 811 |
+
font-size: 15px;
|
| 812 |
+
margin: 1px 0 2px;
|
| 813 |
+
white-space: nowrap;
|
| 814 |
+
overflow: hidden;
|
| 815 |
+
text-overflow: ellipsis;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
.unfurl-desc {
|
| 819 |
+
color: var(--muted);
|
| 820 |
+
font-size: 13.5px;
|
| 821 |
+
line-height: 1.45;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.unfurl-meta {
|
| 825 |
+
margin-top: 6px;
|
| 826 |
+
display: flex;
|
| 827 |
+
flex-wrap: wrap;
|
| 828 |
+
gap: 6px;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.chip {
|
| 832 |
+
font-size: 11.5px;
|
| 833 |
+
background: var(--code-bg);
|
| 834 |
+
border-radius: 999px;
|
| 835 |
+
padding: 2px 9px;
|
| 836 |
+
color: var(--muted);
|
| 837 |
+
font-family: var(--mono);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.unfurl-raw {
|
| 841 |
+
font-family: var(--mono);
|
| 842 |
+
font-size: 11px;
|
| 843 |
+
color: var(--muted);
|
| 844 |
+
border-top: 1px solid var(--line);
|
| 845 |
+
padding: 7px 16px;
|
| 846 |
+
white-space: nowrap;
|
| 847 |
+
overflow: hidden;
|
| 848 |
+
text-overflow: ellipsis;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
.unfurl.embed {
|
| 852 |
+
padding: 0;
|
| 853 |
+
overflow: hidden;
|
| 854 |
+
}
|
| 855 |
+
.embed-head {
|
| 856 |
+
display: flex;
|
| 857 |
+
align-items: center;
|
| 858 |
+
gap: 10px;
|
| 859 |
+
padding: 10px 14px;
|
| 860 |
+
border-bottom: 1px solid var(--line);
|
| 861 |
+
}
|
| 862 |
+
.embed-head .unfurl-kind {
|
| 863 |
+
flex: 0 0 auto;
|
| 864 |
+
}
|
| 865 |
+
.embed-title {
|
| 866 |
+
flex: 1;
|
| 867 |
+
min-width: 0;
|
| 868 |
+
font-weight: 650;
|
| 869 |
+
font-size: 14px;
|
| 870 |
+
color: var(--ink);
|
| 871 |
+
text-decoration: none;
|
| 872 |
+
white-space: nowrap;
|
| 873 |
+
overflow: hidden;
|
| 874 |
+
text-overflow: ellipsis;
|
| 875 |
+
}
|
| 876 |
+
.embed-title:hover {
|
| 877 |
+
color: var(--accent);
|
| 878 |
+
}
|
| 879 |
+
.embed-open {
|
| 880 |
+
flex: 0 0 auto;
|
| 881 |
+
font-family: var(--mono);
|
| 882 |
+
font-size: 12px;
|
| 883 |
+
color: var(--accent);
|
| 884 |
+
text-decoration: none;
|
| 885 |
+
}
|
| 886 |
+
.embed-frame {
|
| 887 |
+
display: block;
|
| 888 |
+
width: 100%;
|
| 889 |
+
height: 560px;
|
| 890 |
+
border: 0;
|
| 891 |
+
background: var(--code-bg);
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
.dashboard-shell {
|
| 895 |
+
display: block;
|
| 896 |
+
}
|
| 897 |
+
.dashboard-shell .dashboard-frame {
|
| 898 |
+
display: block;
|
| 899 |
+
width: 100%;
|
| 900 |
+
height: 900px;
|
| 901 |
+
border: 0;
|
| 902 |
+
background: var(--code-bg);
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.unfurl.image {
|
| 906 |
+
padding: 0;
|
| 907 |
+
}
|
| 908 |
+
.unfurl.image img {
|
| 909 |
+
display: block;
|
| 910 |
+
width: 100%;
|
| 911 |
+
height: auto;
|
| 912 |
+
max-height: 460px;
|
| 913 |
+
object-fit: contain;
|
| 914 |
+
background: var(--code-bg);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.artifact-chip {
|
| 918 |
+
border: 1px solid var(--line);
|
| 919 |
+
background: var(--panel);
|
| 920 |
+
border-radius: var(--radius);
|
| 921 |
+
padding: 10px 14px;
|
| 922 |
+
margin: 8px 0;
|
| 923 |
+
font-size: 14px;
|
| 924 |
+
}
|
| 925 |
+
.cell.dashboard .artifact-chip {
|
| 926 |
+
margin: 14px 18px 18px;
|
| 927 |
+
}
|
| 928 |
+
.artifact-chip code {
|
| 929 |
+
color: var(--accent);
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
/* ---- task board ---- */
|
| 933 |
+
.board-wrap {
|
| 934 |
+
overflow-x: auto;
|
| 935 |
+
border: 1px solid var(--line);
|
| 936 |
+
border-radius: var(--radius);
|
| 937 |
+
margin: 12px 0 20px;
|
| 938 |
+
background: var(--panel);
|
| 939 |
+
}
|
| 940 |
+
table.board {
|
| 941 |
+
border-collapse: collapse;
|
| 942 |
+
width: 100%;
|
| 943 |
+
font-size: 14px;
|
| 944 |
+
}
|
| 945 |
+
table.board th,
|
| 946 |
+
table.board td {
|
| 947 |
+
text-align: left;
|
| 948 |
+
padding: 9px 14px;
|
| 949 |
+
border-bottom: 1px solid var(--line);
|
| 950 |
+
vertical-align: top;
|
| 951 |
+
}
|
| 952 |
+
table.board thead th {
|
| 953 |
+
background: var(--accent-soft);
|
| 954 |
+
font-size: 12px;
|
| 955 |
+
text-transform: uppercase;
|
| 956 |
+
letter-spacing: 0.05em;
|
| 957 |
+
color: #9a4a12;
|
| 958 |
+
font-weight: 600;
|
| 959 |
+
border-bottom: 1px solid var(--line);
|
| 960 |
+
}
|
| 961 |
+
table.board tbody tr:last-child td {
|
| 962 |
+
border-bottom: none;
|
| 963 |
+
}
|
| 964 |
+
table.board .col-check {
|
| 965 |
+
text-align: center;
|
| 966 |
+
width: 92px;
|
| 967 |
+
white-space: nowrap;
|
| 968 |
+
}
|
| 969 |
+
table.board tr.section-row td {
|
| 970 |
+
background: var(--accent-soft);
|
| 971 |
+
text-align: center;
|
| 972 |
+
font-weight: 700;
|
| 973 |
+
font-size: 13px;
|
| 974 |
+
color: var(--accent-strong);
|
| 975 |
+
padding: 7px 14px;
|
| 976 |
+
letter-spacing: 0.02em;
|
| 977 |
+
}
|
| 978 |
+
.box {
|
| 979 |
+
display: inline-flex;
|
| 980 |
+
align-items: center;
|
| 981 |
+
justify-content: center;
|
| 982 |
+
width: 18px;
|
| 983 |
+
height: 18px;
|
| 984 |
+
border: 1.5px solid #cfcbe0;
|
| 985 |
+
border-radius: 5px;
|
| 986 |
+
font-size: 12px;
|
| 987 |
+
color: #fff;
|
| 988 |
+
line-height: 1;
|
| 989 |
+
}
|
| 990 |
+
.box.on {
|
| 991 |
+
background: var(--accent);
|
| 992 |
+
border-color: var(--accent);
|
| 993 |
+
}
|
| 994 |
+
.who-chip {
|
| 995 |
+
display: inline-block;
|
| 996 |
+
padding: 3px 12px;
|
| 997 |
+
border-radius: 999px;
|
| 998 |
+
font-size: 12.5px;
|
| 999 |
+
font-weight: 600;
|
| 1000 |
+
white-space: nowrap;
|
| 1001 |
+
}
|
| 1002 |
+
.who-chip.muted {
|
| 1003 |
+
background: var(--code-bg);
|
| 1004 |
+
color: var(--muted);
|
| 1005 |
+
font-weight: 500;
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
/* ---- status badges + clickable rows ---- */
|
| 1009 |
+
table.board .col-status {
|
| 1010 |
+
width: 130px;
|
| 1011 |
+
white-space: nowrap;
|
| 1012 |
+
}
|
| 1013 |
+
.badge {
|
| 1014 |
+
display: inline-block;
|
| 1015 |
+
padding: 3px 11px;
|
| 1016 |
+
border-radius: 999px;
|
| 1017 |
+
font-size: 12px;
|
| 1018 |
+
font-weight: 600;
|
| 1019 |
+
letter-spacing: 0.01em;
|
| 1020 |
+
}
|
| 1021 |
+
.badge.gray {
|
| 1022 |
+
background: var(--code-bg);
|
| 1023 |
+
color: var(--muted);
|
| 1024 |
+
}
|
| 1025 |
+
.badge.amber {
|
| 1026 |
+
background: var(--accent-soft);
|
| 1027 |
+
color: #b45309;
|
| 1028 |
+
}
|
| 1029 |
+
.badge.green {
|
| 1030 |
+
background: #e6f7ee;
|
| 1031 |
+
color: #1a8a55;
|
| 1032 |
+
}
|
| 1033 |
+
.badge.red {
|
| 1034 |
+
background: #fde8ec;
|
| 1035 |
+
color: #c62a4b;
|
| 1036 |
+
}
|
| 1037 |
+
table.board tr.linked-row {
|
| 1038 |
+
cursor: pointer;
|
| 1039 |
+
}
|
| 1040 |
+
table.board tr.linked-row:hover td {
|
| 1041 |
+
background: var(--accent-soft);
|
| 1042 |
+
}
|
| 1043 |
+
table.board tr.linked-row a {
|
| 1044 |
+
color: var(--ink);
|
| 1045 |
+
font-weight: 600;
|
| 1046 |
+
text-decoration: none;
|
| 1047 |
+
}
|
| 1048 |
+
table.board tr.linked-row:hover a {
|
| 1049 |
+
color: var(--accent-strong);
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
/* ---- agent read hint ---- */
|
| 1053 |
+
.agent-hint {
|
| 1054 |
+
display: flex;
|
| 1055 |
+
align-items: center;
|
| 1056 |
+
flex-wrap: wrap;
|
| 1057 |
+
gap: 8px;
|
| 1058 |
+
margin: 4px 0 22px;
|
| 1059 |
+
font-size: 12.5px;
|
| 1060 |
+
color: var(--muted);
|
| 1061 |
+
}
|
| 1062 |
+
#page .agent-hint code {
|
| 1063 |
+
background: var(--code-bg);
|
| 1064 |
+
padding: 2px 9px;
|
| 1065 |
+
border-radius: 6px;
|
| 1066 |
+
font-family: var(--mono);
|
| 1067 |
+
font-size: 12px;
|
| 1068 |
+
font-weight: 500;
|
| 1069 |
+
color: var(--ink);
|
| 1070 |
+
}
|
| 1071 |
+
.agent-hint .copy {
|
| 1072 |
+
flex: 0 0 auto;
|
| 1073 |
+
background: none;
|
| 1074 |
+
color: var(--muted);
|
| 1075 |
+
border: 1px solid var(--line);
|
| 1076 |
+
border-radius: 6px;
|
| 1077 |
+
width: 22px;
|
| 1078 |
+
height: 22px;
|
| 1079 |
+
font-size: 11px;
|
| 1080 |
+
line-height: 1;
|
| 1081 |
+
cursor: pointer;
|
| 1082 |
+
transition: color 0.12s, border-color 0.12s;
|
| 1083 |
+
}
|
| 1084 |
+
.agent-hint .copy:hover {
|
| 1085 |
+
color: var(--accent-strong);
|
| 1086 |
+
border-color: var(--accent);
|
| 1087 |
+
}
|
| 1088 |
+
.agent-hint .copy.copied {
|
| 1089 |
+
color: #1a8a55;
|
| 1090 |
+
border-color: #1a8a55;
|
| 1091 |
+
}
|
| 1092 |
+
.agent-hint-note {
|
| 1093 |
+
margin-left: auto;
|
| 1094 |
+
font-size: 12px;
|
| 1095 |
+
color: var(--muted);
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
/* ---- logbook summary stats ---- */
|
| 1099 |
+
.logbook-stats {
|
| 1100 |
+
display: flex;
|
| 1101 |
+
flex-wrap: wrap;
|
| 1102 |
+
gap: 12px;
|
| 1103 |
+
margin: 0 0 28px;
|
| 1104 |
+
}
|
| 1105 |
+
.stat-tile {
|
| 1106 |
+
position: relative;
|
| 1107 |
+
display: inline-flex;
|
| 1108 |
+
align-items: center;
|
| 1109 |
+
gap: 11px;
|
| 1110 |
+
border: 1px solid var(--line);
|
| 1111 |
+
background: var(--panel);
|
| 1112 |
+
border-radius: var(--radius);
|
| 1113 |
+
padding: 12px 23px;
|
| 1114 |
+
font: inherit;
|
| 1115 |
+
text-align: left;
|
| 1116 |
+
cursor: pointer;
|
| 1117 |
+
transition: border-color 0.12s, box-shadow 0.12s;
|
| 1118 |
+
}
|
| 1119 |
+
.stat-tile:hover:not([disabled]) {
|
| 1120 |
+
border-color: rgba(249, 115, 22, 0.45);
|
| 1121 |
+
box-shadow: 0 3px 12px rgba(31, 41, 55, 0.06);
|
| 1122 |
+
}
|
| 1123 |
+
.stat-tile:focus-visible {
|
| 1124 |
+
outline: 2px solid var(--accent);
|
| 1125 |
+
outline-offset: 2px;
|
| 1126 |
+
}
|
| 1127 |
+
.stat-tile[disabled] {
|
| 1128 |
+
cursor: default;
|
| 1129 |
+
opacity: 0.7;
|
| 1130 |
+
}
|
| 1131 |
+
.stat-tile.open {
|
| 1132 |
+
border-color: rgba(249, 115, 22, 0.6);
|
| 1133 |
+
box-shadow: 0 3px 12px rgba(31, 41, 55, 0.08);
|
| 1134 |
+
}
|
| 1135 |
+
.stat-icon {
|
| 1136 |
+
width: 24px;
|
| 1137 |
+
height: 24px;
|
| 1138 |
+
flex: 0 0 24px;
|
| 1139 |
+
object-fit: contain;
|
| 1140 |
+
align-self: center;
|
| 1141 |
+
}
|
| 1142 |
+
.stat-text {
|
| 1143 |
+
display: flex;
|
| 1144 |
+
align-items: baseline;
|
| 1145 |
+
gap: 8px;
|
| 1146 |
+
white-space: nowrap;
|
| 1147 |
+
line-height: 1;
|
| 1148 |
+
}
|
| 1149 |
+
.stat-num {
|
| 1150 |
+
font-family: var(--mono);
|
| 1151 |
+
font-size: 20px;
|
| 1152 |
+
font-weight: 600;
|
| 1153 |
+
line-height: 1;
|
| 1154 |
+
color: var(--accent-strong);
|
| 1155 |
+
}
|
| 1156 |
+
.stat-label {
|
| 1157 |
+
font-size: 15px;
|
| 1158 |
+
line-height: 1;
|
| 1159 |
+
color: var(--muted);
|
| 1160 |
+
}
|
| 1161 |
+
.stat-caret {
|
| 1162 |
+
margin-left: 2px;
|
| 1163 |
+
font-size: 10px;
|
| 1164 |
+
color: var(--muted);
|
| 1165 |
+
align-self: center;
|
| 1166 |
+
transition: transform 0.12s;
|
| 1167 |
+
}
|
| 1168 |
+
.stat-tile.open .stat-caret {
|
| 1169 |
+
transform: rotate(180deg);
|
| 1170 |
+
}
|
| 1171 |
+
.stat-popover {
|
| 1172 |
+
position: absolute;
|
| 1173 |
+
top: 100%;
|
| 1174 |
+
left: 0;
|
| 1175 |
+
margin-top: 6px;
|
| 1176 |
+
min-width: 300px;
|
| 1177 |
+
max-width: min(460px, 92vw);
|
| 1178 |
+
max-height: 340px;
|
| 1179 |
+
overflow-y: auto;
|
| 1180 |
+
z-index: 20;
|
| 1181 |
+
background: var(--panel);
|
| 1182 |
+
border: 1px solid var(--line);
|
| 1183 |
+
border-radius: var(--radius);
|
| 1184 |
+
box-shadow: 0 8px 28px rgba(31, 41, 55, 0.12);
|
| 1185 |
+
padding: 6px;
|
| 1186 |
+
}
|
| 1187 |
+
.stat-popover[hidden] {
|
| 1188 |
+
display: none;
|
| 1189 |
+
}
|
| 1190 |
+
.stat-pop-head {
|
| 1191 |
+
padding: 6px 10px 8px;
|
| 1192 |
+
font-size: 11.5px;
|
| 1193 |
+
font-weight: 700;
|
| 1194 |
+
letter-spacing: 0.03em;
|
| 1195 |
+
text-transform: uppercase;
|
| 1196 |
+
color: var(--muted);
|
| 1197 |
+
}
|
| 1198 |
+
.stat-row {
|
| 1199 |
+
display: flex;
|
| 1200 |
+
align-items: flex-start;
|
| 1201 |
+
gap: 10px;
|
| 1202 |
+
padding: 9px 11px;
|
| 1203 |
+
border-radius: 9px;
|
| 1204 |
+
border: 1px solid transparent;
|
| 1205 |
+
text-decoration: none;
|
| 1206 |
+
color: inherit;
|
| 1207 |
+
cursor: pointer;
|
| 1208 |
+
}
|
| 1209 |
+
.stat-row:hover {
|
| 1210 |
+
border-color: rgba(249, 115, 22, 0.4);
|
| 1211 |
+
background: var(--accent-soft);
|
| 1212 |
+
}
|
| 1213 |
+
.stat-row-ico {
|
| 1214 |
+
font-size: 15px;
|
| 1215 |
+
line-height: 1.3;
|
| 1216 |
+
flex: 0 0 auto;
|
| 1217 |
+
}
|
| 1218 |
+
.stat-row-main {
|
| 1219 |
+
min-width: 0;
|
| 1220 |
+
flex: 1;
|
| 1221 |
+
}
|
| 1222 |
+
.stat-row-title {
|
| 1223 |
+
font-family: var(--mono);
|
| 1224 |
+
font-size: 12.5px;
|
| 1225 |
+
font-weight: 600;
|
| 1226 |
+
color: var(--ink);
|
| 1227 |
+
overflow: hidden;
|
| 1228 |
+
text-overflow: ellipsis;
|
| 1229 |
+
white-space: nowrap;
|
| 1230 |
+
}
|
| 1231 |
+
.stat-row-meta {
|
| 1232 |
+
margin-top: 2px;
|
| 1233 |
+
font-size: 12px;
|
| 1234 |
+
color: var(--muted);
|
| 1235 |
+
}
|
| 1236 |
+
.stat-row-state.open {
|
| 1237 |
+
color: var(--accent);
|
| 1238 |
+
font-weight: 600;
|
| 1239 |
+
border-radius: 5px;
|
| 1240 |
+
padding: 1px 5px;
|
| 1241 |
+
margin: -1px -2px;
|
| 1242 |
+
}
|
| 1243 |
+
.stat-row-state.open:hover {
|
| 1244 |
+
background: rgba(249, 115, 22, 0.14);
|
| 1245 |
+
text-decoration: underline;
|
| 1246 |
+
}
|
| 1247 |
+
.art-ico {
|
| 1248 |
+
width: 1em;
|
| 1249 |
+
height: 1em;
|
| 1250 |
+
object-fit: contain;
|
| 1251 |
+
vertical-align: -0.15em;
|
| 1252 |
+
}
|
| 1253 |
+
|
| 1254 |
+
/* ---- scroll-to-resource highlight ---- */
|
| 1255 |
+
.res-flash {
|
| 1256 |
+
animation: res-flash 1.5s ease;
|
| 1257 |
+
border-radius: 8px;
|
| 1258 |
+
}
|
| 1259 |
+
@keyframes res-flash {
|
| 1260 |
+
0%,
|
| 1261 |
+
25% {
|
| 1262 |
+
box-shadow: 0 0 0 3px var(--accent);
|
| 1263 |
+
}
|
| 1264 |
+
100% {
|
| 1265 |
+
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0);
|
| 1266 |
+
}
|
| 1267 |
+
}
|
| 1268 |
+
|
| 1269 |
+
/* ---- inline resource chips ---- */
|
| 1270 |
+
#page .res-chip {
|
| 1271 |
+
display: inline-flex;
|
| 1272 |
+
align-items: center;
|
| 1273 |
+
gap: 5px;
|
| 1274 |
+
max-width: 100%;
|
| 1275 |
+
padding: 0 9px 0 6px;
|
| 1276 |
+
margin: 0 1px;
|
| 1277 |
+
border: 1px solid var(--line);
|
| 1278 |
+
border-radius: 999px;
|
| 1279 |
+
background: var(--panel);
|
| 1280 |
+
font-family: var(--mono);
|
| 1281 |
+
font-size: 0.78em;
|
| 1282 |
+
font-weight: 600;
|
| 1283 |
+
color: var(--ink);
|
| 1284 |
+
text-decoration: none;
|
| 1285 |
+
white-space: nowrap;
|
| 1286 |
+
overflow: hidden;
|
| 1287 |
+
text-overflow: ellipsis;
|
| 1288 |
+
vertical-align: middle;
|
| 1289 |
+
line-height: 1.65;
|
| 1290 |
+
transform: translateY(-0.08em);
|
| 1291 |
+
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
| 1292 |
+
}
|
| 1293 |
+
.res-chip-ico {
|
| 1294 |
+
font-size: 1.05em;
|
| 1295 |
+
line-height: 1;
|
| 1296 |
+
}
|
| 1297 |
+
#page .res-chip:hover,
|
| 1298 |
+
#page .res-chip.res-hl {
|
| 1299 |
+
border-color: var(--accent);
|
| 1300 |
+
background: var(--accent-soft);
|
| 1301 |
+
color: var(--accent-strong);
|
| 1302 |
+
}
|
| 1303 |
+
#page a.res-link.res-hl {
|
| 1304 |
+
background: var(--accent-soft);
|
| 1305 |
+
border-radius: 4px;
|
| 1306 |
+
}
|
| 1307 |
+
.rail-item.res-hl {
|
| 1308 |
+
border-color: var(--accent);
|
| 1309 |
+
background: var(--accent-soft);
|
| 1310 |
+
box-shadow: 0 3px 12px rgba(249, 115, 22, 0.14);
|
| 1311 |
+
}
|
| 1312 |
+
.rail-item.res-hl .rail-title {
|
| 1313 |
+
color: var(--accent-strong);
|
| 1314 |
+
}
|
| 1315 |
+
.rail-item.rail-local {
|
| 1316 |
+
cursor: default;
|
| 1317 |
+
}
|
| 1318 |
+
.artifact-chip.res-hl {
|
| 1319 |
+
border-color: var(--accent);
|
| 1320 |
+
background: var(--accent-soft);
|
| 1321 |
+
}
|
| 1322 |
+
|
| 1323 |
+
/* ---- contextual resources rail ---- */
|
| 1324 |
+
.context-rail {
|
| 1325 |
+
position: relative;
|
| 1326 |
+
width: 248px;
|
| 1327 |
+
}
|
| 1328 |
+
.context-rail[hidden] {
|
| 1329 |
+
display: none;
|
| 1330 |
+
}
|
| 1331 |
+
.rail-kind {
|
| 1332 |
+
display: flex;
|
| 1333 |
+
align-items: center;
|
| 1334 |
+
gap: 5px;
|
| 1335 |
+
font-family: var(--mono);
|
| 1336 |
+
font-size: 10px;
|
| 1337 |
+
text-transform: uppercase;
|
| 1338 |
+
letter-spacing: 0.08em;
|
| 1339 |
+
font-weight: 600;
|
| 1340 |
+
color: var(--accent);
|
| 1341 |
+
margin-bottom: 4px;
|
| 1342 |
+
}
|
| 1343 |
+
.rail-item {
|
| 1344 |
+
position: absolute;
|
| 1345 |
+
left: 0;
|
| 1346 |
+
right: 0;
|
| 1347 |
+
display: block;
|
| 1348 |
+
border: 1px solid var(--line);
|
| 1349 |
+
border-radius: 10px;
|
| 1350 |
+
background: var(--panel);
|
| 1351 |
+
padding: 9px 12px;
|
| 1352 |
+
margin-bottom: 8px;
|
| 1353 |
+
text-decoration: none;
|
| 1354 |
+
color: inherit;
|
| 1355 |
+
transition: border-color 0.14s, box-shadow 0.14s;
|
| 1356 |
+
}
|
| 1357 |
+
.rail-item:hover {
|
| 1358 |
+
border-color: rgba(249, 115, 22, 0.45);
|
| 1359 |
+
box-shadow: 0 3px 12px rgba(31, 41, 55, 0.06);
|
| 1360 |
+
}
|
| 1361 |
+
.rail-title {
|
| 1362 |
+
font-family: var(--mono);
|
| 1363 |
+
font-size: 12.5px;
|
| 1364 |
+
font-weight: 600;
|
| 1365 |
+
color: var(--ink);
|
| 1366 |
+
overflow-wrap: anywhere;
|
| 1367 |
+
line-height: 1.4;
|
| 1368 |
+
}
|
| 1369 |
+
.rail-item:hover .rail-title {
|
| 1370 |
+
color: var(--accent-strong);
|
| 1371 |
+
}
|
| 1372 |
+
.rail-meta {
|
| 1373 |
+
font-size: 11.5px;
|
| 1374 |
+
color: var(--muted);
|
| 1375 |
+
margin-top: 2px;
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
@media (max-width: 1400px) {
|
| 1379 |
+
.page-layout {
|
| 1380 |
+
display: block;
|
| 1381 |
+
}
|
| 1382 |
+
.context-rail {
|
| 1383 |
+
width: 100%;
|
| 1384 |
+
margin-top: 28px;
|
| 1385 |
+
position: static;
|
| 1386 |
+
min-height: 0 !important;
|
| 1387 |
+
display: grid;
|
| 1388 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 1389 |
+
gap: 10px;
|
| 1390 |
+
}
|
| 1391 |
+
.context-rail[hidden] {
|
| 1392 |
+
display: none;
|
| 1393 |
+
}
|
| 1394 |
+
.context-rail .rail-item {
|
| 1395 |
+
position: static;
|
| 1396 |
+
margin-bottom: 0;
|
| 1397 |
+
}
|
| 1398 |
+
}
|
| 1399 |
+
|
| 1400 |
+
/* ---- connect footer + modal ---- */
|
| 1401 |
+
#sidebar-foot {
|
| 1402 |
+
margin-top: auto;
|
| 1403 |
+
padding-top: 14px;
|
| 1404 |
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
#connect-btn {
|
| 1408 |
+
width: 100%;
|
| 1409 |
+
display: flex;
|
| 1410 |
+
align-items: center;
|
| 1411 |
+
gap: 8px;
|
| 1412 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1413 |
+
color: #c3c4cb;
|
| 1414 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 1415 |
+
border-radius: 9px;
|
| 1416 |
+
padding: 9px 12px;
|
| 1417 |
+
font-size: 13.5px;
|
| 1418 |
+
font-family: var(--sans);
|
| 1419 |
+
cursor: pointer;
|
| 1420 |
+
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
| 1421 |
+
}
|
| 1422 |
+
#connect-btn:hover {
|
| 1423 |
+
background: rgba(249, 115, 22, 0.14);
|
| 1424 |
+
border-color: rgba(249, 115, 22, 0.4);
|
| 1425 |
+
color: #fdba74;
|
| 1426 |
+
}
|
| 1427 |
+
#connect-btn .ico {
|
| 1428 |
+
font-size: 15px;
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
#modal[hidden] {
|
| 1432 |
+
display: none;
|
| 1433 |
+
}
|
| 1434 |
+
#modal {
|
| 1435 |
+
position: fixed;
|
| 1436 |
+
inset: 0;
|
| 1437 |
+
z-index: 100;
|
| 1438 |
+
display: flex;
|
| 1439 |
+
align-items: center;
|
| 1440 |
+
justify-content: center;
|
| 1441 |
+
padding: 24px;
|
| 1442 |
+
}
|
| 1443 |
+
.modal-backdrop {
|
| 1444 |
+
position: absolute;
|
| 1445 |
+
inset: 0;
|
| 1446 |
+
background: rgba(20, 18, 30, 0.5);
|
| 1447 |
+
backdrop-filter: blur(2px);
|
| 1448 |
+
}
|
| 1449 |
+
.modal-card {
|
| 1450 |
+
position: relative;
|
| 1451 |
+
background: var(--panel);
|
| 1452 |
+
border-radius: 16px;
|
| 1453 |
+
width: 100%;
|
| 1454 |
+
max-width: 620px;
|
| 1455 |
+
max-height: 85vh;
|
| 1456 |
+
overflow-y: auto;
|
| 1457 |
+
box-shadow: 0 24px 70px rgba(20, 15, 50, 0.28);
|
| 1458 |
+
}
|
| 1459 |
+
.modal-head {
|
| 1460 |
+
display: flex;
|
| 1461 |
+
align-items: center;
|
| 1462 |
+
justify-content: space-between;
|
| 1463 |
+
gap: 12px;
|
| 1464 |
+
padding: 18px 22px;
|
| 1465 |
+
border-bottom: 1px solid var(--line);
|
| 1466 |
+
position: sticky;
|
| 1467 |
+
top: 0;
|
| 1468 |
+
background: var(--panel);
|
| 1469 |
+
}
|
| 1470 |
+
.modal-title {
|
| 1471 |
+
display: flex;
|
| 1472 |
+
align-items: center;
|
| 1473 |
+
gap: 10px;
|
| 1474 |
+
font-family: var(--serif);
|
| 1475 |
+
font-size: 21px;
|
| 1476 |
+
letter-spacing: -0.01em;
|
| 1477 |
+
}
|
| 1478 |
+
.modal-logo {
|
| 1479 |
+
width: 26px;
|
| 1480 |
+
height: 26px;
|
| 1481 |
+
object-fit: contain;
|
| 1482 |
+
}
|
| 1483 |
+
.modal-actions {
|
| 1484 |
+
display: flex;
|
| 1485 |
+
align-items: center;
|
| 1486 |
+
gap: 8px;
|
| 1487 |
+
}
|
| 1488 |
+
.btn {
|
| 1489 |
+
font-family: var(--sans);
|
| 1490 |
+
font-size: 13.5px;
|
| 1491 |
+
font-weight: 600;
|
| 1492 |
+
border: 1px solid var(--line);
|
| 1493 |
+
background: var(--panel);
|
| 1494 |
+
color: var(--ink);
|
| 1495 |
+
border-radius: 9px;
|
| 1496 |
+
padding: 8px 13px;
|
| 1497 |
+
cursor: pointer;
|
| 1498 |
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
| 1499 |
+
}
|
| 1500 |
+
.btn:hover {
|
| 1501 |
+
border-color: var(--accent);
|
| 1502 |
+
color: var(--accent-strong);
|
| 1503 |
+
}
|
| 1504 |
+
.btn.copied {
|
| 1505 |
+
border-color: #1a8a55;
|
| 1506 |
+
color: #1a8a55;
|
| 1507 |
+
}
|
| 1508 |
+
.btn.icon {
|
| 1509 |
+
font-size: 18px;
|
| 1510 |
+
line-height: 1;
|
| 1511 |
+
padding: 6px 11px;
|
| 1512 |
+
font-weight: 400;
|
| 1513 |
+
}
|
| 1514 |
+
.modal-body {
|
| 1515 |
+
padding: 20px 22px 26px;
|
| 1516 |
+
}
|
| 1517 |
+
.modal-intro {
|
| 1518 |
+
margin: 0 0 20px;
|
| 1519 |
+
color: var(--muted);
|
| 1520 |
+
line-height: 1.55;
|
| 1521 |
+
}
|
| 1522 |
+
#connect-steps {
|
| 1523 |
+
list-style: none;
|
| 1524 |
+
margin: 0;
|
| 1525 |
+
padding: 0;
|
| 1526 |
+
}
|
| 1527 |
+
#connect-steps li {
|
| 1528 |
+
margin-bottom: 18px;
|
| 1529 |
+
}
|
| 1530 |
+
.step-title {
|
| 1531 |
+
font-weight: 600;
|
| 1532 |
+
font-size: 14.5px;
|
| 1533 |
+
margin-bottom: 8px;
|
| 1534 |
+
}
|
| 1535 |
+
.codeblock {
|
| 1536 |
+
display: flex;
|
| 1537 |
+
align-items: center;
|
| 1538 |
+
gap: 8px;
|
| 1539 |
+
background: #17181c;
|
| 1540 |
+
border-radius: 10px;
|
| 1541 |
+
padding: 11px 12px 11px 15px;
|
| 1542 |
+
}
|
| 1543 |
+
.codeblock code {
|
| 1544 |
+
flex: 1;
|
| 1545 |
+
min-width: 0;
|
| 1546 |
+
overflow-x: auto;
|
| 1547 |
+
white-space: nowrap;
|
| 1548 |
+
font-family: var(--mono);
|
| 1549 |
+
font-size: 13px;
|
| 1550 |
+
color: #f0efff;
|
| 1551 |
+
background: none;
|
| 1552 |
+
padding: 0;
|
| 1553 |
+
}
|
| 1554 |
+
.codeblock .copy {
|
| 1555 |
+
flex: 0 0 auto;
|
| 1556 |
+
background: rgba(255, 255, 255, 0.08);
|
| 1557 |
+
color: #c3c4cb;
|
| 1558 |
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
| 1559 |
+
border-radius: 7px;
|
| 1560 |
+
width: 30px;
|
| 1561 |
+
height: 30px;
|
| 1562 |
+
font-size: 14px;
|
| 1563 |
+
cursor: pointer;
|
| 1564 |
+
transition: background 0.12s, color 0.12s;
|
| 1565 |
+
}
|
| 1566 |
+
.codeblock .copy:hover {
|
| 1567 |
+
background: rgba(249, 115, 22, 0.2);
|
| 1568 |
+
color: #fdba74;
|
| 1569 |
+
}
|
| 1570 |
+
.codeblock .copy.copied {
|
| 1571 |
+
color: #52d08a;
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
@media (max-width: 720px) {
|
| 1575 |
+
#app {
|
| 1576 |
+
flex-direction: column;
|
| 1577 |
+
}
|
| 1578 |
+
#sidebar {
|
| 1579 |
+
width: 100%;
|
| 1580 |
+
flex: none;
|
| 1581 |
+
height: auto;
|
| 1582 |
+
position: static;
|
| 1583 |
+
}
|
| 1584 |
+
#content {
|
| 1585 |
+
display: block;
|
| 1586 |
+
width: 100%;
|
| 1587 |
+
padding: 28px 20px 80px;
|
| 1588 |
+
overflow-x: hidden;
|
| 1589 |
+
}
|
| 1590 |
+
#page {
|
| 1591 |
+
width: 100%;
|
| 1592 |
+
max-width: 100%;
|
| 1593 |
+
}
|
| 1594 |
+
#page h1 {
|
| 1595 |
+
font-size: 30px;
|
| 1596 |
+
}
|
| 1597 |
+
.cell-head {
|
| 1598 |
+
align-items: flex-start;
|
| 1599 |
+
flex-direction: column;
|
| 1600 |
+
gap: 4px;
|
| 1601 |
+
}
|
| 1602 |
+
}
|
trackio/frontend_templates/logbook/logbook.js
ADDED
|
@@ -0,0 +1,2275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
"use strict";
|
| 3 |
+
|
| 4 |
+
let MANIFEST = null;
|
| 5 |
+
const PAGE_CACHE = {};
|
| 6 |
+
const UNFURL_CACHE = {};
|
| 7 |
+
const LIVE_RELOAD_MS = 1500;
|
| 8 |
+
const FIGURE_FRAME_WINDOWS = new Set();
|
| 9 |
+
let FIGURE_NAVIGATION_READY = false;
|
| 10 |
+
|
| 11 |
+
function esc(s) {
|
| 12 |
+
return String(s)
|
| 13 |
+
.replace(/&/g, "&")
|
| 14 |
+
.replace(/</g, "<")
|
| 15 |
+
.replace(/>/g, ">")
|
| 16 |
+
.replace(/"/g, """)
|
| 17 |
+
.replace(/'/g, "'");
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function flattenTree(node, depth, acc) {
|
| 21 |
+
acc.push({ node: node, depth: depth });
|
| 22 |
+
(node.children || []).forEach((c) => flattenTree(c, depth + 1, acc));
|
| 23 |
+
return acc;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function findNode(node, slug) {
|
| 27 |
+
if (node.slug === slug) return node;
|
| 28 |
+
for (const c of node.children || []) {
|
| 29 |
+
const hit = findNode(c, slug);
|
| 30 |
+
if (hit) return hit;
|
| 31 |
+
}
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/* -------------------- minimal markdown -------------------- */
|
| 36 |
+
|
| 37 |
+
function inline(text) {
|
| 38 |
+
let t = esc(text);
|
| 39 |
+
t = t.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
|
| 40 |
+
t = t.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
|
| 41 |
+
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt, url) => {
|
| 42 |
+
const safe = esc(url);
|
| 43 |
+
const attrs = /^https?:/.test(url) ? ' target="_blank" rel="noopener"' : "";
|
| 44 |
+
const item = /^https?:/.test(url) ? classifyResource(url) : null;
|
| 45 |
+
const data = item
|
| 46 |
+
? ` class="res-link" data-res-url="${esc(item.url)}"`
|
| 47 |
+
: "";
|
| 48 |
+
return `<a href="${safe}"${attrs}${data}>${txt}</a>`;
|
| 49 |
+
});
|
| 50 |
+
t = t.replace(/(^|[\s(])(https?:\/\/[^\s<>)"'`]+)/g, (m, pre, url) => {
|
| 51 |
+
let rest = "";
|
| 52 |
+
const cut = url.search(/"|'|<|>/);
|
| 53 |
+
if (cut !== -1) {
|
| 54 |
+
rest = url.slice(cut);
|
| 55 |
+
url = url.slice(0, cut);
|
| 56 |
+
}
|
| 57 |
+
const trailing = (url.match(/[.,;:!?`]+$/) || [""])[0];
|
| 58 |
+
const clean = trailing ? url.slice(0, -trailing.length) : url;
|
| 59 |
+
if (!clean) return m;
|
| 60 |
+
const item = classifyResource(clean);
|
| 61 |
+
if (item) return `${pre}${resChipHtml(item)}${trailing}${rest}`;
|
| 62 |
+
return `${pre}<a href="${clean}" target="_blank" rel="noopener">${clean}</a>${trailing}${rest}`;
|
| 63 |
+
});
|
| 64 |
+
return t;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function resChipHtml(item) {
|
| 68 |
+
return (
|
| 69 |
+
`<a class="res-chip" href="${esc(item.url)}" target="_blank" ` +
|
| 70 |
+
`rel="noopener" data-res-url="${esc(item.url)}">` +
|
| 71 |
+
`<span class="res-chip-ico">${RESOURCE_ICONS[item.kind]}</span>` +
|
| 72 |
+
`${esc(item.id)}</a>`
|
| 73 |
+
);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const URL_ONLY = /^(https?:\/\/[^\s]+)$/;
|
| 77 |
+
const DETECTED_URL =
|
| 78 |
+
/(https?:\/\/[^\s<>)\]"'`]+|trackio-local-dashboard:\/\/[^\s<>)\]"'`]+|trackio-artifact:\/\/[^\s<>)\]"'`]+|trackio-local-path:\/\/[^\s<>)\]"'`]+)/g;
|
| 79 |
+
|
| 80 |
+
function renderMarkdown(md, container) {
|
| 81 |
+
const cellRe = /(^|\n)---\n<!-- trackio-cell\n([\s\S]*?)\n-->\n([\s\S]*?)(?=\n---\n<!-- trackio-cell\n|\s*$)/g;
|
| 82 |
+
const tokens = [];
|
| 83 |
+
let pos = 0;
|
| 84 |
+
let found = false;
|
| 85 |
+
let match;
|
| 86 |
+
while ((match = cellRe.exec(md))) {
|
| 87 |
+
found = true;
|
| 88 |
+
tokens.push({
|
| 89 |
+
kind: "md",
|
| 90 |
+
text: md.slice(pos, match.index + match[1].length),
|
| 91 |
+
});
|
| 92 |
+
tokens.push({
|
| 93 |
+
kind: "cell",
|
| 94 |
+
meta: parseCellMeta(match[2]),
|
| 95 |
+
body: match[3],
|
| 96 |
+
});
|
| 97 |
+
pos = match.index + match[0].length;
|
| 98 |
+
}
|
| 99 |
+
tokens.push({ kind: "md", text: found ? md.slice(pos) : md });
|
| 100 |
+
|
| 101 |
+
for (let i = 0; i < tokens.length; i++) {
|
| 102 |
+
const t = tokens[i];
|
| 103 |
+
if (t.kind === "md") {
|
| 104 |
+
renderMarkdownPlain(t.text, container);
|
| 105 |
+
continue;
|
| 106 |
+
}
|
| 107 |
+
if (t.consumed) continue;
|
| 108 |
+
if (t.meta.type === "code") {
|
| 109 |
+
const arts = [];
|
| 110 |
+
for (let j = i + 1; j < tokens.length; j++) {
|
| 111 |
+
const n = tokens[j];
|
| 112 |
+
if (n.kind === "md") {
|
| 113 |
+
if (n.text.trim() === "") continue;
|
| 114 |
+
break;
|
| 115 |
+
}
|
| 116 |
+
if (n.meta.type === "artifact") {
|
| 117 |
+
arts.push(n);
|
| 118 |
+
n.consumed = true;
|
| 119 |
+
continue;
|
| 120 |
+
}
|
| 121 |
+
break;
|
| 122 |
+
}
|
| 123 |
+
renderCell(t.meta, t.body, container, arts);
|
| 124 |
+
} else {
|
| 125 |
+
renderCell(t.meta, t.body, container);
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function parseCellMeta(raw) {
|
| 131 |
+
try {
|
| 132 |
+
return JSON.parse(raw);
|
| 133 |
+
} catch (e) {
|
| 134 |
+
return { type: "markdown", title: "Note" };
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function renderMarkdownPlain(md, container) {
|
| 139 |
+
const lines = md.replace(/<!--[\s\S]*?-->/g, "").split("\n");
|
| 140 |
+
let i = 0;
|
| 141 |
+
let para = [];
|
| 142 |
+
|
| 143 |
+
function flushPara() {
|
| 144 |
+
if (!para.length) return;
|
| 145 |
+
const joined = para.join(" ").trim();
|
| 146 |
+
para = [];
|
| 147 |
+
if (!joined) return;
|
| 148 |
+
if (/^trackio-artifact:\/\/\S+$/.test(joined)) return;
|
| 149 |
+
if (/^trackio-local-path:\/\/\S+$/.test(joined)) return;
|
| 150 |
+
if (joined.indexOf("📦 Artifact") !== -1) {
|
| 151 |
+
const div = document.createElement("div");
|
| 152 |
+
div.className = "artifact-chip";
|
| 153 |
+
div.innerHTML = ARTIFACT_ICON_IMG + inline(joined.replace(/📦\s*/, ""));
|
| 154 |
+
container.appendChild(div);
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
if (URL_ONLY.test(joined) || IMG_PATH.test(joined)) {
|
| 158 |
+
const el = renderStandaloneUrl(joined);
|
| 159 |
+
if (el) container.appendChild(el);
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
const p = document.createElement("p");
|
| 163 |
+
p.innerHTML = inline(joined);
|
| 164 |
+
container.appendChild(p);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
while (i < lines.length) {
|
| 168 |
+
const line = lines[i];
|
| 169 |
+
const trimmed = line.trim();
|
| 170 |
+
|
| 171 |
+
if (trimmed === "") {
|
| 172 |
+
flushPara();
|
| 173 |
+
i++;
|
| 174 |
+
continue;
|
| 175 |
+
}
|
| 176 |
+
const fence = trimmed.match(/^(`{3,}|~{3,})(.*)$/);
|
| 177 |
+
if (fence) {
|
| 178 |
+
flushPara();
|
| 179 |
+
const marker = fence[1][0];
|
| 180 |
+
const closeRe = new RegExp("^" + marker + "{" + fence[1].length + ",}\\s*$");
|
| 181 |
+
const info = fence[2].trim();
|
| 182 |
+
const buf = [];
|
| 183 |
+
i++;
|
| 184 |
+
while (i < lines.length && !closeRe.test(lines[i].trim())) {
|
| 185 |
+
buf.push(lines[i]);
|
| 186 |
+
i++;
|
| 187 |
+
}
|
| 188 |
+
i++;
|
| 189 |
+
const lang = (info.split(/\s+/)[0] || "").toLowerCase();
|
| 190 |
+
const tm = info.match(/title=(\S+)/);
|
| 191 |
+
container.appendChild(
|
| 192 |
+
renderCode(buf.join("\n"), lang, tm ? tm[1] : null)
|
| 193 |
+
);
|
| 194 |
+
continue;
|
| 195 |
+
}
|
| 196 |
+
if (trimmed === "---") {
|
| 197 |
+
flushPara();
|
| 198 |
+
container.appendChild(document.createElement("hr"));
|
| 199 |
+
i++;
|
| 200 |
+
continue;
|
| 201 |
+
}
|
| 202 |
+
const h = trimmed.match(/^(#{1,4})\s+(.*)$/);
|
| 203 |
+
if (h) {
|
| 204 |
+
flushPara();
|
| 205 |
+
const el = document.createElement("h" + h[1].length);
|
| 206 |
+
el.innerHTML = inline(h[2]);
|
| 207 |
+
container.appendChild(el);
|
| 208 |
+
i++;
|
| 209 |
+
continue;
|
| 210 |
+
}
|
| 211 |
+
if (
|
| 212 |
+
trimmed.startsWith("|") &&
|
| 213 |
+
i + 1 < lines.length &&
|
| 214 |
+
/^\|?[\s:|-]*-{2,}[\s:|-]*\|?$/.test(lines[i + 1].trim())
|
| 215 |
+
) {
|
| 216 |
+
flushPara();
|
| 217 |
+
const rows = [];
|
| 218 |
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
| 219 |
+
rows.push(parseRow(lines[i].trim()));
|
| 220 |
+
i++;
|
| 221 |
+
}
|
| 222 |
+
renderTable(rows, container);
|
| 223 |
+
continue;
|
| 224 |
+
}
|
| 225 |
+
if (trimmed.startsWith("> ")) {
|
| 226 |
+
flushPara();
|
| 227 |
+
const bq = document.createElement("blockquote");
|
| 228 |
+
bq.innerHTML = inline(trimmed.slice(2));
|
| 229 |
+
container.appendChild(bq);
|
| 230 |
+
i++;
|
| 231 |
+
continue;
|
| 232 |
+
}
|
| 233 |
+
if (/^`[^`]+`$/.test(trimmed)) {
|
| 234 |
+
flushPara();
|
| 235 |
+
const el = document.createElement("div");
|
| 236 |
+
el.className = "ts";
|
| 237 |
+
el.textContent = trimmed.replace(/`/g, "");
|
| 238 |
+
container.appendChild(el);
|
| 239 |
+
i++;
|
| 240 |
+
continue;
|
| 241 |
+
}
|
| 242 |
+
if (trimmed.startsWith("- ")) {
|
| 243 |
+
flushPara();
|
| 244 |
+
const items = [];
|
| 245 |
+
while (i < lines.length && lines[i].trim().startsWith("- ")) {
|
| 246 |
+
items.push(lines[i].trim().slice(2).trim());
|
| 247 |
+
i++;
|
| 248 |
+
}
|
| 249 |
+
renderList(items, container);
|
| 250 |
+
continue;
|
| 251 |
+
}
|
| 252 |
+
para.push(trimmed);
|
| 253 |
+
i++;
|
| 254 |
+
}
|
| 255 |
+
flushPara();
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function renderCell(meta, body, container, artifacts) {
|
| 259 |
+
const cell = document.createElement("section");
|
| 260 |
+
cell.className = `cell ${meta.type || "markdown"}`;
|
| 261 |
+
if (meta.id) cell.dataset.cellId = meta.id;
|
| 262 |
+
if (isPinned(meta)) cell.classList.add("pinned-source");
|
| 263 |
+
|
| 264 |
+
const head = document.createElement("div");
|
| 265 |
+
head.className = "cell-head";
|
| 266 |
+
const rawTitle = (meta.title || "").trim();
|
| 267 |
+
const title = rawTitle && rawTitle.toLowerCase() !== "untitled" ? esc(rawTitle) : "";
|
| 268 |
+
const when = meta.created_at ? `<span>${esc(formatTime(meta.created_at))}</span>` : "";
|
| 269 |
+
head.innerHTML =
|
| 270 |
+
(title ? `<div class="cell-title">${title}</div>` : "") +
|
| 271 |
+
`<div class="cell-meta">${when}</div>`;
|
| 272 |
+
if (!title) head.classList.add("no-title");
|
| 273 |
+
cell.appendChild(head);
|
| 274 |
+
|
| 275 |
+
const bodyEl = document.createElement("div");
|
| 276 |
+
bodyEl.className = "cell-body";
|
| 277 |
+
if (meta.type === "code") {
|
| 278 |
+
renderCodeCell(body, bodyEl, artifacts);
|
| 279 |
+
} else if (meta.type === "figure") {
|
| 280 |
+
cell.dataset.resUrl = `trackio-figure://${(meta.title || "Figure").trim()}`;
|
| 281 |
+
renderFigureCell(body, bodyEl, head);
|
| 282 |
+
} else if (meta.type === "artifact") {
|
| 283 |
+
renderMarkdownPlain(body, bodyEl);
|
| 284 |
+
const chip = bodyEl.querySelector(".artifact-chip");
|
| 285 |
+
const uri = body.match(
|
| 286 |
+
/(trackio-artifact:\/\/\S+|trackio-local-path:\/\/\S+|https:\/\/huggingface\.co\/buckets\/[^\s<)]+#\S+)/
|
| 287 |
+
);
|
| 288 |
+
if (chip && uri) chip.dataset.resUrl = uri[1];
|
| 289 |
+
} else if (meta.type === "dashboard") {
|
| 290 |
+
const sp = body.match(/https:\/\/huggingface\.co\/spaces\/[^\s<>)"'`]+/);
|
| 291 |
+
cell.dataset.resUrl = sp
|
| 292 |
+
? sp[0]
|
| 293 |
+
: `trackio-local-dashboard://${(meta.dashboard_project || "").trim()}`;
|
| 294 |
+
renderDashboardCell(meta, body, bodyEl, head);
|
| 295 |
+
} else {
|
| 296 |
+
const cleaned = stripDuplicateTitle(body, meta.title);
|
| 297 |
+
renderMarkdownPlain(cleaned, bodyEl);
|
| 298 |
+
renderDetectedEmbeds(cleaned, bodyEl);
|
| 299 |
+
}
|
| 300 |
+
cell.appendChild(bodyEl);
|
| 301 |
+
container.appendChild(cell);
|
| 302 |
+
return cell;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function isPinned(meta) {
|
| 306 |
+
return Boolean(meta && (meta.pinned === true || meta.pinned === "true"));
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function stripDuplicateTitle(body, title) {
|
| 310 |
+
if (!title) return body;
|
| 311 |
+
const m = body.match(/^\s*#{1,6}\s+([^\n]+)\n?/);
|
| 312 |
+
if (!m) return body;
|
| 313 |
+
const norm = (s) =>
|
| 314 |
+
s
|
| 315 |
+
.toLowerCase()
|
| 316 |
+
.replace(/[*_`#]/g, "")
|
| 317 |
+
.replace(/\s+/g, " ")
|
| 318 |
+
.trim();
|
| 319 |
+
return norm(m[1]) === norm(title) ? body.slice(m[0].length) : body;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function formatTime(iso) {
|
| 323 |
+
const d = new Date(iso);
|
| 324 |
+
if (Number.isNaN(d.getTime())) return iso;
|
| 325 |
+
return d.toLocaleString(undefined, {
|
| 326 |
+
month: "short",
|
| 327 |
+
day: "numeric",
|
| 328 |
+
hour: "2-digit",
|
| 329 |
+
minute: "2-digit",
|
| 330 |
+
});
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function parseFences(text) {
|
| 334 |
+
const fenceRe = /(`{3,4}|~{3,4})([^\n]*)\n([\s\S]*?)\n\1/g;
|
| 335 |
+
const parts = [];
|
| 336 |
+
let pos = 0;
|
| 337 |
+
let match;
|
| 338 |
+
while ((match = fenceRe.exec(text))) {
|
| 339 |
+
if (match.index > pos) {
|
| 340 |
+
parts.push({ kind: "text", text: text.slice(pos, match.index) });
|
| 341 |
+
}
|
| 342 |
+
const info = match[2].trim();
|
| 343 |
+
const lang = (info.split(/\s+/)[0] || "").toLowerCase();
|
| 344 |
+
const titleMatch = info.match(/title=(\S+)/);
|
| 345 |
+
parts.push({
|
| 346 |
+
kind: lang === "result" || lang === "output" ? "output" : "code",
|
| 347 |
+
lang,
|
| 348 |
+
title: titleMatch ? titleMatch[1] : null,
|
| 349 |
+
text: match[3],
|
| 350 |
+
});
|
| 351 |
+
pos = match.index + match[0].length;
|
| 352 |
+
}
|
| 353 |
+
if (pos < text.length) parts.push({ kind: "text", text: text.slice(pos) });
|
| 354 |
+
return parts;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function fitFigureFrame(frame, wrap) {
|
| 358 |
+
let doc;
|
| 359 |
+
try {
|
| 360 |
+
doc = frame.contentDocument;
|
| 361 |
+
} catch (e) {
|
| 362 |
+
return;
|
| 363 |
+
}
|
| 364 |
+
if (!doc || !doc.body) return;
|
| 365 |
+
frame.style.transform = "none";
|
| 366 |
+
frame.style.width = "100%";
|
| 367 |
+
frame.style.height = "auto";
|
| 368 |
+
frame.style.position = "";
|
| 369 |
+
frame.style.left = "";
|
| 370 |
+
frame.style.top = "";
|
| 371 |
+
const avail = wrap.clientWidth;
|
| 372 |
+
const isFullscreen =
|
| 373 |
+
document.fullscreenElement === wrap ||
|
| 374 |
+
document.webkitFullscreenElement === wrap;
|
| 375 |
+
const availHeight = isFullscreen ? wrap.clientHeight : Infinity;
|
| 376 |
+
const cw = Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth, 1);
|
| 377 |
+
const ch = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 1);
|
| 378 |
+
const scale = Math.min(avail / cw, availHeight / ch);
|
| 379 |
+
if (avail && scale < 1 - 1e-3) {
|
| 380 |
+
frame.style.width = `${cw}px`;
|
| 381 |
+
frame.style.height = `${ch}px`;
|
| 382 |
+
frame.style.transformOrigin = "top left";
|
| 383 |
+
frame.style.transform = `scale(${scale})`;
|
| 384 |
+
if (isFullscreen) {
|
| 385 |
+
frame.style.position = "absolute";
|
| 386 |
+
frame.style.left = `${Math.max(0, (avail - cw * scale) / 2)}px`;
|
| 387 |
+
frame.style.top = `${Math.max(0, (availHeight - ch * scale) / 2)}px`;
|
| 388 |
+
wrap.style.height = "100%";
|
| 389 |
+
} else {
|
| 390 |
+
wrap.style.height = `${Math.ceil(ch * scale)}px`;
|
| 391 |
+
}
|
| 392 |
+
} else {
|
| 393 |
+
frame.style.width = "100%";
|
| 394 |
+
frame.style.height = `${ch}px`;
|
| 395 |
+
wrap.style.height = isFullscreen ? "100%" : `${ch}px`;
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function attachFigureFit(frame, wrap) {
|
| 400 |
+
const refit = () => fitFigureFrame(frame, wrap);
|
| 401 |
+
frame.addEventListener("load", refit);
|
| 402 |
+
if (window.ResizeObserver) {
|
| 403 |
+
const ro = new ResizeObserver(() => refit());
|
| 404 |
+
ro.observe(wrap);
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
function renderFigureCell(text, container, head) {
|
| 409 |
+
const parts = parseFences(text);
|
| 410 |
+
const htmlPart = parts.find((part) => part.lang === "html");
|
| 411 |
+
const rawPart = parts.find((part) => part.lang === "raw");
|
| 412 |
+
if (!htmlPart || !htmlPart.text.trim()) {
|
| 413 |
+
const empty = document.createElement("p");
|
| 414 |
+
empty.className = "muted";
|
| 415 |
+
empty.textContent = "No figure HTML.";
|
| 416 |
+
container.appendChild(empty);
|
| 417 |
+
return;
|
| 418 |
+
}
|
| 419 |
+
const frame = document.createElement("iframe");
|
| 420 |
+
frame.className = "figure-frame";
|
| 421 |
+
frame.sandbox = "allow-scripts allow-same-origin";
|
| 422 |
+
frame.loading = "lazy";
|
| 423 |
+
frame.srcdoc = htmlPart.text;
|
| 424 |
+
registerFigureNavigation(frame);
|
| 425 |
+
const figWrap = document.createElement("div");
|
| 426 |
+
figWrap.className = "figure-fit";
|
| 427 |
+
figWrap.appendChild(frame);
|
| 428 |
+
attachFigureFit(frame, figWrap);
|
| 429 |
+
if (head) {
|
| 430 |
+
const metaEl = head.querySelector(".cell-meta");
|
| 431 |
+
if (metaEl)
|
| 432 |
+
metaEl.insertBefore(buildFullscreenControl(figWrap, frame), metaEl.firstChild);
|
| 433 |
+
}
|
| 434 |
+
if (!rawPart || !rawPart.text.trim()) {
|
| 435 |
+
container.appendChild(figWrap);
|
| 436 |
+
return;
|
| 437 |
+
}
|
| 438 |
+
const sw = document.createElement("div");
|
| 439 |
+
sw.className = "fig-switch";
|
| 440 |
+
const thumb = document.createElement("span");
|
| 441 |
+
thumb.className = "fig-switch-thumb";
|
| 442 |
+
const figBtn = document.createElement("button");
|
| 443 |
+
figBtn.type = "button";
|
| 444 |
+
figBtn.className = "active";
|
| 445 |
+
figBtn.textContent = "Figure";
|
| 446 |
+
const rawBtn = document.createElement("button");
|
| 447 |
+
rawBtn.type = "button";
|
| 448 |
+
rawBtn.textContent = "Raw";
|
| 449 |
+
sw.appendChild(thumb);
|
| 450 |
+
sw.appendChild(figBtn);
|
| 451 |
+
sw.appendChild(rawBtn);
|
| 452 |
+
const rawView = document.createElement("div");
|
| 453 |
+
rawView.className = "figure-raw";
|
| 454 |
+
rawView.hidden = true;
|
| 455 |
+
const pre = document.createElement("pre");
|
| 456 |
+
const code = document.createElement("code");
|
| 457 |
+
code.textContent = rawPart.text;
|
| 458 |
+
pre.appendChild(code);
|
| 459 |
+
rawView.appendChild(pre);
|
| 460 |
+
rawView.appendChild(copySnippetBtn(rawPart.text));
|
| 461 |
+
const select = (showRaw) => {
|
| 462 |
+
sw.classList.toggle("raw", showRaw);
|
| 463 |
+
figBtn.classList.toggle("active", !showRaw);
|
| 464 |
+
rawBtn.classList.toggle("active", showRaw);
|
| 465 |
+
figWrap.hidden = showRaw;
|
| 466 |
+
rawView.hidden = !showRaw;
|
| 467 |
+
};
|
| 468 |
+
figBtn.addEventListener("click", () => select(false));
|
| 469 |
+
rawBtn.addEventListener("click", () => select(true));
|
| 470 |
+
if (head) {
|
| 471 |
+
head.insertBefore(sw, head.querySelector(".cell-meta"));
|
| 472 |
+
} else {
|
| 473 |
+
container.appendChild(sw);
|
| 474 |
+
}
|
| 475 |
+
container.appendChild(figWrap);
|
| 476 |
+
container.appendChild(rawView);
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// Poster embeds can send `{ type: "trackio-logbook:navigate", target: "..." }`
|
| 480 |
+
// from their iframe. Only accept messages from figure frames we created, and
|
| 481 |
+
// only route to pages that are present in this logbook's manifest.
|
| 482 |
+
function registerFigureNavigation(frame) {
|
| 483 |
+
const registerFrameWindow = () => {
|
| 484 |
+
if (frame.contentWindow) FIGURE_FRAME_WINDOWS.add(frame.contentWindow);
|
| 485 |
+
};
|
| 486 |
+
// `srcdoc` replaces the initial about:blank document. Register after that
|
| 487 |
+
// navigation as well, so messages come from the live figure document.
|
| 488 |
+
frame.addEventListener("load", registerFrameWindow);
|
| 489 |
+
registerFrameWindow();
|
| 490 |
+
if (FIGURE_NAVIGATION_READY) return;
|
| 491 |
+
FIGURE_NAVIGATION_READY = true;
|
| 492 |
+
window.addEventListener("message", (event) => {
|
| 493 |
+
if (!FIGURE_FRAME_WINDOWS.has(event.source)) return;
|
| 494 |
+
const message = event.data;
|
| 495 |
+
if (!message || message.type !== "trackio-logbook:navigate") return;
|
| 496 |
+
const target = String(message.target || "").replace(/^#?\//, "");
|
| 497 |
+
if (!target || !MANIFEST || !findNode(MANIFEST.root, target)) return;
|
| 498 |
+
const hash = "#/" + target;
|
| 499 |
+
if (location.hash === hash) scrollToHash();
|
| 500 |
+
else location.hash = hash;
|
| 501 |
+
});
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
const FULLSCREEN_ICON =
|
| 505 |
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
| 506 |
+
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
| 507 |
+
'<path d="M8 3H3v5M16 3h5v5M21 16v5h-5M3 16v5h5"/>' +
|
| 508 |
+
'<path d="M3 8 8 3M16 3l5 5M21 16l-5 5M8 21l-5-5"/></svg>';
|
| 509 |
+
|
| 510 |
+
// Figures are rendered in same-origin iframes, so fullscreen the fitted
|
| 511 |
+
// wrapper rather than the iframe document. This uses the browser's native
|
| 512 |
+
// fullscreen UI and preserves the figure's existing responsive sizing.
|
| 513 |
+
function buildFullscreenControl(figWrap, frame) {
|
| 514 |
+
const wrap = document.createElement("span");
|
| 515 |
+
wrap.className = "cell-fullscreen";
|
| 516 |
+
const btn = document.createElement("button");
|
| 517 |
+
btn.type = "button";
|
| 518 |
+
btn.className = "cell-fullscreen-btn";
|
| 519 |
+
btn.setAttribute("aria-label", "Open figure in fullscreen");
|
| 520 |
+
btn.title = "Open figure in fullscreen";
|
| 521 |
+
btn.innerHTML = FULLSCREEN_ICON;
|
| 522 |
+
wrap.appendChild(btn);
|
| 523 |
+
|
| 524 |
+
btn.addEventListener("click", async () => {
|
| 525 |
+
const request = figWrap.requestFullscreen || figWrap.webkitRequestFullscreen;
|
| 526 |
+
if (!request) return;
|
| 527 |
+
try {
|
| 528 |
+
await request.call(figWrap);
|
| 529 |
+
} catch (_) {
|
| 530 |
+
// Fullscreen can be disabled by the embedding browser or policy.
|
| 531 |
+
}
|
| 532 |
+
});
|
| 533 |
+
document.addEventListener("fullscreenchange", () => {
|
| 534 |
+
if (document.fullscreenElement === figWrap) fitFigureFrame(frame, figWrap);
|
| 535 |
+
});
|
| 536 |
+
return wrap;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function extractUrls(text) {
|
| 540 |
+
const seen = new Set();
|
| 541 |
+
const urls = [];
|
| 542 |
+
let match;
|
| 543 |
+
while ((match = DETECTED_URL.exec(text))) {
|
| 544 |
+
const url = match[1].replace(/[.,;:!?'"`]+$/, "");
|
| 545 |
+
if (!seen.has(url)) {
|
| 546 |
+
seen.add(url);
|
| 547 |
+
urls.push(url);
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
DETECTED_URL.lastIndex = 0;
|
| 551 |
+
return urls;
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
const IMG_URL = /(\.(png|jpe?g|gif|svg|webp)(\?|$)|\/artifact_blob\/)/i;
|
| 555 |
+
|
| 556 |
+
function renderDetectedEmbeds(text, container) {
|
| 557 |
+
extractUrls(text).forEach((url) => {
|
| 558 |
+
if (url.startsWith("trackio-local-dashboard://")) {
|
| 559 |
+
const div = document.createElement("div");
|
| 560 |
+
div.className = "artifact-chip";
|
| 561 |
+
div.dataset.resUrl = url;
|
| 562 |
+
div.innerHTML =
|
| 563 |
+
"🎯 <strong>Local Trackio dashboard</strong> — publish the logbook to share it";
|
| 564 |
+
container.appendChild(div);
|
| 565 |
+
} else if (IMG_URL.test(url)) {
|
| 566 |
+
container.appendChild(renderImage(url));
|
| 567 |
+
} else if (/huggingface\.co\/spaces\//.test(url)) {
|
| 568 |
+
maybeEmbedTrackioSpace(url, container);
|
| 569 |
+
}
|
| 570 |
+
});
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
function renderStandaloneUrl(url) {
|
| 574 |
+
if (IMG_URL.test(url) || IMG_PATH.test(url)) return renderImage(url);
|
| 575 |
+
const item = classifyResource(url);
|
| 576 |
+
if (item) {
|
| 577 |
+
const marker = document.createElement("span");
|
| 578 |
+
marker.className = "resource-anchor";
|
| 579 |
+
marker.dataset.resUrl = item.url;
|
| 580 |
+
marker.setAttribute("aria-hidden", "true");
|
| 581 |
+
return marker;
|
| 582 |
+
}
|
| 583 |
+
const p = document.createElement("p");
|
| 584 |
+
p.innerHTML = inline(url);
|
| 585 |
+
return p;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
function renderImage(url) {
|
| 589 |
+
const a = document.createElement("a");
|
| 590 |
+
a.className = "unfurl image";
|
| 591 |
+
a.href = url;
|
| 592 |
+
a.target = "_blank";
|
| 593 |
+
a.rel = "noopener";
|
| 594 |
+
const img = document.createElement("img");
|
| 595 |
+
img.loading = "lazy";
|
| 596 |
+
img.src = url;
|
| 597 |
+
img.alt = "artifact image";
|
| 598 |
+
a.appendChild(img);
|
| 599 |
+
return a;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
function maybeEmbedTrackioSpace(url, container) {
|
| 603 |
+
const id = url.split("/spaces/")[1].split(/[?#]/)[0].replace(/\/$/, "");
|
| 604 |
+
const holder = document.createElement("div");
|
| 605 |
+
container.appendChild(holder);
|
| 606 |
+
getJSON(`https://huggingface.co/api/spaces/${id}`).then((d) => {
|
| 607 |
+
const tags = (d && d.tags) || [];
|
| 608 |
+
if (tags.some((t) => String(t).toLowerCase() === "trackio")) {
|
| 609 |
+
renderTrackioSpaceEmbed(holder, url, id);
|
| 610 |
+
} else {
|
| 611 |
+
holder.remove();
|
| 612 |
+
}
|
| 613 |
+
});
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
function jpGutter(label) {
|
| 617 |
+
const g = document.createElement("div");
|
| 618 |
+
g.className = "jp-gutter";
|
| 619 |
+
g.textContent = label;
|
| 620 |
+
return g;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function renderOutArtifact(info) {
|
| 624 |
+
const remote = !info.local && !!info.url;
|
| 625 |
+
const el = document.createElement(remote ? "a" : "div");
|
| 626 |
+
el.className = "out-artifact";
|
| 627 |
+
if (remote) {
|
| 628 |
+
el.href = info.url;
|
| 629 |
+
el.target = "_blank";
|
| 630 |
+
el.rel = "noopener";
|
| 631 |
+
}
|
| 632 |
+
el.dataset.resUrl = info.resUrl;
|
| 633 |
+
const parts = [info.type, info.size].filter(Boolean).map(esc);
|
| 634 |
+
const state = remote
|
| 635 |
+
? `<span class="out-artifact-state open">Open ↗</span>`
|
| 636 |
+
: `<span class="out-artifact-state">publish to share</span>`;
|
| 637 |
+
const meta = parts.length ? `${parts.join(" · ")} · ${state}` : state;
|
| 638 |
+
el.innerHTML =
|
| 639 |
+
`<span class="out-artifact-ico">${ARTIFACT_ICON_IMG}</span>` +
|
| 640 |
+
`<span class="out-artifact-name">${esc(info.name)}</span>` +
|
| 641 |
+
`<span class="out-artifact-meta">${meta}</span>`;
|
| 642 |
+
return el;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
function renderCodeCell(body, container, artifacts) {
|
| 646 |
+
const parts = parseFences(body);
|
| 647 |
+
const block = document.createElement("div");
|
| 648 |
+
block.className = "jp";
|
| 649 |
+
const input = document.createElement("div");
|
| 650 |
+
input.className = "jp-in";
|
| 651 |
+
const inputBody = document.createElement("div");
|
| 652 |
+
inputBody.className = "jp-in-body";
|
| 653 |
+
input.appendChild(jpGutter("In"));
|
| 654 |
+
input.appendChild(inputBody);
|
| 655 |
+
let metaEl = null;
|
| 656 |
+
let outputEl = null;
|
| 657 |
+
let outBody = null;
|
| 658 |
+
const ensureOut = () => {
|
| 659 |
+
if (outputEl) return;
|
| 660 |
+
outputEl = document.createElement("div");
|
| 661 |
+
outputEl.className = "jp-out";
|
| 662 |
+
outputEl.appendChild(jpGutter("Out"));
|
| 663 |
+
outBody = document.createElement("div");
|
| 664 |
+
outBody.className = "jp-out-body";
|
| 665 |
+
outputEl.appendChild(outBody);
|
| 666 |
+
};
|
| 667 |
+
const embedTexts = [];
|
| 668 |
+
parts.forEach((part) => {
|
| 669 |
+
if (part.kind === "text") {
|
| 670 |
+
const text = part.text.trim();
|
| 671 |
+
if (!text) return;
|
| 672 |
+
if (/^exit\s+\S+(\s|·)/.test(text)) {
|
| 673 |
+
metaEl = document.createElement("div");
|
| 674 |
+
metaEl.className = "jp-meta";
|
| 675 |
+
metaEl.textContent = text.replace(
|
| 676 |
+
/\s*·\s*[A-Z][a-z]{2} \d{1,2}, \d{4}.*$/,
|
| 677 |
+
""
|
| 678 |
+
);
|
| 679 |
+
} else {
|
| 680 |
+
renderMarkdownPlain(text, container);
|
| 681 |
+
embedTexts.push(text);
|
| 682 |
+
}
|
| 683 |
+
return;
|
| 684 |
+
}
|
| 685 |
+
if (part.kind === "output") {
|
| 686 |
+
ensureOut();
|
| 687 |
+
const pre = document.createElement("pre");
|
| 688 |
+
pre.className = "jp-out-pre";
|
| 689 |
+
const c = document.createElement("code");
|
| 690 |
+
c.textContent = part.text;
|
| 691 |
+
pre.appendChild(c);
|
| 692 |
+
outBody.appendChild(pre);
|
| 693 |
+
outputEl.appendChild(copySnippetBtn(part.text));
|
| 694 |
+
embedTexts.push(part.text);
|
| 695 |
+
return;
|
| 696 |
+
}
|
| 697 |
+
inputBody.appendChild(renderCode(part.text, part.lang, part.title));
|
| 698 |
+
});
|
| 699 |
+
if (artifacts && artifacts.length) {
|
| 700 |
+
ensureOut();
|
| 701 |
+
const artWrap = document.createElement("div");
|
| 702 |
+
artWrap.className = "jp-artifacts";
|
| 703 |
+
artifacts.forEach((a) => {
|
| 704 |
+
artWrap.appendChild(
|
| 705 |
+
renderOutArtifact(artifactInfoFromCell(a.meta, a.body))
|
| 706 |
+
);
|
| 707 |
+
});
|
| 708 |
+
outBody.appendChild(artWrap);
|
| 709 |
+
}
|
| 710 |
+
if (inputBody.childNodes.length > 0) block.appendChild(input);
|
| 711 |
+
if (metaEl) block.appendChild(metaEl);
|
| 712 |
+
if (outputEl) block.appendChild(outputEl);
|
| 713 |
+
if (block.childNodes.length) container.appendChild(block);
|
| 714 |
+
embedTexts.forEach((text) => renderDetectedEmbeds(text, container));
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
function parseRow(line) {
|
| 718 |
+
let s = line.trim();
|
| 719 |
+
if (s.startsWith("|")) s = s.slice(1);
|
| 720 |
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
| 721 |
+
return s.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, "|").trim());
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
const TRUTHY = ["x", "✓", "✔", "yes", "done", "true", "[x]"];
|
| 725 |
+
const CHIP_COLORS = [
|
| 726 |
+
["#e7f0ff", "#2158d0"],
|
| 727 |
+
["#fde8ec", "#c62a4b"],
|
| 728 |
+
["#e6f7ee", "#1a8a55"],
|
| 729 |
+
["#fdf0e0", "#b26a12"],
|
| 730 |
+
["#efe9ff", "#5b3bd6"],
|
| 731 |
+
["#e6f6f8", "#127b88"],
|
| 732 |
+
];
|
| 733 |
+
|
| 734 |
+
function chipColor(name) {
|
| 735 |
+
let h = 0;
|
| 736 |
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
| 737 |
+
return CHIP_COLORS[h % CHIP_COLORS.length];
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
const STATUS_MAP = {
|
| 741 |
+
"": ["Planned", "gray"],
|
| 742 |
+
planned: ["Planned", "gray"],
|
| 743 |
+
todo: ["Planned", "gray"],
|
| 744 |
+
"to do": ["Planned", "gray"],
|
| 745 |
+
backlog: ["Planned", "gray"],
|
| 746 |
+
"in progress": ["In progress", "amber"],
|
| 747 |
+
"in-progress": ["In progress", "amber"],
|
| 748 |
+
wip: ["In progress", "amber"],
|
| 749 |
+
running: ["In progress", "amber"],
|
| 750 |
+
active: ["In progress", "amber"],
|
| 751 |
+
done: ["Done", "green"],
|
| 752 |
+
complete: ["Done", "green"],
|
| 753 |
+
completed: ["Done", "green"],
|
| 754 |
+
blocked: ["Blocked", "red"],
|
| 755 |
+
failed: ["Failed", "red"],
|
| 756 |
+
abandoned: ["Abandoned", "gray"],
|
| 757 |
+
};
|
| 758 |
+
|
| 759 |
+
function statusBadge(val) {
|
| 760 |
+
const [label, tone] = STATUS_MAP[val.toLowerCase()] || [val || "—", "gray"];
|
| 761 |
+
return `<span class="badge ${tone}">${esc(label)}</span>`;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
function renderTable(rows, container) {
|
| 765 |
+
if (rows.length < 2) return;
|
| 766 |
+
const header = rows[0];
|
| 767 |
+
const body = rows.slice(2);
|
| 768 |
+
const roles = header.map((h) => {
|
| 769 |
+
const t = h.toLowerCase();
|
| 770 |
+
if (t.includes("status") || t.includes("state")) return "status";
|
| 771 |
+
if (t.includes("progress") || t.includes("complete") || t.includes("done"))
|
| 772 |
+
return "check";
|
| 773 |
+
if (t === "who" || t.includes("assign") || t.includes("owner")) return "who";
|
| 774 |
+
return "text";
|
| 775 |
+
});
|
| 776 |
+
const table = document.createElement("table");
|
| 777 |
+
table.className = "board";
|
| 778 |
+
const thead = document.createElement("thead");
|
| 779 |
+
const htr = document.createElement("tr");
|
| 780 |
+
header.forEach((h, c) => {
|
| 781 |
+
const th = document.createElement("th");
|
| 782 |
+
th.textContent = h;
|
| 783 |
+
if (roles[c] === "check") th.className = "col-check";
|
| 784 |
+
htr.appendChild(th);
|
| 785 |
+
});
|
| 786 |
+
thead.appendChild(htr);
|
| 787 |
+
table.appendChild(thead);
|
| 788 |
+
const tbody = document.createElement("tbody");
|
| 789 |
+
body.forEach((cells) => {
|
| 790 |
+
const nonEmpty = cells.filter((x) => x !== "").length;
|
| 791 |
+
if (header.length > 1 && nonEmpty === 1 && cells[0]) {
|
| 792 |
+
const tr = document.createElement("tr");
|
| 793 |
+
tr.className = "section-row";
|
| 794 |
+
const td = document.createElement("td");
|
| 795 |
+
td.colSpan = header.length;
|
| 796 |
+
td.innerHTML = inline(cells[0]);
|
| 797 |
+
tr.appendChild(td);
|
| 798 |
+
tbody.appendChild(tr);
|
| 799 |
+
return;
|
| 800 |
+
}
|
| 801 |
+
const tr = document.createElement("tr");
|
| 802 |
+
header.forEach((_, c) => {
|
| 803 |
+
const td = document.createElement("td");
|
| 804 |
+
const val = (cells[c] || "").trim();
|
| 805 |
+
if (roles[c] === "status") {
|
| 806 |
+
td.className = "col-status";
|
| 807 |
+
td.innerHTML = statusBadge(val);
|
| 808 |
+
} else if (roles[c] === "check") {
|
| 809 |
+
td.className = "col-check";
|
| 810 |
+
const on = TRUTHY.indexOf(val.toLowerCase()) !== -1;
|
| 811 |
+
td.innerHTML = `<span class="box ${on ? "on" : ""}">${on ? "✓" : ""}</span>`;
|
| 812 |
+
} else if (roles[c] === "who") {
|
| 813 |
+
if (!val || /^to assign$/i.test(val)) {
|
| 814 |
+
td.innerHTML = `<span class="who-chip muted">${esc(val || "—")}</span>`;
|
| 815 |
+
} else {
|
| 816 |
+
const [bg, fg] = chipColor(val);
|
| 817 |
+
td.innerHTML = `<span class="who-chip" style="background:${bg};color:${fg}">${esc(val)}</span>`;
|
| 818 |
+
}
|
| 819 |
+
} else {
|
| 820 |
+
td.innerHTML = inline(val);
|
| 821 |
+
}
|
| 822 |
+
tr.appendChild(td);
|
| 823 |
+
});
|
| 824 |
+
const link = tr.querySelector('a[href^="#/"]');
|
| 825 |
+
if (link) {
|
| 826 |
+
tr.classList.add("linked-row");
|
| 827 |
+
tr.addEventListener("click", (e) => {
|
| 828 |
+
if (e.target.tagName !== "A") location.hash = link.getAttribute("href");
|
| 829 |
+
});
|
| 830 |
+
}
|
| 831 |
+
tbody.appendChild(tr);
|
| 832 |
+
});
|
| 833 |
+
table.appendChild(tbody);
|
| 834 |
+
const wrap = document.createElement("div");
|
| 835 |
+
wrap.className = "board-wrap";
|
| 836 |
+
wrap.appendChild(table);
|
| 837 |
+
container.appendChild(wrap);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
const HL_RULES = {
|
| 841 |
+
python: [
|
| 842 |
+
["comment", /#[^\n]*/],
|
| 843 |
+
["string", /'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/],
|
| 844 |
+
[
|
| 845 |
+
"keyword",
|
| 846 |
+
/\b(?:def|class|return|if|elif|else|for|while|import|from|as|with|try|except|finally|raise|in|not|and|or|is|None|True|False|lambda|yield|global|nonlocal|assert|pass|break|continue|async|await|print)\b/,
|
| 847 |
+
],
|
| 848 |
+
["number", /\b\d[\d_.eE+-]*\b/],
|
| 849 |
+
],
|
| 850 |
+
bash: [
|
| 851 |
+
["comment", /#[^\n]*/],
|
| 852 |
+
["string", /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/],
|
| 853 |
+
["keyword", /\b(?:if|then|else|fi|for|in|do|done|while|case|esac|function|export|source|echo|cd|return|local)\b/],
|
| 854 |
+
["number", /(?<=\s)-{1,2}[a-zA-Z][\w-]*/],
|
| 855 |
+
],
|
| 856 |
+
json: [
|
| 857 |
+
["string", /"(?:\\.|[^"\\])*"/],
|
| 858 |
+
["keyword", /\b(?:true|false|null)\b/],
|
| 859 |
+
["number", /-?\b\d[\d.eE+-]*\b/],
|
| 860 |
+
],
|
| 861 |
+
yaml: [
|
| 862 |
+
["comment", /#[^\n]*/],
|
| 863 |
+
["string", /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/],
|
| 864 |
+
["keyword", /\b(?:true|false|null|yes|no)\b/],
|
| 865 |
+
["number", /-?\b\d[\d.eE+-]*\b/],
|
| 866 |
+
],
|
| 867 |
+
};
|
| 868 |
+
HL_RULES.javascript = HL_RULES.python;
|
| 869 |
+
HL_RULES.typescript = HL_RULES.python;
|
| 870 |
+
HL_RULES.sql = [
|
| 871 |
+
["comment", /--[^\n]*/],
|
| 872 |
+
["string", /'(?:\\.|[^'\\])*'/],
|
| 873 |
+
[
|
| 874 |
+
"keyword",
|
| 875 |
+
/\b(?:SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|LIMIT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|AS|AND|OR|NOT|NULL|COUNT|DISTINCT|IN)\b/i,
|
| 876 |
+
],
|
| 877 |
+
["number", /\b\d[\d.]*\b/],
|
| 878 |
+
];
|
| 879 |
+
|
| 880 |
+
function highlightCode(code, lang) {
|
| 881 |
+
const rules = HL_RULES[lang];
|
| 882 |
+
if (!rules) return esc(code);
|
| 883 |
+
const combined = new RegExp(rules.map((r) => "(" + r[1].source + ")").join("|"), "g");
|
| 884 |
+
let out = "";
|
| 885 |
+
let last = 0;
|
| 886 |
+
let m;
|
| 887 |
+
while ((m = combined.exec(code))) {
|
| 888 |
+
if (m[0] === "") {
|
| 889 |
+
combined.lastIndex++;
|
| 890 |
+
continue;
|
| 891 |
+
}
|
| 892 |
+
out += esc(code.slice(last, m.index));
|
| 893 |
+
let gi = 1;
|
| 894 |
+
while (gi < m.length && m[gi] === undefined) gi++;
|
| 895 |
+
out += `<span class="tok-${rules[gi - 1][0]}">${esc(m[0])}</span>`;
|
| 896 |
+
last = m.index + m[0].length;
|
| 897 |
+
}
|
| 898 |
+
out += esc(code.slice(last));
|
| 899 |
+
return out;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
function copySnippetBtn(text) {
|
| 903 |
+
const btn = document.createElement("button");
|
| 904 |
+
btn.type = "button";
|
| 905 |
+
btn.className = "copy-snippet";
|
| 906 |
+
btn.title = "Copy";
|
| 907 |
+
btn.textContent = "⧉";
|
| 908 |
+
btn.addEventListener("click", (e) => {
|
| 909 |
+
e.preventDefault();
|
| 910 |
+
e.stopPropagation();
|
| 911 |
+
copyText(text, btn, "⧉");
|
| 912 |
+
});
|
| 913 |
+
return btn;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
function renderCode(code, lang, title) {
|
| 917 |
+
const pre = document.createElement("pre");
|
| 918 |
+
pre.className = "hl";
|
| 919 |
+
const c = document.createElement("code");
|
| 920 |
+
c.innerHTML = highlightCode(code, lang);
|
| 921 |
+
pre.appendChild(c);
|
| 922 |
+
if (!title) {
|
| 923 |
+
const wrap = document.createElement("div");
|
| 924 |
+
wrap.className = "snippet";
|
| 925 |
+
wrap.appendChild(pre);
|
| 926 |
+
wrap.appendChild(copySnippetBtn(code));
|
| 927 |
+
return wrap;
|
| 928 |
+
}
|
| 929 |
+
const det = document.createElement("details");
|
| 930 |
+
det.className = "code-accordion";
|
| 931 |
+
det.dataset.resUrl = `trackio-script://${title}`;
|
| 932 |
+
const sum = document.createElement("summary");
|
| 933 |
+
sum.innerHTML =
|
| 934 |
+
`<span class="code-ico"></></span>` +
|
| 935 |
+
`<span class="code-name">${esc(title)}</span>`;
|
| 936 |
+
sum
|
| 937 |
+
.querySelector(".code-name")
|
| 938 |
+
.addEventListener("click", (e) => e.preventDefault());
|
| 939 |
+
det.appendChild(sum);
|
| 940 |
+
const wrap = document.createElement("div");
|
| 941 |
+
wrap.className = "snippet";
|
| 942 |
+
wrap.appendChild(pre);
|
| 943 |
+
wrap.appendChild(copySnippetBtn(code));
|
| 944 |
+
det.appendChild(wrap);
|
| 945 |
+
return det;
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
const IMG_PATH = /^[^\s]+\.(png|jpe?g|gif|svg|webp)$/i;
|
| 949 |
+
|
| 950 |
+
function renderList(items, container) {
|
| 951 |
+
let ul = null;
|
| 952 |
+
items.forEach((item) => {
|
| 953 |
+
if (URL_ONLY.test(item) || IMG_PATH.test(item)) {
|
| 954 |
+
const el = renderStandaloneUrl(item);
|
| 955 |
+
if (el) {
|
| 956 |
+
ul = null;
|
| 957 |
+
container.appendChild(el);
|
| 958 |
+
}
|
| 959 |
+
} else if (item.indexOf("📦 Artifact") !== -1) {
|
| 960 |
+
ul = null;
|
| 961 |
+
const div = document.createElement("div");
|
| 962 |
+
div.className = "artifact-chip";
|
| 963 |
+
div.innerHTML = inline(item.replace("📦", "🪣"));
|
| 964 |
+
container.appendChild(div);
|
| 965 |
+
} else if (item.indexOf("trackio-local-dashboard://") !== -1) {
|
| 966 |
+
ul = null;
|
| 967 |
+
const uri = item.match(/trackio-local-dashboard:\/\/\S+/)?.[0] || "";
|
| 968 |
+
const div = document.createElement("div");
|
| 969 |
+
div.className = "artifact-chip";
|
| 970 |
+
if (uri) div.dataset.resUrl = uri;
|
| 971 |
+
div.innerHTML =
|
| 972 |
+
"🎯 <strong>Local dashboard</strong> — publish the logbook to share it";
|
| 973 |
+
container.appendChild(div);
|
| 974 |
+
} else {
|
| 975 |
+
if (!ul) {
|
| 976 |
+
ul = document.createElement("ul");
|
| 977 |
+
container.appendChild(ul);
|
| 978 |
+
}
|
| 979 |
+
const li = document.createElement("li");
|
| 980 |
+
li.innerHTML = inline(item);
|
| 981 |
+
ul.appendChild(li);
|
| 982 |
+
}
|
| 983 |
+
});
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
/* -------------------- resources rail -------------------- */
|
| 987 |
+
|
| 988 |
+
function fmt(n) {
|
| 989 |
+
if (n == null) return null;
|
| 990 |
+
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
| 991 |
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
| 992 |
+
return String(n);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
const RESOURCE_SECTIONS = [
|
| 996 |
+
["dashboard", "Dashboards", "🎯"],
|
| 997 |
+
["model", "Models", "🤗"],
|
| 998 |
+
["dataset", "Datasets", "📊"],
|
| 999 |
+
["space", "Spaces", "🚀"],
|
| 1000 |
+
["artifact", "Artifacts", "🪣"],
|
| 1001 |
+
["paper", "Papers", "📄"],
|
| 1002 |
+
["repo", "Code", "🐙"],
|
| 1003 |
+
["job", "Jobs", "⚙️"],
|
| 1004 |
+
["bucket", "Buckets", "🪣"],
|
| 1005 |
+
];
|
| 1006 |
+
|
| 1007 |
+
const RESOURCE_ICONS = Object.fromEntries(
|
| 1008 |
+
RESOURCE_SECTIONS.map(([kind, , icon]) => [kind, icon])
|
| 1009 |
+
);
|
| 1010 |
+
|
| 1011 |
+
const ARTIFACT_ICON_IMG = `<img class="art-ico" src="./bucket-icon.svg" alt="" />`;
|
| 1012 |
+
const DASHBOARD_ICON_IMG = `<img class="art-ico" src="./trackio-logo-light.png" alt="" />`;
|
| 1013 |
+
|
| 1014 |
+
const RESOURCE_DESC = {
|
| 1015 |
+
dashboard: "Dashboard",
|
| 1016 |
+
model: "Model",
|
| 1017 |
+
dataset: "Dataset",
|
| 1018 |
+
space: "Space",
|
| 1019 |
+
artifact: "Artifact — in Bucket",
|
| 1020 |
+
paper: "Paper",
|
| 1021 |
+
repo: "Repository",
|
| 1022 |
+
job: "Job — status & logs",
|
| 1023 |
+
bucket: "Bucket — artifacts & data",
|
| 1024 |
+
};
|
| 1025 |
+
|
| 1026 |
+
const HF_NON_MODEL_PREFIX =
|
| 1027 |
+
/^(datasets|spaces|jobs|buckets|papers|blog|docs|api|posts|collections|organizations|settings|new|join|login|pricing|tasks|learn|chat|models)(\/|$)/;
|
| 1028 |
+
|
| 1029 |
+
function hfId(url, marker) {
|
| 1030 |
+
return url.split(marker)[1].split(/[?#]/)[0].replace(/\/$/, "");
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
function classifyResource(url) {
|
| 1034 |
+
if (IMG_URL.test(url)) {
|
| 1035 |
+
return null;
|
| 1036 |
+
}
|
| 1037 |
+
let m;
|
| 1038 |
+
if (url.startsWith("trackio-local-dashboard://")) {
|
| 1039 |
+
return {
|
| 1040 |
+
kind: "dashboard",
|
| 1041 |
+
id: url.slice("trackio-local-dashboard://".length),
|
| 1042 |
+
url,
|
| 1043 |
+
local: true,
|
| 1044 |
+
};
|
| 1045 |
+
}
|
| 1046 |
+
if (url.startsWith("trackio-artifact://")) {
|
| 1047 |
+
return {
|
| 1048 |
+
kind: "artifact",
|
| 1049 |
+
id: url.slice("trackio-artifact://".length),
|
| 1050 |
+
url,
|
| 1051 |
+
local: true,
|
| 1052 |
+
};
|
| 1053 |
+
}
|
| 1054 |
+
if (url.startsWith("trackio-local-path://")) {
|
| 1055 |
+
return {
|
| 1056 |
+
kind: "artifact",
|
| 1057 |
+
id: url.slice("trackio-local-path://".length),
|
| 1058 |
+
url,
|
| 1059 |
+
local: true,
|
| 1060 |
+
};
|
| 1061 |
+
}
|
| 1062 |
+
if ((m = url.match(/huggingface\.co\/buckets\/[^#\s]+#(.+)/))) {
|
| 1063 |
+
return { kind: "artifact", id: decodeURIComponent(m[1]), url };
|
| 1064 |
+
}
|
| 1065 |
+
if (/huggingface\.co\/datasets\/[^/]+\/[^/]+/.test(url)) {
|
| 1066 |
+
return { kind: "dataset", id: hfId(url, "/datasets/"), url };
|
| 1067 |
+
}
|
| 1068 |
+
if (/huggingface\.co\/spaces\/[^/]+\/[^/]+/.test(url)) {
|
| 1069 |
+
return { kind: "space", id: hfId(url, "/spaces/"), url };
|
| 1070 |
+
}
|
| 1071 |
+
if (/huggingface\.co\/jobs\//.test(url)) {
|
| 1072 |
+
const parts = hfId(url, "/jobs/").split("/");
|
| 1073 |
+
const jid = parts[1] || "";
|
| 1074 |
+
return {
|
| 1075 |
+
kind: "job",
|
| 1076 |
+
id: parts[0] + (jid ? ` · ${jid.slice(0, 12)}${jid.length > 12 ? "…" : ""}` : ""),
|
| 1077 |
+
url,
|
| 1078 |
+
};
|
| 1079 |
+
}
|
| 1080 |
+
if (/huggingface\.co\/buckets\//.test(url)) {
|
| 1081 |
+
return { kind: "bucket", id: hfId(url, "/buckets/"), url };
|
| 1082 |
+
}
|
| 1083 |
+
if (/huggingface\.co\/papers\//.test(url)) {
|
| 1084 |
+
return { kind: "paper", id: `Paper ${hfId(url, "/papers/")}`, url };
|
| 1085 |
+
}
|
| 1086 |
+
if ((m = url.match(/arxiv\.org\/(?:abs|pdf)\/([^?#\s]+)/))) {
|
| 1087 |
+
return { kind: "paper", id: `arXiv:${m[1].replace(/\.pdf$/, "")}`, url };
|
| 1088 |
+
}
|
| 1089 |
+
if ((m = url.match(/github\.com\/([^/?#]+\/[^/?#]+)/))) {
|
| 1090 |
+
return { kind: "repo", id: m[1], url };
|
| 1091 |
+
}
|
| 1092 |
+
if ((m = url.match(/huggingface\.co\/([^?#]+)/))) {
|
| 1093 |
+
const rest = m[1].replace(/\/$/, "");
|
| 1094 |
+
if (/^[^/]+\/[^/]+$/.test(rest) && !HF_NON_MODEL_PREFIX.test(rest)) {
|
| 1095 |
+
return { kind: "model", id: rest, url };
|
| 1096 |
+
}
|
| 1097 |
+
}
|
| 1098 |
+
return null;
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
async function fillRailMeta(item, el) {
|
| 1102 |
+
if (item.local) return;
|
| 1103 |
+
const meta = el.querySelector(".rail-meta");
|
| 1104 |
+
const set = (parts) => {
|
| 1105 |
+
const text = parts.filter(Boolean).join(" · ");
|
| 1106 |
+
if (text) meta.textContent = text;
|
| 1107 |
+
};
|
| 1108 |
+
if (item.kind === "model") {
|
| 1109 |
+
const d = await getJSON(`https://huggingface.co/api/models/${item.id}`);
|
| 1110 |
+
if (d) set([d.pipeline_tag, `↓ ${fmt(d.downloads)}`, `♥ ${fmt(d.likes)}`]);
|
| 1111 |
+
} else if (item.kind === "dataset") {
|
| 1112 |
+
const d = await getJSON(`https://huggingface.co/api/datasets/${item.id}`);
|
| 1113 |
+
if (d) set([`↓ ${fmt(d.downloads)}`, `♥ ${fmt(d.likes)}`]);
|
| 1114 |
+
} else if (item.kind === "space" || item.kind === "dashboard") {
|
| 1115 |
+
const d = await getJSON(`https://huggingface.co/api/spaces/${item.id}`);
|
| 1116 |
+
if (d) set([d.sdk, `♥ ${fmt(d.likes)}`]);
|
| 1117 |
+
} else if (item.kind === "repo") {
|
| 1118 |
+
const d = await getJSON(`https://api.github.com/repos/${item.id}`);
|
| 1119 |
+
if (d) set([`★ ${fmt(d.stargazers_count)}`, d.language]);
|
| 1120 |
+
} else if (item.kind === "paper") {
|
| 1121 |
+
const m = item.id.match(/^(?:arXiv:|Paper )(.+)$/);
|
| 1122 |
+
if (!m) return;
|
| 1123 |
+
const arxivId = m[1].replace(/v\d+$/, "");
|
| 1124 |
+
const d = await getJSON(`https://huggingface.co/api/papers/${arxivId}`);
|
| 1125 |
+
if (d && d.id) {
|
| 1126 |
+
if (el.href) el.href = `https://huggingface.co/papers/${d.id}`;
|
| 1127 |
+
const title =
|
| 1128 |
+
d.title && d.title.length > 70 ? `${d.title.slice(0, 69)}…` : d.title;
|
| 1129 |
+
set([title, d.upvotes ? `▲ ${fmt(d.upvotes)}` : null]);
|
| 1130 |
+
}
|
| 1131 |
+
}
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
const BARE_ID_SKIP_DIRS = new Set([
|
| 1135 |
+
"scripts",
|
| 1136 |
+
"configs",
|
| 1137 |
+
"config",
|
| 1138 |
+
"results",
|
| 1139 |
+
"figures",
|
| 1140 |
+
"data",
|
| 1141 |
+
"datasets",
|
| 1142 |
+
"src",
|
| 1143 |
+
"tests",
|
| 1144 |
+
"test",
|
| 1145 |
+
"examples",
|
| 1146 |
+
"pages",
|
| 1147 |
+
"assets",
|
| 1148 |
+
"docs",
|
| 1149 |
+
"outputs",
|
| 1150 |
+
"output",
|
| 1151 |
+
"checkpoints",
|
| 1152 |
+
"models",
|
| 1153 |
+
"utils",
|
| 1154 |
+
"lib",
|
| 1155 |
+
"bin",
|
| 1156 |
+
"tmp",
|
| 1157 |
+
"node_modules",
|
| 1158 |
+
"dist",
|
| 1159 |
+
"build",
|
| 1160 |
+
]);
|
| 1161 |
+
const FILE_EXT_RE =
|
| 1162 |
+
/\.(py|pyc|js|ts|jsx|tsx|json|jsonl|yaml|yml|csv|tsv|md|txt|sh|bash|html|css|png|jpe?g|svg|gif|webp|ipynb|toml|cfg|ini|lock|pdf|whl|gz|zip|tar|pt|pth|bin|safetensors|db|sqlite)$/i;
|
| 1163 |
+
|
| 1164 |
+
async function detectBareModelIds(text, groups) {
|
| 1165 |
+
const stripped = text.replace(DETECTED_URL, " ");
|
| 1166 |
+
DETECTED_URL.lastIndex = 0;
|
| 1167 |
+
const seen = new Set();
|
| 1168 |
+
const candidates = [];
|
| 1169 |
+
const re = /(^|[\s"'`(=[])([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)/g;
|
| 1170 |
+
let m;
|
| 1171 |
+
while ((m = re.exec(stripped)) && candidates.length < 15) {
|
| 1172 |
+
const id = m[2].replace(/[.:,]+$/, "");
|
| 1173 |
+
if (seen.has(id)) continue;
|
| 1174 |
+
seen.add(id);
|
| 1175 |
+
if (FILE_EXT_RE.test(id)) continue;
|
| 1176 |
+
if (BARE_ID_SKIP_DIRS.has(id.split("/")[0].toLowerCase())) continue;
|
| 1177 |
+
candidates.push(id);
|
| 1178 |
+
}
|
| 1179 |
+
const results = await Promise.all(
|
| 1180 |
+
candidates.map((id) => getJSON(`https://huggingface.co/api/models/${id}`))
|
| 1181 |
+
);
|
| 1182 |
+
let added = false;
|
| 1183 |
+
const confirmed = [];
|
| 1184 |
+
results.forEach((d, i) => {
|
| 1185 |
+
if (!d || !d.id) return;
|
| 1186 |
+
const id = candidates[i];
|
| 1187 |
+
confirmed.push(id);
|
| 1188 |
+
const url = `https://huggingface.co/${id}`;
|
| 1189 |
+
if (!groups.has("model")) groups.set("model", new Map());
|
| 1190 |
+
if (!groups.get("model").has(url)) {
|
| 1191 |
+
groups.get("model").set(url, { kind: "model", id, url });
|
| 1192 |
+
added = true;
|
| 1193 |
+
}
|
| 1194 |
+
});
|
| 1195 |
+
return { added, confirmed };
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
function chipifyBareIds(ids, container) {
|
| 1199 |
+
if (!ids.length) return;
|
| 1200 |
+
const escaped = ids.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
| 1201 |
+
const pattern = new RegExp("(" + escaped.join("|") + ")");
|
| 1202 |
+
const splitter = new RegExp(pattern.source, "g");
|
| 1203 |
+
container
|
| 1204 |
+
.querySelectorAll(".cell.markdown .cell-body")
|
| 1205 |
+
.forEach((body) => {
|
| 1206 |
+
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
|
| 1207 |
+
acceptNode(node) {
|
| 1208 |
+
if (!pattern.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
|
| 1209 |
+
for (
|
| 1210 |
+
let el = node.parentElement;
|
| 1211 |
+
el && el !== body;
|
| 1212 |
+
el = el.parentElement
|
| 1213 |
+
) {
|
| 1214 |
+
if (["A", "CODE", "PRE", "BUTTON"].indexOf(el.tagName) !== -1) {
|
| 1215 |
+
return NodeFilter.FILTER_REJECT;
|
| 1216 |
+
}
|
| 1217 |
+
}
|
| 1218 |
+
return NodeFilter.FILTER_ACCEPT;
|
| 1219 |
+
},
|
| 1220 |
+
});
|
| 1221 |
+
const nodes = [];
|
| 1222 |
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
| 1223 |
+
nodes.forEach((node) => {
|
| 1224 |
+
const frag = document.createDocumentFragment();
|
| 1225 |
+
node.nodeValue.split(splitter).forEach((part) => {
|
| 1226 |
+
if (ids.indexOf(part) !== -1) {
|
| 1227 |
+
const holder = document.createElement("span");
|
| 1228 |
+
holder.innerHTML = resChipHtml({
|
| 1229 |
+
kind: "model",
|
| 1230 |
+
id: part,
|
| 1231 |
+
url: `https://huggingface.co/${part}`,
|
| 1232 |
+
});
|
| 1233 |
+
frag.appendChild(holder.firstChild);
|
| 1234 |
+
} else if (part) {
|
| 1235 |
+
frag.appendChild(document.createTextNode(part));
|
| 1236 |
+
}
|
| 1237 |
+
});
|
| 1238 |
+
node.parentNode.replaceChild(frag, node);
|
| 1239 |
+
});
|
| 1240 |
+
});
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
let RAIL_TOKEN = 0;
|
| 1244 |
+
const RAIL_EXCLUDE_KINDS = new Set(["paper", "repo", "artifact", "dashboard"]);
|
| 1245 |
+
|
| 1246 |
+
function railDashboardItem(it) {
|
| 1247 |
+
return {
|
| 1248 |
+
kind: "dashboard",
|
| 1249 |
+
id: it.id,
|
| 1250 |
+
url: it.local ? it.resUrl : it.url || it.resUrl,
|
| 1251 |
+
local: it.local,
|
| 1252 |
+
railLabel: "Dashboard",
|
| 1253 |
+
};
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
function promoteTrackioSpacesInRail(groups, dashResUrls, body, rail, token) {
|
| 1257 |
+
const spaceGroup = groups.get("space");
|
| 1258 |
+
if (!spaceGroup || !spaceGroup.size) return;
|
| 1259 |
+
spaceGroup.forEach((item, url) => {
|
| 1260 |
+
getJSON(`https://huggingface.co/api/spaces/${item.id}`)
|
| 1261 |
+
.then((d) => {
|
| 1262 |
+
if (rail.dataset.renderToken !== token) return;
|
| 1263 |
+
const tags = (d && d.tags) || [];
|
| 1264 |
+
if (!tags.some((t) => String(t).toLowerCase() === "trackio")) return;
|
| 1265 |
+
if (dashResUrls.has(url)) return;
|
| 1266 |
+
spaceGroup.delete(url);
|
| 1267 |
+
if (!spaceGroup.size) groups.delete("space");
|
| 1268 |
+
if (!groups.has("dashboard")) groups.set("dashboard", new Map());
|
| 1269 |
+
groups.get("dashboard").set(url, {
|
| 1270 |
+
kind: "dashboard",
|
| 1271 |
+
id: item.id,
|
| 1272 |
+
url: item.url,
|
| 1273 |
+
local: false,
|
| 1274 |
+
railLabel: "Dashboard",
|
| 1275 |
+
});
|
| 1276 |
+
dashResUrls.add(url);
|
| 1277 |
+
paintRail(groups, body, rail);
|
| 1278 |
+
})
|
| 1279 |
+
.catch(() => {});
|
| 1280 |
+
});
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
+
function renderRail(md, body, rail) {
|
| 1284 |
+
const token = String(++RAIL_TOKEN);
|
| 1285 |
+
rail.dataset.renderToken = token;
|
| 1286 |
+
const scanText = md.replace(
|
| 1287 |
+
/(`{3,4}|~{3,4})(html|raw)[^\n]*\n[\s\S]*?\n\1/g,
|
| 1288 |
+
" "
|
| 1289 |
+
);
|
| 1290 |
+
const groups = new Map();
|
| 1291 |
+
const dashMap = new Map();
|
| 1292 |
+
const dashResUrls = new Set();
|
| 1293 |
+
cellDashboardItems(md).forEach((it) => {
|
| 1294 |
+
if (dashMap.has(it.resUrl)) return;
|
| 1295 |
+
dashMap.set(it.resUrl, railDashboardItem(it));
|
| 1296 |
+
dashResUrls.add(it.resUrl);
|
| 1297 |
+
});
|
| 1298 |
+
if (dashMap.size) groups.set("dashboard", dashMap);
|
| 1299 |
+
extractUrls(scanText).forEach((url) => {
|
| 1300 |
+
const item = classifyResource(url);
|
| 1301 |
+
if (!item) return;
|
| 1302 |
+
if (RAIL_EXCLUDE_KINDS.has(item.kind)) return;
|
| 1303 |
+
if (dashResUrls.has(url)) return;
|
| 1304 |
+
if (!groups.has(item.kind)) groups.set(item.kind, new Map());
|
| 1305 |
+
groups.get(item.kind).set(item.url, item);
|
| 1306 |
+
});
|
| 1307 |
+
const artMap = new Map();
|
| 1308 |
+
cellArtifactItems(md).forEach((it) => {
|
| 1309 |
+
if (artMap.has(it.resUrl)) return;
|
| 1310 |
+
const label = it.type
|
| 1311 |
+
? it.type.charAt(0).toUpperCase() + it.type.slice(1)
|
| 1312 |
+
: "Artifact";
|
| 1313 |
+
artMap.set(it.resUrl, {
|
| 1314 |
+
kind: "artifact",
|
| 1315 |
+
id: it.name,
|
| 1316 |
+
url: it.local ? it.resUrl : it.url || it.resUrl,
|
| 1317 |
+
local: it.local,
|
| 1318 |
+
railLabel: label,
|
| 1319 |
+
size: it.size,
|
| 1320 |
+
});
|
| 1321 |
+
});
|
| 1322 |
+
if (artMap.size) groups.set("artifact", artMap);
|
| 1323 |
+
paintRail(groups, body, rail);
|
| 1324 |
+
promoteTrackioSpacesInRail(groups, dashResUrls, body, rail, token);
|
| 1325 |
+
detectBareModelIds(scanText, groups)
|
| 1326 |
+
.then((result) => {
|
| 1327 |
+
if (rail.dataset.renderToken !== token) return;
|
| 1328 |
+
chipifyBareIds(result.confirmed, body);
|
| 1329 |
+
if (result.added) paintRail(groups, body, rail);
|
| 1330 |
+
})
|
| 1331 |
+
.catch(() => {});
|
| 1332 |
+
}
|
| 1333 |
+
|
| 1334 |
+
function paintRail(groups, body, rail) {
|
| 1335 |
+
rail.innerHTML = "";
|
| 1336 |
+
RESOURCE_SECTIONS.forEach(([kind, label, icon]) => {
|
| 1337 |
+
const group = groups.get(kind);
|
| 1338 |
+
if (!group || !group.size) return;
|
| 1339 |
+
group.forEach((item) => {
|
| 1340 |
+
const el = document.createElement(item.local ? "div" : "a");
|
| 1341 |
+
el.className = item.local ? "rail-item rail-local" : "rail-item";
|
| 1342 |
+
if (!item.local) {
|
| 1343 |
+
el.href = item.url;
|
| 1344 |
+
el.target = "_blank";
|
| 1345 |
+
el.rel = "noopener";
|
| 1346 |
+
}
|
| 1347 |
+
el.dataset.resUrl = item.url;
|
| 1348 |
+
let desc;
|
| 1349 |
+
if (kind === "artifact") {
|
| 1350 |
+
const state = item.local ? "publish to share" : "Open ↗";
|
| 1351 |
+
desc = item.size ? `${item.size} · ${state}` : state;
|
| 1352 |
+
} else if (kind === "dashboard") {
|
| 1353 |
+
desc = item.local ? "publish to share" : "Open ↗";
|
| 1354 |
+
} else {
|
| 1355 |
+
desc = item.local ? "publish to share" : RESOURCE_DESC[kind];
|
| 1356 |
+
}
|
| 1357 |
+
const kindLabel = item.railLabel || label.replace(/s$/, "");
|
| 1358 |
+
const iconHtml =
|
| 1359 |
+
kind === "artifact"
|
| 1360 |
+
? ARTIFACT_ICON_IMG
|
| 1361 |
+
: kind === "dashboard"
|
| 1362 |
+
? DASHBOARD_ICON_IMG
|
| 1363 |
+
: `<span>${icon}</span>`;
|
| 1364 |
+
el.innerHTML =
|
| 1365 |
+
`<div class="rail-kind">${iconHtml}${esc(kindLabel)}</div>` +
|
| 1366 |
+
`<div class="rail-title">${esc(item.id)}</div>` +
|
| 1367 |
+
`<div class="rail-meta">${esc(desc)}</div>`;
|
| 1368 |
+
rail.appendChild(el);
|
| 1369 |
+
fillRailMeta(item, el)
|
| 1370 |
+
.catch(() => {})
|
| 1371 |
+
.finally(() => scheduleRailPosition(body, rail));
|
| 1372 |
+
});
|
| 1373 |
+
});
|
| 1374 |
+
rail.hidden = !rail.childElementCount;
|
| 1375 |
+
scheduleRailPosition(body, rail);
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
function resourceAnchor(body, url) {
|
| 1379 |
+
return body.querySelector(`[data-res-url="${CSS.escape(url)}"]`);
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
function positionRail(body, rail) {
|
| 1383 |
+
if (rail.hidden || !rail.isConnected) return;
|
| 1384 |
+
const bodyRect = body.getBoundingClientRect();
|
| 1385 |
+
const items = Array.from(rail.querySelectorAll(".rail-item")).map((el, index) => {
|
| 1386 |
+
const anchor = resourceAnchor(body, el.dataset.resUrl);
|
| 1387 |
+
return {
|
| 1388 |
+
el,
|
| 1389 |
+
index,
|
| 1390 |
+
desired: anchor
|
| 1391 |
+
? Math.max(0, anchor.getBoundingClientRect().top - bodyRect.top)
|
| 1392 |
+
: 0,
|
| 1393 |
+
};
|
| 1394 |
+
});
|
| 1395 |
+
items.sort((a, b) => a.desired - b.desired || a.index - b.index);
|
| 1396 |
+
let cursor = 0;
|
| 1397 |
+
items.forEach(({ el, desired }) => {
|
| 1398 |
+
const top = Math.max(desired, cursor);
|
| 1399 |
+
el.style.top = `${top}px`;
|
| 1400 |
+
cursor = top + el.offsetHeight + 10;
|
| 1401 |
+
});
|
| 1402 |
+
rail.style.minHeight = `${Math.max(body.offsetHeight, cursor)}px`;
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
function scheduleRailPosition(body, rail) {
|
| 1406 |
+
cancelAnimationFrame(Number(rail.dataset.positionFrame || 0));
|
| 1407 |
+
rail.dataset.positionFrame = String(
|
| 1408 |
+
requestAnimationFrame(() => positionRail(body, rail))
|
| 1409 |
+
);
|
| 1410 |
+
}
|
| 1411 |
+
|
| 1412 |
+
function dashboardSubdomainFromUrl(url) {
|
| 1413 |
+
return spaceIdFromUrl(url).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
function dashboardOpenLink(head, url) {
|
| 1417 |
+
if (!head || !url) return;
|
| 1418 |
+
const meta = head.querySelector(".cell-meta");
|
| 1419 |
+
if (!meta) return;
|
| 1420 |
+
let link = meta.querySelector(".cell-open");
|
| 1421 |
+
if (!link) {
|
| 1422 |
+
link = document.createElement("a");
|
| 1423 |
+
link.className = "cell-open";
|
| 1424 |
+
link.target = "_blank";
|
| 1425 |
+
link.rel = "noopener";
|
| 1426 |
+
meta.insertBefore(link, meta.firstChild);
|
| 1427 |
+
}
|
| 1428 |
+
link.href = url;
|
| 1429 |
+
link.textContent = "Open ↗";
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
function dashboardFrame(src) {
|
| 1433 |
+
const iframe = document.createElement("iframe");
|
| 1434 |
+
iframe.className = "dashboard-frame";
|
| 1435 |
+
iframe.src = src;
|
| 1436 |
+
iframe.loading = "lazy";
|
| 1437 |
+
iframe.allow = "clipboard-read; clipboard-write; fullscreen";
|
| 1438 |
+
return iframe;
|
| 1439 |
+
}
|
| 1440 |
+
|
| 1441 |
+
function renderDashboardCell(meta, body, container, head) {
|
| 1442 |
+
const project = meta.dashboard_project || "";
|
| 1443 |
+
const holder = document.createElement("div");
|
| 1444 |
+
holder.className = "dashboard-shell";
|
| 1445 |
+
container.appendChild(holder);
|
| 1446 |
+
const space = body.match(/https:\/\/huggingface\.co\/spaces\/[^\s<>)"'`]+/);
|
| 1447 |
+
if (space) {
|
| 1448 |
+
const url = space[0];
|
| 1449 |
+
dashboardOpenLink(head, url);
|
| 1450 |
+
holder.appendChild(
|
| 1451 |
+
dashboardFrame(
|
| 1452 |
+
`https://${dashboardSubdomainFromUrl(url)}.hf.space/?sidebar=hidden&hide_empty_tabs=true`
|
| 1453 |
+
)
|
| 1454 |
+
);
|
| 1455 |
+
return;
|
| 1456 |
+
}
|
| 1457 |
+
if (!isLocalPreview()) {
|
| 1458 |
+
holder.className = "artifact-chip";
|
| 1459 |
+
holder.dataset.resUrl = `trackio-local-dashboard://${project}`;
|
| 1460 |
+
holder.innerHTML =
|
| 1461 |
+
"🎯 <strong>Local Trackio dashboard</strong> — publish the logbook to share it";
|
| 1462 |
+
return;
|
| 1463 |
+
}
|
| 1464 |
+
const open = "/dashboard/?project=" + encodeURIComponent(project);
|
| 1465 |
+
dashboardOpenLink(head, open);
|
| 1466 |
+
holder.appendChild(
|
| 1467 |
+
dashboardFrame(open + "&sidebar=hidden&hide_empty_tabs=true"),
|
| 1468 |
+
);
|
| 1469 |
+
}
|
| 1470 |
+
|
| 1471 |
+
const CACHE_PREFIX = "trackio-logbook:";
|
| 1472 |
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
| 1473 |
+
const CACHE_MISS_TTL_MS = 60 * 60 * 1000;
|
| 1474 |
+
|
| 1475 |
+
function cacheGet(url) {
|
| 1476 |
+
try {
|
| 1477 |
+
const raw = localStorage.getItem(CACHE_PREFIX + url);
|
| 1478 |
+
if (!raw) return undefined;
|
| 1479 |
+
const entry = JSON.parse(raw);
|
| 1480 |
+
const ttl = entry.d === null ? CACHE_MISS_TTL_MS : CACHE_TTL_MS;
|
| 1481 |
+
if (Date.now() - entry.t > ttl) {
|
| 1482 |
+
localStorage.removeItem(CACHE_PREFIX + url);
|
| 1483 |
+
return undefined;
|
| 1484 |
+
}
|
| 1485 |
+
return entry.d;
|
| 1486 |
+
} catch (e) {
|
| 1487 |
+
return undefined;
|
| 1488 |
+
}
|
| 1489 |
+
}
|
| 1490 |
+
|
| 1491 |
+
function cacheSet(url, data) {
|
| 1492 |
+
try {
|
| 1493 |
+
localStorage.setItem(
|
| 1494 |
+
CACHE_PREFIX + url,
|
| 1495 |
+
JSON.stringify({ t: Date.now(), d: data })
|
| 1496 |
+
);
|
| 1497 |
+
} catch (e) {}
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
async function getJSON(url) {
|
| 1501 |
+
if (UNFURL_CACHE[url] !== undefined) return UNFURL_CACHE[url];
|
| 1502 |
+
const cached = cacheGet(url);
|
| 1503 |
+
if (cached !== undefined) {
|
| 1504 |
+
UNFURL_CACHE[url] = cached;
|
| 1505 |
+
return cached;
|
| 1506 |
+
}
|
| 1507 |
+
try {
|
| 1508 |
+
const r = await fetch(url);
|
| 1509 |
+
if (!r.ok) throw new Error(r.status);
|
| 1510 |
+
const j = await r.json();
|
| 1511 |
+
UNFURL_CACHE[url] = j;
|
| 1512 |
+
cacheSet(url, j);
|
| 1513 |
+
return j;
|
| 1514 |
+
} catch (e) {
|
| 1515 |
+
UNFURL_CACHE[url] = null;
|
| 1516 |
+
cacheSet(url, null);
|
| 1517 |
+
return null;
|
| 1518 |
+
}
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
/* -------------------- routing / render -------------------- */
|
| 1522 |
+
|
| 1523 |
+
function buildTree() {
|
| 1524 |
+
const tree = document.getElementById("tree");
|
| 1525 |
+
tree.innerHTML = "";
|
| 1526 |
+
const nodes = [];
|
| 1527 |
+
(MANIFEST.root.children || []).forEach((c) => flattenTree(c, 0, nodes));
|
| 1528 |
+
nodes.forEach(({ node, depth }) => {
|
| 1529 |
+
const a = document.createElement("a");
|
| 1530 |
+
a.href = "#/" + node.slug;
|
| 1531 |
+
a.className = "depth-" + depth;
|
| 1532 |
+
a.dataset.slug = node.slug;
|
| 1533 |
+
const mark = document.createElement("span");
|
| 1534 |
+
mark.className = "tree-mark";
|
| 1535 |
+
mark.textContent = "§";
|
| 1536 |
+
a.appendChild(mark);
|
| 1537 |
+
a.appendChild(document.createTextNode(" " + node.title));
|
| 1538 |
+
tree.appendChild(a);
|
| 1539 |
+
});
|
| 1540 |
+
}
|
| 1541 |
+
|
| 1542 |
+
function highlight(slug) {
|
| 1543 |
+
document
|
| 1544 |
+
.querySelectorAll("#tree a")
|
| 1545 |
+
.forEach((a) => a.classList.toggle("active", a.dataset.slug === slug));
|
| 1546 |
+
document
|
| 1547 |
+
.getElementById("book-head")
|
| 1548 |
+
.classList.toggle("active", slug === MANIFEST.root.slug);
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
function clearPageCache() {
|
| 1552 |
+
Object.keys(PAGE_CACHE).forEach((key) => {
|
| 1553 |
+
delete PAGE_CACHE[key];
|
| 1554 |
+
});
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
function isLocalPreview() {
|
| 1558 |
+
return ["localhost", "127.0.0.1", "::1"].includes(location.hostname);
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
async function fetchManifest() {
|
| 1562 |
+
const suffix = isLocalPreview() ? `?t=${Date.now()}` : "";
|
| 1563 |
+
return await (await fetch("./logbook.json" + suffix, { cache: "no-store" })).json();
|
| 1564 |
+
}
|
| 1565 |
+
|
| 1566 |
+
async function fetchPage(node) {
|
| 1567 |
+
if (PAGE_CACHE[node.file]) return PAGE_CACHE[node.file];
|
| 1568 |
+
try {
|
| 1569 |
+
const suffix = isLocalPreview()
|
| 1570 |
+
? `?rev=${encodeURIComponent(MANIFEST.revision || "")}`
|
| 1571 |
+
: "";
|
| 1572 |
+
const r = await fetch("./" + node.file + suffix, { cache: "no-store" });
|
| 1573 |
+
PAGE_CACHE[node.file] = await r.text();
|
| 1574 |
+
} catch (e) {
|
| 1575 |
+
PAGE_CACHE[node.file] = "# " + node.title + "\n\n_Could not load section._";
|
| 1576 |
+
}
|
| 1577 |
+
return PAGE_CACHE[node.file];
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
function allNodes() {
|
| 1581 |
+
const nodes = [];
|
| 1582 |
+
flattenTree(MANIFEST.root, 0, nodes);
|
| 1583 |
+
return nodes.map(({ node }) => node);
|
| 1584 |
+
}
|
| 1585 |
+
|
| 1586 |
+
function collectPinnedCells(markdown, nodes) {
|
| 1587 |
+
const cells = [];
|
| 1588 |
+
markdown.forEach((text, index) => {
|
| 1589 |
+
const cellRe = /(^|\n)---\n<!-- trackio-cell\n([\s\S]*?)\n-->\n([\s\S]*?)(?=\n---\n<!-- trackio-cell\n|\s*$)/g;
|
| 1590 |
+
let match;
|
| 1591 |
+
let cellIndex = 0;
|
| 1592 |
+
while ((match = cellRe.exec(text))) {
|
| 1593 |
+
const meta = parseCellMeta(match[2]);
|
| 1594 |
+
if (isPinned(meta)) {
|
| 1595 |
+
cells.push({
|
| 1596 |
+
meta,
|
| 1597 |
+
body: match[3],
|
| 1598 |
+
node: nodes[index],
|
| 1599 |
+
index: cells.length,
|
| 1600 |
+
order: meta.pinned_at || meta.created_at || "",
|
| 1601 |
+
cellIndex,
|
| 1602 |
+
});
|
| 1603 |
+
}
|
| 1604 |
+
cellIndex++;
|
| 1605 |
+
}
|
| 1606 |
+
});
|
| 1607 |
+
return cells.sort(
|
| 1608 |
+
(a, b) =>
|
| 1609 |
+
a.order.localeCompare(b.order) ||
|
| 1610 |
+
a.index - b.index ||
|
| 1611 |
+
a.cellIndex - b.cellIndex
|
| 1612 |
+
);
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
function renderPinnedNotes(cells, container) {
|
| 1616 |
+
if (!cells.length) return;
|
| 1617 |
+
const deck = document.createElement("section");
|
| 1618 |
+
deck.className = "pinned-notes";
|
| 1619 |
+
const list = document.createElement("div");
|
| 1620 |
+
list.className = "pinned-notes-list";
|
| 1621 |
+
cells.forEach(({ meta, body }) => {
|
| 1622 |
+
const cell = renderCell(meta, body, list);
|
| 1623 |
+
cell.classList.add("pinned-copy");
|
| 1624 |
+
});
|
| 1625 |
+
deck.appendChild(list);
|
| 1626 |
+
const anchor =
|
| 1627 |
+
container.querySelector(".logbook-stats") ||
|
| 1628 |
+
container.querySelector(".agent-hint");
|
| 1629 |
+
container.insertBefore(deck, anchor ? anchor.nextSibling : container.firstChild);
|
| 1630 |
+
container.closest(".book-intro").classList.add("has-pinned-notes");
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
function removeIndexProse(body) {
|
| 1634 |
+
const h1 = Array.from(body.children).find((el) => el.tagName === "H1");
|
| 1635 |
+
if (!h1) return;
|
| 1636 |
+
let current = h1.nextElementSibling;
|
| 1637 |
+
while (current && current.tagName !== "H2") {
|
| 1638 |
+
const next = current.nextElementSibling;
|
| 1639 |
+
current.remove();
|
| 1640 |
+
current = next;
|
| 1641 |
+
}
|
| 1642 |
+
}
|
| 1643 |
+
|
| 1644 |
+
function removePageDirectory(body) {
|
| 1645 |
+
const heading = Array.from(body.children).find(
|
| 1646 |
+
(el) => el.tagName === "H2" && el.textContent.trim().toLowerCase() === "pages"
|
| 1647 |
+
);
|
| 1648 |
+
if (!heading) return;
|
| 1649 |
+
let current = heading;
|
| 1650 |
+
while (current) {
|
| 1651 |
+
const next = current.nextElementSibling;
|
| 1652 |
+
current.remove();
|
| 1653 |
+
if (next && ["H1", "H2"].includes(next.tagName)) break;
|
| 1654 |
+
current = next;
|
| 1655 |
+
}
|
| 1656 |
+
}
|
| 1657 |
+
|
| 1658 |
+
const RAIL_OBSERVERS = [];
|
| 1659 |
+
|
| 1660 |
+
async function renderLogbook(opts = {}) {
|
| 1661 |
+
const scrollY = window.scrollY;
|
| 1662 |
+
const page = document.getElementById("page");
|
| 1663 |
+
RAIL_OBSERVERS.splice(0).forEach((observer) => observer.disconnect());
|
| 1664 |
+
page.innerHTML = "";
|
| 1665 |
+
const nodes = allNodes();
|
| 1666 |
+
const markdown = await Promise.all(nodes.map(fetchPage));
|
| 1667 |
+
const pinnedCells = collectPinnedCells(markdown, nodes);
|
| 1668 |
+
let bookIntroBody = null;
|
| 1669 |
+
nodes.forEach((node, index) => {
|
| 1670 |
+
const section = document.createElement("section");
|
| 1671 |
+
section.className = "page-section";
|
| 1672 |
+
section.id = "/" + node.slug;
|
| 1673 |
+
section.dataset.slug = node.slug;
|
| 1674 |
+
|
| 1675 |
+
const layout = document.createElement("div");
|
| 1676 |
+
layout.className = "page-layout";
|
| 1677 |
+
const body = document.createElement("div");
|
| 1678 |
+
body.className = "page-body";
|
| 1679 |
+
const rail = document.createElement("aside");
|
| 1680 |
+
rail.className = "context-rail";
|
| 1681 |
+
rail.setAttribute("aria-label", `Resources for ${node.title}`);
|
| 1682 |
+
|
| 1683 |
+
renderMarkdown(markdown[index], body);
|
| 1684 |
+
if (node.slug === MANIFEST.root.slug) {
|
| 1685 |
+
section.classList.add("book-intro");
|
| 1686 |
+
removeIndexProse(body);
|
| 1687 |
+
removePageDirectory(body);
|
| 1688 |
+
const hint = buildAgentHint();
|
| 1689 |
+
const h1 = body.querySelector("h1");
|
| 1690 |
+
if (h1 && h1.parentNode === body) {
|
| 1691 |
+
body.insertBefore(hint, h1.nextSibling);
|
| 1692 |
+
} else {
|
| 1693 |
+
body.prepend(hint);
|
| 1694 |
+
}
|
| 1695 |
+
hint.after(buildLogbookStats(markdown));
|
| 1696 |
+
bookIntroBody = body;
|
| 1697 |
+
}
|
| 1698 |
+
layout.appendChild(body);
|
| 1699 |
+
layout.appendChild(rail);
|
| 1700 |
+
section.appendChild(layout);
|
| 1701 |
+
page.appendChild(section);
|
| 1702 |
+
renderRail(markdown[index], body, rail);
|
| 1703 |
+
if (window.ResizeObserver) {
|
| 1704 |
+
const observer = new ResizeObserver(() => scheduleRailPosition(body, rail));
|
| 1705 |
+
observer.observe(body);
|
| 1706 |
+
observer.observe(rail);
|
| 1707 |
+
RAIL_OBSERVERS.push(observer);
|
| 1708 |
+
}
|
| 1709 |
+
});
|
| 1710 |
+
if (bookIntroBody) renderPinnedNotes(pinnedCells, bookIntroBody);
|
| 1711 |
+
if (bookIntroBody) {
|
| 1712 |
+
const section = bookIntroBody.closest(".book-intro");
|
| 1713 |
+
const hasExtra = Array.from(bookIntroBody.children).some(
|
| 1714 |
+
(el) =>
|
| 1715 |
+
el.tagName !== "H1" &&
|
| 1716 |
+
!el.classList.contains("agent-hint") &&
|
| 1717 |
+
!el.classList.contains("logbook-stats") &&
|
| 1718 |
+
!el.classList.contains("pinned-notes")
|
| 1719 |
+
);
|
| 1720 |
+
if (section && !section.classList.contains("has-pinned-notes") && !hasExtra) {
|
| 1721 |
+
section.classList.add("book-intro-tight");
|
| 1722 |
+
}
|
| 1723 |
+
}
|
| 1724 |
+
requestAnimationFrame(() => {
|
| 1725 |
+
if (opts.preserveScroll) {
|
| 1726 |
+
window.scrollTo(0, scrollY);
|
| 1727 |
+
} else {
|
| 1728 |
+
scrollToHash({ behavior: "auto" });
|
| 1729 |
+
}
|
| 1730 |
+
updateActiveSection();
|
| 1731 |
+
});
|
| 1732 |
+
}
|
| 1733 |
+
|
| 1734 |
+
function setupResourceHover() {
|
| 1735 |
+
document.addEventListener("mouseover", (e) => {
|
| 1736 |
+
const el = e.target.closest && e.target.closest("[data-res-url]");
|
| 1737 |
+
if (!el || el.classList.contains("rail-item")) return;
|
| 1738 |
+
const url = el.getAttribute("data-res-url");
|
| 1739 |
+
const section = el.closest(".page-section");
|
| 1740 |
+
const scope = section || document;
|
| 1741 |
+
scope.querySelectorAll(".context-rail [data-res-url]").forEach((n) => {
|
| 1742 |
+
n.classList.toggle("res-hl", n.getAttribute("data-res-url") === url);
|
| 1743 |
+
});
|
| 1744 |
+
});
|
| 1745 |
+
document.addEventListener("mouseout", (e) => {
|
| 1746 |
+
const el = e.target.closest && e.target.closest("[data-res-url]");
|
| 1747 |
+
if (!el || el.classList.contains("rail-item")) return;
|
| 1748 |
+
document.querySelectorAll(".context-rail .res-hl").forEach((n) => {
|
| 1749 |
+
n.classList.remove("res-hl");
|
| 1750 |
+
});
|
| 1751 |
+
});
|
| 1752 |
+
}
|
| 1753 |
+
|
| 1754 |
+
let STATS_TOKEN = 0;
|
| 1755 |
+
let STATS_LISTENERS = false;
|
| 1756 |
+
|
| 1757 |
+
function fmtBytes(n) {
|
| 1758 |
+
if (n == null || isNaN(n)) return null;
|
| 1759 |
+
if (n < 1000) return `${n} B`;
|
| 1760 |
+
const units = ["kB", "MB", "GB", "TB"];
|
| 1761 |
+
let v = n;
|
| 1762 |
+
let i = -1;
|
| 1763 |
+
do {
|
| 1764 |
+
v /= 1000;
|
| 1765 |
+
i++;
|
| 1766 |
+
} while (v >= 1000 && i < units.length - 1);
|
| 1767 |
+
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
| 1768 |
+
}
|
| 1769 |
+
|
| 1770 |
+
function spaceIdFromUrl(url) {
|
| 1771 |
+
return url.split("/spaces/")[1].split(/[?#]/)[0].replace(/\/$/, "");
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
const LB_CELL_RE = /(^|\n)---\n<!-- trackio-cell\n([\s\S]*?)\n-->\n([\s\S]*?)(?=\n---\n<!-- trackio-cell\n|\s*$)/g;
|
| 1775 |
+
|
| 1776 |
+
function cellDashboardItems(md) {
|
| 1777 |
+
const re = new RegExp(LB_CELL_RE.source, "g");
|
| 1778 |
+
const items = [];
|
| 1779 |
+
let m;
|
| 1780 |
+
while ((m = re.exec(md))) {
|
| 1781 |
+
const meta = parseCellMeta(m[2]);
|
| 1782 |
+
if (meta.type !== "dashboard") continue;
|
| 1783 |
+
const body = m[3];
|
| 1784 |
+
const project = meta.dashboard_project || "";
|
| 1785 |
+
const sp = body.match(/https:\/\/huggingface\.co\/spaces\/[^\s<>)"'`]+/);
|
| 1786 |
+
const local = !sp;
|
| 1787 |
+
const url = sp ? sp[0] : "";
|
| 1788 |
+
const resUrl = local ? `trackio-local-dashboard://${project}` : url;
|
| 1789 |
+
items.push({
|
| 1790 |
+
id: local ? project : spaceIdFromUrl(url),
|
| 1791 |
+
local,
|
| 1792 |
+
url,
|
| 1793 |
+
resUrl,
|
| 1794 |
+
});
|
| 1795 |
+
}
|
| 1796 |
+
return items;
|
| 1797 |
+
}
|
| 1798 |
+
|
| 1799 |
+
function artifactInfoFromCell(meta, body) {
|
| 1800 |
+
const name = meta.artifact || meta.path || "";
|
| 1801 |
+
let size = null;
|
| 1802 |
+
const sm = body.match(/·\s*([\d.]+\s*[kMGT]?B)\b/);
|
| 1803 |
+
if (sm) size = sm[1].trim();
|
| 1804 |
+
if (!size && meta.size != null) size = fmtBytes(meta.size);
|
| 1805 |
+
const bucket = body.match(/https:\/\/huggingface\.co\/buckets\/[^\s<>)"'`]+/);
|
| 1806 |
+
const artUri = body.match(/trackio-artifact:\/\/\S+/);
|
| 1807 |
+
const pathUri = body.match(/trackio-local-path:\/\/\S+/);
|
| 1808 |
+
const url = bucket ? bucket[0] : "";
|
| 1809 |
+
const local = !bucket;
|
| 1810 |
+
const resUrl =
|
| 1811 |
+
url || (artUri ? artUri[0] : pathUri ? pathUri[0] : `trackio-artifact://${name}`);
|
| 1812 |
+
return {
|
| 1813 |
+
name,
|
| 1814 |
+
type: meta.artifact_type || "",
|
| 1815 |
+
size,
|
| 1816 |
+
local,
|
| 1817 |
+
isPathRef: !!meta.path,
|
| 1818 |
+
url,
|
| 1819 |
+
resUrl,
|
| 1820 |
+
};
|
| 1821 |
+
}
|
| 1822 |
+
|
| 1823 |
+
function cellArtifactItems(md) {
|
| 1824 |
+
const re = new RegExp(LB_CELL_RE.source, "g");
|
| 1825 |
+
const items = [];
|
| 1826 |
+
let m;
|
| 1827 |
+
while ((m = re.exec(md))) {
|
| 1828 |
+
const meta = parseCellMeta(m[2]);
|
| 1829 |
+
const body = m[3];
|
| 1830 |
+
const order = meta.created_at || "";
|
| 1831 |
+
if (meta.type === "artifact") {
|
| 1832 |
+
const info = artifactInfoFromCell(meta, body);
|
| 1833 |
+
if (info.name) items.push({ ...info, order });
|
| 1834 |
+
}
|
| 1835 |
+
}
|
| 1836 |
+
return items;
|
| 1837 |
+
}
|
| 1838 |
+
|
| 1839 |
+
function collectLogbookResources(markdownList) {
|
| 1840 |
+
const re = new RegExp(LB_CELL_RE.source, "g");
|
| 1841 |
+
const dashboards = new Map();
|
| 1842 |
+
markdownList.forEach((md) => {
|
| 1843 |
+
let m;
|
| 1844 |
+
while ((m = re.exec(md))) {
|
| 1845 |
+
const meta = parseCellMeta(m[2]);
|
| 1846 |
+
const body = m[3];
|
| 1847 |
+
if (meta.type !== "dashboard") continue;
|
| 1848 |
+
const project = meta.dashboard_project || "";
|
| 1849 |
+
const space = body.match(/https:\/\/huggingface\.co\/spaces\/[^\s<>)"'`]+/);
|
| 1850 |
+
const local = !space;
|
| 1851 |
+
const url = space ? space[0] : "";
|
| 1852 |
+
const key = local ? `local:${project}` : `space:${spaceIdFromUrl(url)}`;
|
| 1853 |
+
const resUrl = local ? `trackio-local-dashboard://${project}` : url;
|
| 1854 |
+
if (!dashboards.has(key))
|
| 1855 |
+
dashboards.set(key, { project, local, url, resUrl });
|
| 1856 |
+
}
|
| 1857 |
+
});
|
| 1858 |
+
const artifacts = new Map();
|
| 1859 |
+
markdownList.forEach((md) => {
|
| 1860 |
+
cellArtifactItems(md).forEach((it) => {
|
| 1861 |
+
const key = `${it.type}:${it.name}`;
|
| 1862 |
+
const prev = artifacts.get(key);
|
| 1863 |
+
if (!prev || it.order >= prev.order) artifacts.set(key, it);
|
| 1864 |
+
});
|
| 1865 |
+
});
|
| 1866 |
+
return {
|
| 1867 |
+
dashboards: Array.from(dashboards.values()).sort((a, b) =>
|
| 1868 |
+
a.project.localeCompare(b.project)
|
| 1869 |
+
),
|
| 1870 |
+
artifacts: Array.from(artifacts.values()).sort((a, b) =>
|
| 1871 |
+
a.name.localeCompare(b.name)
|
| 1872 |
+
),
|
| 1873 |
+
};
|
| 1874 |
+
}
|
| 1875 |
+
|
| 1876 |
+
function closeStatPopovers() {
|
| 1877 |
+
document
|
| 1878 |
+
.querySelectorAll(".stat-popover")
|
| 1879 |
+
.forEach((p) => (p.hidden = true));
|
| 1880 |
+
document
|
| 1881 |
+
.querySelectorAll(".stat-tile.open")
|
| 1882 |
+
.forEach((t) => t.classList.remove("open"));
|
| 1883 |
+
}
|
| 1884 |
+
|
| 1885 |
+
function ensureStatListeners() {
|
| 1886 |
+
if (STATS_LISTENERS) return;
|
| 1887 |
+
STATS_LISTENERS = true;
|
| 1888 |
+
document.addEventListener("click", closeStatPopovers);
|
| 1889 |
+
document.addEventListener("keydown", (e) => {
|
| 1890 |
+
if (e.key === "Escape") closeStatPopovers();
|
| 1891 |
+
});
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
function stateHtml(remote, url) {
|
| 1895 |
+
return remote
|
| 1896 |
+
? `<a class="stat-row-state open" href="${esc(url)}" target="_blank" rel="noopener" title="Open in a new tab">Open ↗</a>`
|
| 1897 |
+
: `<span class="stat-row-state">publish to share</span>`;
|
| 1898 |
+
}
|
| 1899 |
+
|
| 1900 |
+
function scrollToResource(resUrl) {
|
| 1901 |
+
closeStatPopovers();
|
| 1902 |
+
if (!resUrl) return;
|
| 1903 |
+
const el = document.querySelector(
|
| 1904 |
+
`#page .page-body [data-res-url="${CSS.escape(resUrl)}"]:not(.stat-row)`
|
| 1905 |
+
);
|
| 1906 |
+
if (!el) return;
|
| 1907 |
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
| 1908 |
+
el.classList.add("res-flash");
|
| 1909 |
+
setTimeout(() => el.classList.remove("res-flash"), 1500);
|
| 1910 |
+
}
|
| 1911 |
+
|
| 1912 |
+
function dashRowHtml(d) {
|
| 1913 |
+
const inner =
|
| 1914 |
+
`<span class="stat-row-ico">${DASHBOARD_ICON_IMG}</span>` +
|
| 1915 |
+
`<div class="stat-row-main"><div class="stat-row-title">${esc(d.project)}</div>` +
|
| 1916 |
+
`<div class="stat-row-meta">${stateHtml(!d.local, d.url)}</div></div>`;
|
| 1917 |
+
return `<div class="stat-row" data-res-url="${esc(d.resUrl)}" title="Jump to it in the logbook">${inner}</div>`;
|
| 1918 |
+
}
|
| 1919 |
+
|
| 1920 |
+
function artRowHtml(a) {
|
| 1921 |
+
const remote = !a.local && !!a.url;
|
| 1922 |
+
const parts = [a.type, a.size].filter(Boolean).map(esc);
|
| 1923 |
+
const meta = parts.length
|
| 1924 |
+
? `${parts.join(" · ")} · ${stateHtml(remote, a.url)}`
|
| 1925 |
+
: stateHtml(remote, a.url);
|
| 1926 |
+
const inner =
|
| 1927 |
+
`<span class="stat-row-ico">${ARTIFACT_ICON_IMG}</span>` +
|
| 1928 |
+
`<div class="stat-row-main"><div class="stat-row-title">${esc(a.name)}</div>` +
|
| 1929 |
+
`<div class="stat-row-meta">${meta}</div></div>`;
|
| 1930 |
+
return `<div class="stat-row" data-res-url="${esc(a.resUrl)}" title="Jump to it in the logbook">${inner}</div>`;
|
| 1931 |
+
}
|
| 1932 |
+
|
| 1933 |
+
function statTile(icon, alt, singular, plural, head, rowFn) {
|
| 1934 |
+
const tile = document.createElement("button");
|
| 1935 |
+
tile.type = "button";
|
| 1936 |
+
tile.className = "stat-tile";
|
| 1937 |
+
const render = (items) => {
|
| 1938 |
+
const count = items.length;
|
| 1939 |
+
const label = count === 1 ? singular : plural;
|
| 1940 |
+
const caret = count > 0 ? `<span class="stat-caret">▾</span>` : "";
|
| 1941 |
+
tile.innerHTML =
|
| 1942 |
+
`<img class="stat-icon" src="${icon}" alt="${esc(alt)}" />` +
|
| 1943 |
+
`<div class="stat-text"><div class="stat-num">${count}</div>` +
|
| 1944 |
+
`<div class="stat-label">${esc(label)}</div></div>` +
|
| 1945 |
+
caret;
|
| 1946 |
+
tile.disabled = count === 0;
|
| 1947 |
+
if (count > 0) {
|
| 1948 |
+
const pop = document.createElement("div");
|
| 1949 |
+
pop.className = "stat-popover";
|
| 1950 |
+
pop.hidden = true;
|
| 1951 |
+
pop.innerHTML =
|
| 1952 |
+
`<div class="stat-pop-head">${esc(head)}</div>` +
|
| 1953 |
+
items.map(rowFn).join("");
|
| 1954 |
+
pop.addEventListener("click", (e) => {
|
| 1955 |
+
if (e.target.closest("a.stat-row-state")) {
|
| 1956 |
+
e.stopPropagation();
|
| 1957 |
+
return;
|
| 1958 |
+
}
|
| 1959 |
+
e.stopPropagation();
|
| 1960 |
+
const row = e.target.closest(".stat-row");
|
| 1961 |
+
if (row) scrollToResource(row.dataset.resUrl);
|
| 1962 |
+
});
|
| 1963 |
+
tile.appendChild(pop);
|
| 1964 |
+
}
|
| 1965 |
+
};
|
| 1966 |
+
tile.addEventListener("click", (e) => {
|
| 1967 |
+
if (tile.disabled) return;
|
| 1968 |
+
e.stopPropagation();
|
| 1969 |
+
const pop = tile.querySelector(".stat-popover");
|
| 1970 |
+
if (!pop) return;
|
| 1971 |
+
const isOpen = !pop.hidden;
|
| 1972 |
+
closeStatPopovers();
|
| 1973 |
+
if (!isOpen) {
|
| 1974 |
+
pop.hidden = false;
|
| 1975 |
+
tile.classList.add("open");
|
| 1976 |
+
}
|
| 1977 |
+
});
|
| 1978 |
+
return { tile, render };
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
function buildLogbookStats(markdownList) {
|
| 1982 |
+
const token = ++STATS_TOKEN;
|
| 1983 |
+
ensureStatListeners();
|
| 1984 |
+
const { dashboards, artifacts } = collectLogbookResources(markdownList);
|
| 1985 |
+
|
| 1986 |
+
const el = document.createElement("div");
|
| 1987 |
+
el.className = "logbook-stats";
|
| 1988 |
+
const dash = statTile(
|
| 1989 |
+
"./trackio-logo-light.png",
|
| 1990 |
+
"Trackio",
|
| 1991 |
+
"Trackio Dashboard",
|
| 1992 |
+
"Trackio Dashboards",
|
| 1993 |
+
"Dashboards created in this logbook",
|
| 1994 |
+
dashRowHtml
|
| 1995 |
+
);
|
| 1996 |
+
const art = statTile(
|
| 1997 |
+
"./bucket-icon.svg",
|
| 1998 |
+
"Bucket",
|
| 1999 |
+
"Artifact",
|
| 2000 |
+
"Artifacts",
|
| 2001 |
+
"Artifacts created in this logbook",
|
| 2002 |
+
artRowHtml
|
| 2003 |
+
);
|
| 2004 |
+
dash.render(dashboards);
|
| 2005 |
+
art.render(artifacts);
|
| 2006 |
+
el.appendChild(dash.tile);
|
| 2007 |
+
el.appendChild(art.tile);
|
| 2008 |
+
|
| 2009 |
+
const scanText = markdownList
|
| 2010 |
+
.map((md) =>
|
| 2011 |
+
md.replace(/(`{3,4}|~{3,4})(html|raw)[^\n]*\n[\s\S]*?\n\1/g, " ")
|
| 2012 |
+
)
|
| 2013 |
+
.join("\n");
|
| 2014 |
+
const seen = new Set(
|
| 2015 |
+
dashboards.map((d) =>
|
| 2016 |
+
d.local ? `local:${d.project}` : `space:${spaceIdFromUrl(d.url)}`
|
| 2017 |
+
)
|
| 2018 |
+
);
|
| 2019 |
+
const remoteSpaces = new Map();
|
| 2020 |
+
extractUrls(scanText).forEach((url) => {
|
| 2021 |
+
const item = classifyResource(url);
|
| 2022 |
+
if (item && item.kind === "space" && !item.local) {
|
| 2023 |
+
remoteSpaces.set(item.url, item);
|
| 2024 |
+
}
|
| 2025 |
+
});
|
| 2026 |
+
remoteSpaces.forEach((s) => {
|
| 2027 |
+
const key = `space:${s.id}`;
|
| 2028 |
+
if (seen.has(key)) return;
|
| 2029 |
+
getJSON(`https://huggingface.co/api/spaces/${s.id}`)
|
| 2030 |
+
.then((d) => {
|
| 2031 |
+
if (STATS_TOKEN !== token) return;
|
| 2032 |
+
const tags = (d && d.tags) || [];
|
| 2033 |
+
if (
|
| 2034 |
+
!seen.has(key) &&
|
| 2035 |
+
tags.some((t) => String(t).toLowerCase() === "trackio")
|
| 2036 |
+
) {
|
| 2037 |
+
seen.add(key);
|
| 2038 |
+
dashboards.push({
|
| 2039 |
+
project: s.id,
|
| 2040 |
+
local: false,
|
| 2041 |
+
url: s.url,
|
| 2042 |
+
resUrl: s.url,
|
| 2043 |
+
});
|
| 2044 |
+
dashboards.sort((a, b) => a.project.localeCompare(b.project));
|
| 2045 |
+
dash.render(dashboards);
|
| 2046 |
+
}
|
| 2047 |
+
})
|
| 2048 |
+
.catch(() => {});
|
| 2049 |
+
});
|
| 2050 |
+
return el;
|
| 2051 |
+
}
|
| 2052 |
+
|
| 2053 |
+
function buildAgentHint() {
|
| 2054 |
+
const onSpaces =
|
| 2055 |
+
/\.hf\.space$/.test(location.hostname) ||
|
| 2056 |
+
/(^|\.)huggingface\.co$/.test(location.hostname);
|
| 2057 |
+
let source = "";
|
| 2058 |
+
if (onSpaces && MANIFEST.space_id) {
|
| 2059 |
+
source = ` ${MANIFEST.space_id}`;
|
| 2060 |
+
} else if (/^https?:$/.test(location.protocol)) {
|
| 2061 |
+
source = ` ${location.origin}/`;
|
| 2062 |
+
}
|
| 2063 |
+
const command = `trackio logbook read${source}`;
|
| 2064 |
+
const tokens = MANIFEST.agent_view_tokens;
|
| 2065 |
+
const div = document.createElement("div");
|
| 2066 |
+
div.className = "agent-hint";
|
| 2067 |
+
const label = document.createElement("span");
|
| 2068 |
+
label.className = "agent-hint-label";
|
| 2069 |
+
label.textContent = "Read from the CLI:";
|
| 2070 |
+
const code = document.createElement("code");
|
| 2071 |
+
code.textContent = command;
|
| 2072 |
+
const copy = document.createElement("button");
|
| 2073 |
+
copy.className = "copy";
|
| 2074 |
+
copy.type = "button";
|
| 2075 |
+
copy.title = "Copy";
|
| 2076 |
+
copy.textContent = "⧉";
|
| 2077 |
+
copy.addEventListener("click", () => copyText(command, copy, "⧉"));
|
| 2078 |
+
const note = document.createElement("span");
|
| 2079 |
+
note.className = "agent-hint-note";
|
| 2080 |
+
note.textContent =
|
| 2081 |
+
"compact view for agents" + (tokens ? ` · ~${fmt(tokens)} tokens` : "");
|
| 2082 |
+
div.appendChild(label);
|
| 2083 |
+
div.appendChild(code);
|
| 2084 |
+
div.appendChild(copy);
|
| 2085 |
+
div.appendChild(note);
|
| 2086 |
+
return div;
|
| 2087 |
+
}
|
| 2088 |
+
|
| 2089 |
+
function currentSlug() {
|
| 2090 |
+
const slug = (location.hash || "").replace(/^#\//, "") || MANIFEST.root.slug;
|
| 2091 |
+
return findNode(MANIFEST.root, slug) ? slug : MANIFEST.root.slug;
|
| 2092 |
+
}
|
| 2093 |
+
|
| 2094 |
+
function scrollToHash(opts = {}) {
|
| 2095 |
+
const slug = currentSlug();
|
| 2096 |
+
if (!location.hash) {
|
| 2097 |
+
window.scrollTo({ top: 0, behavior: opts.behavior || "auto" });
|
| 2098 |
+
highlight(slug);
|
| 2099 |
+
return;
|
| 2100 |
+
}
|
| 2101 |
+
const section = document.getElementById("/" + slug);
|
| 2102 |
+
if (section) section.scrollIntoView({ behavior: opts.behavior || "smooth" });
|
| 2103 |
+
highlight(slug);
|
| 2104 |
+
}
|
| 2105 |
+
|
| 2106 |
+
function navigateToLogbookSlug(target) {
|
| 2107 |
+
const slug = String(target || "").replace(/^#?\//, "").trim();
|
| 2108 |
+
if (!slug || !findNode(MANIFEST.root, slug)) return;
|
| 2109 |
+
const hash = "#/" + slug;
|
| 2110 |
+
if (location.hash === hash) {
|
| 2111 |
+
scrollToHash({ behavior: "smooth" });
|
| 2112 |
+
} else {
|
| 2113 |
+
location.hash = hash;
|
| 2114 |
+
}
|
| 2115 |
+
}
|
| 2116 |
+
|
| 2117 |
+
function setupFigureNavigation() {
|
| 2118 |
+
window.addEventListener("message", (event) => {
|
| 2119 |
+
const data = event.data;
|
| 2120 |
+
if (!data || data.type !== "trackio-logbook:navigate") return;
|
| 2121 |
+
// Only accept messages from one of this logbook's sandboxed figure
|
| 2122 |
+
// iframes, rather than from an arbitrary same-origin page.
|
| 2123 |
+
const isFigureFrame = Array.from(
|
| 2124 |
+
document.querySelectorAll("iframe.figure-frame")
|
| 2125 |
+
).some((frame) => frame.contentWindow === event.source);
|
| 2126 |
+
if (!isFigureFrame) return;
|
| 2127 |
+
navigateToLogbookSlug(data.target);
|
| 2128 |
+
});
|
| 2129 |
+
}
|
| 2130 |
+
|
| 2131 |
+
let SCROLL_FRAME = 0;
|
| 2132 |
+
function updateActiveSection() {
|
| 2133 |
+
cancelAnimationFrame(SCROLL_FRAME);
|
| 2134 |
+
SCROLL_FRAME = requestAnimationFrame(() => {
|
| 2135 |
+
const sections = Array.from(document.querySelectorAll(".page-section"));
|
| 2136 |
+
if (!sections.length) return;
|
| 2137 |
+
const marker = Math.min(window.innerHeight * 0.28, 180);
|
| 2138 |
+
let active = sections[0];
|
| 2139 |
+
sections.forEach((section) => {
|
| 2140 |
+
if (section.getBoundingClientRect().top <= marker) active = section;
|
| 2141 |
+
});
|
| 2142 |
+
if (
|
| 2143 |
+
window.innerHeight + window.scrollY >=
|
| 2144 |
+
document.documentElement.scrollHeight - 2
|
| 2145 |
+
) {
|
| 2146 |
+
active = sections[sections.length - 1];
|
| 2147 |
+
}
|
| 2148 |
+
highlight(active.dataset.slug);
|
| 2149 |
+
});
|
| 2150 |
+
}
|
| 2151 |
+
|
| 2152 |
+
function startLiveReload() {
|
| 2153 |
+
if (!isLocalPreview()) return;
|
| 2154 |
+
setInterval(async () => {
|
| 2155 |
+
try {
|
| 2156 |
+
const next = await fetchManifest();
|
| 2157 |
+
if (!next || next.revision === MANIFEST.revision) return;
|
| 2158 |
+
MANIFEST = next;
|
| 2159 |
+
clearPageCache();
|
| 2160 |
+
document.title = MANIFEST.title + " · Trackio Logbook";
|
| 2161 |
+
document.getElementById("book-title").textContent = MANIFEST.title;
|
| 2162 |
+
document.getElementById("book-head").setAttribute("aria-label", MANIFEST.title);
|
| 2163 |
+
buildTree();
|
| 2164 |
+
renderLogbook({ preserveScroll: true });
|
| 2165 |
+
} catch (e) {}
|
| 2166 |
+
}, LIVE_RELOAD_MS);
|
| 2167 |
+
}
|
| 2168 |
+
|
| 2169 |
+
function setupConnect() {
|
| 2170 |
+
const space = MANIFEST.space_id;
|
| 2171 |
+
if (!space) return;
|
| 2172 |
+
const steps = [
|
| 2173 |
+
{ t: "Install Trackio, if you don't have it yet.", c: "uv tool install trackio" },
|
| 2174 |
+
{ t: "Add the Trackio skill for your agent, then reload it.", c: "trackio skills add" },
|
| 2175 |
+
{ t: "Connect to this logbook.", c: `trackio logbook open ${space}` },
|
| 2176 |
+
];
|
| 2177 |
+
const ol = document.getElementById("connect-steps");
|
| 2178 |
+
steps.forEach((s, i) => {
|
| 2179 |
+
const li = document.createElement("li");
|
| 2180 |
+
const title = document.createElement("div");
|
| 2181 |
+
title.className = "step-title";
|
| 2182 |
+
title.textContent = `${i + 1}. ${s.t}`;
|
| 2183 |
+
const block = document.createElement("div");
|
| 2184 |
+
block.className = "codeblock";
|
| 2185 |
+
const code = document.createElement("code");
|
| 2186 |
+
code.textContent = s.c;
|
| 2187 |
+
const copy = document.createElement("button");
|
| 2188 |
+
copy.className = "copy";
|
| 2189 |
+
copy.type = "button";
|
| 2190 |
+
copy.title = "Copy";
|
| 2191 |
+
copy.textContent = "⧉";
|
| 2192 |
+
copy.addEventListener("click", () => copyText(s.c, copy, "⧉"));
|
| 2193 |
+
block.appendChild(code);
|
| 2194 |
+
block.appendChild(copy);
|
| 2195 |
+
li.appendChild(title);
|
| 2196 |
+
li.appendChild(block);
|
| 2197 |
+
ol.appendChild(li);
|
| 2198 |
+
});
|
| 2199 |
+
|
| 2200 |
+
const agentPrompt =
|
| 2201 |
+
`Read and help maintain this Trackio experiment logbook ("${MANIFEST.title}").\n\n` +
|
| 2202 |
+
"1. If you don't have Trackio, install it: uv tool install trackio\n" +
|
| 2203 |
+
"2. Add the Trackio skill for your agent: trackio skills add (then reload)\n" +
|
| 2204 |
+
`3. Connect to this logbook: trackio logbook open ${space}\n\n` +
|
| 2205 |
+
"Start with `trackio logbook read`; use `trackio logbook read page \"...\"` " +
|
| 2206 |
+
"for a page-level view, then fetch relevant details with " +
|
| 2207 |
+
"`trackio logbook read cell cell_<id>`. If I've given you " +
|
| 2208 |
+
'write access to the Space, add findings with `trackio logbook cell markdown "..." ' +
|
| 2209 |
+
'--page "..."` and they will sync back automatically.';
|
| 2210 |
+
|
| 2211 |
+
const foot = document.getElementById("sidebar-foot");
|
| 2212 |
+
foot.hidden = false;
|
| 2213 |
+
const modal = document.getElementById("modal");
|
| 2214 |
+
const open = () => (modal.hidden = false);
|
| 2215 |
+
const close = () => (modal.hidden = true);
|
| 2216 |
+
document.getElementById("connect-btn").addEventListener("click", open);
|
| 2217 |
+
document.getElementById("modal-close").addEventListener("click", close);
|
| 2218 |
+
modal.querySelector(".modal-backdrop").addEventListener("click", close);
|
| 2219 |
+
document.addEventListener("keydown", (e) => {
|
| 2220 |
+
if (e.key === "Escape") close();
|
| 2221 |
+
});
|
| 2222 |
+
const agentBtn = document.getElementById("copy-agent");
|
| 2223 |
+
agentBtn.addEventListener("click", () =>
|
| 2224 |
+
copyText(agentPrompt, agentBtn, "Copy for agent")
|
| 2225 |
+
);
|
| 2226 |
+
}
|
| 2227 |
+
|
| 2228 |
+
function copyText(text, btn, restore) {
|
| 2229 |
+
const done = () => {
|
| 2230 |
+
const prev = btn.textContent;
|
| 2231 |
+
btn.textContent = restore === "⧉" ? "✓" : "Copied!";
|
| 2232 |
+
btn.classList.add("copied");
|
| 2233 |
+
setTimeout(() => {
|
| 2234 |
+
btn.textContent = restore;
|
| 2235 |
+
btn.classList.remove("copied");
|
| 2236 |
+
}, 1400);
|
| 2237 |
+
void prev;
|
| 2238 |
+
};
|
| 2239 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 2240 |
+
navigator.clipboard.writeText(text).then(done, done);
|
| 2241 |
+
} else {
|
| 2242 |
+
const ta = document.createElement("textarea");
|
| 2243 |
+
ta.value = text;
|
| 2244 |
+
document.body.appendChild(ta);
|
| 2245 |
+
ta.select();
|
| 2246 |
+
try {
|
| 2247 |
+
document.execCommand("copy");
|
| 2248 |
+
} catch (e) {}
|
| 2249 |
+
document.body.removeChild(ta);
|
| 2250 |
+
done();
|
| 2251 |
+
}
|
| 2252 |
+
}
|
| 2253 |
+
|
| 2254 |
+
async function init() {
|
| 2255 |
+
MANIFEST = await fetchManifest();
|
| 2256 |
+
document.title = MANIFEST.title + " · Trackio Logbook";
|
| 2257 |
+
document.getElementById("book-title").textContent = MANIFEST.title;
|
| 2258 |
+
document.getElementById("book-head").setAttribute("aria-label", MANIFEST.title);
|
| 2259 |
+
document.getElementById("book-head").addEventListener("click", () => {
|
| 2260 |
+
const target = "#/" + MANIFEST.root.slug;
|
| 2261 |
+
if (location.hash === target) scrollToHash();
|
| 2262 |
+
else location.hash = target;
|
| 2263 |
+
});
|
| 2264 |
+
buildTree();
|
| 2265 |
+
setupConnect();
|
| 2266 |
+
setupResourceHover();
|
| 2267 |
+
setupFigureNavigation();
|
| 2268 |
+
window.addEventListener("hashchange", () => scrollToHash());
|
| 2269 |
+
window.addEventListener("scroll", updateActiveSection, { passive: true });
|
| 2270 |
+
await renderLogbook();
|
| 2271 |
+
startLiveReload();
|
| 2272 |
+
}
|
| 2273 |
+
|
| 2274 |
+
init();
|
| 2275 |
+
})();
|
trackio/frontend_templates/logbook/trackio-logo-light.png
ADDED
|
trackio/frontend_templates/logbook/trackio-logo.png
ADDED
|
trackio/frontend_templates/logbook/trackio-wordmark-dark.png
ADDED
|
trackio/frontend_templates/starter/app.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const projectSelectEl = document.querySelector("#project-select");
|
| 2 |
+
const runListEl = document.querySelector("#run-list");
|
| 3 |
+
const metricsTitleEl = document.querySelector("#metrics-title");
|
| 4 |
+
const metricsSubtitleEl = document.querySelector("#metrics-subtitle");
|
| 5 |
+
const metricsGridEl = document.querySelector("#metrics-grid");
|
| 6 |
+
const tracesSubtitleEl = document.querySelector("#traces-subtitle");
|
| 7 |
+
const tracesBodyEl = document.querySelector("#traces-body");
|
| 8 |
+
const navButtons = Array.from(document.querySelectorAll(".nav-link"));
|
| 9 |
+
const pages = Array.from(document.querySelectorAll(".page"));
|
| 10 |
+
|
| 11 |
+
const state = {
|
| 12 |
+
projects: [],
|
| 13 |
+
selectedProject: null,
|
| 14 |
+
runs: [],
|
| 15 |
+
selectedRunIds: [],
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
/*
|
| 19 |
+
Trackio routes used by this starter today:
|
| 20 |
+
- /api/get_all_projects
|
| 21 |
+
- /api/get_runs_for_project
|
| 22 |
+
- /api/get_metrics_for_run
|
| 23 |
+
- /api/get_metric_values
|
| 24 |
+
- /api/get_traces
|
| 25 |
+
|
| 26 |
+
Useful routes for expanding this starter toward the full dashboard:
|
| 27 |
+
- /api/get_system_metrics_for_run
|
| 28 |
+
- /api/get_system_logs
|
| 29 |
+
- /api/get_system_logs_batch
|
| 30 |
+
- /api/get_logs
|
| 31 |
+
- /api/get_logs_batch
|
| 32 |
+
- /api/get_snapshot
|
| 33 |
+
- /api/get_alerts
|
| 34 |
+
- /api/query_project
|
| 35 |
+
- /api/get_project_summary
|
| 36 |
+
- /api/get_run_summary
|
| 37 |
+
- /api/get_project_files
|
| 38 |
+
- /api/get_settings
|
| 39 |
+
- /api/get_run_mutation_status
|
| 40 |
+
- /api/delete_run
|
| 41 |
+
- /api/rename_run
|
| 42 |
+
- /api/force_sync
|
| 43 |
+
- /api/bulk_upload_media
|
| 44 |
+
- /api/upload
|
| 45 |
+
|
| 46 |
+
File/media URLs:
|
| 47 |
+
- /file?path=ABSOLUTE_PATH_FROM_API
|
| 48 |
+
*/
|
| 49 |
+
const RUN_COLORS = [
|
| 50 |
+
"#1f77b4",
|
| 51 |
+
"#ff7f0e",
|
| 52 |
+
"#2ca02c",
|
| 53 |
+
"#d62728",
|
| 54 |
+
"#9467bd",
|
| 55 |
+
"#8c564b",
|
| 56 |
+
"#e377c2",
|
| 57 |
+
"#7f7f7f",
|
| 58 |
+
"#bcbd22",
|
| 59 |
+
"#17becf",
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
async function api(name, payload = {}) {
|
| 63 |
+
const response = await fetch(`/api/${name}`, {
|
| 64 |
+
method: "POST",
|
| 65 |
+
headers: { "Content-Type": "application/json" },
|
| 66 |
+
body: JSON.stringify(payload),
|
| 67 |
+
});
|
| 68 |
+
const json = await response.json();
|
| 69 |
+
if (!response.ok || json.error) {
|
| 70 |
+
throw new Error(json.error || `Request failed for ${name}`);
|
| 71 |
+
}
|
| 72 |
+
return json.data;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function runKey(run) {
|
| 76 |
+
return run.id || run.name;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function colorForRun(run) {
|
| 80 |
+
const index = state.runs.findIndex((candidate) => runKey(candidate) === runKey(run));
|
| 81 |
+
return RUN_COLORS[((index >= 0 ? index : 0) % RUN_COLORS.length + RUN_COLORS.length) % RUN_COLORS.length];
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function formatValue(value) {
|
| 85 |
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
| 86 |
+
return String(value);
|
| 87 |
+
}
|
| 88 |
+
if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) {
|
| 89 |
+
return value.toExponential(2);
|
| 90 |
+
}
|
| 91 |
+
return value.toFixed(3);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function getQueryParams() {
|
| 95 |
+
return new URLSearchParams(window.location.search);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function setQueryParams(params) {
|
| 99 |
+
const next = new URL(window.location.href);
|
| 100 |
+
for (const [key, value] of Object.entries(params)) {
|
| 101 |
+
if (value == null || value === "" || (Array.isArray(value) && value.length === 0)) {
|
| 102 |
+
next.searchParams.delete(key);
|
| 103 |
+
continue;
|
| 104 |
+
}
|
| 105 |
+
next.searchParams.set(key, Array.isArray(value) ? value.join(",") : value);
|
| 106 |
+
}
|
| 107 |
+
window.history.replaceState({}, "", next);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function setActivePage(pageName) {
|
| 111 |
+
navButtons.forEach((button) => {
|
| 112 |
+
button.classList.toggle("active", button.dataset.pageTarget === pageName);
|
| 113 |
+
});
|
| 114 |
+
pages.forEach((page) => {
|
| 115 |
+
page.classList.toggle("active", page.dataset.page === pageName);
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function bindNavigation() {
|
| 120 |
+
navButtons.forEach((button) => {
|
| 121 |
+
button.addEventListener("click", () => setActivePage(button.dataset.pageTarget));
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function pickInitialProject(projects) {
|
| 126 |
+
const params = getQueryParams();
|
| 127 |
+
const project = params.get("project");
|
| 128 |
+
if (project && projects.includes(project)) {
|
| 129 |
+
return project;
|
| 130 |
+
}
|
| 131 |
+
return projects[0] || null;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function pickInitialRunIds(runs) {
|
| 135 |
+
const params = getQueryParams();
|
| 136 |
+
const fromUrl = (params.get("run_ids") || "")
|
| 137 |
+
.split(",")
|
| 138 |
+
.map((item) => item.trim())
|
| 139 |
+
.filter(Boolean);
|
| 140 |
+
const validIds = runs.map(runKey);
|
| 141 |
+
const selected = fromUrl.filter((id) => validIds.includes(id));
|
| 142 |
+
if (selected.length) {
|
| 143 |
+
return selected;
|
| 144 |
+
}
|
| 145 |
+
return runs.slice(0, 2).map(runKey);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function renderProjectSelect() {
|
| 149 |
+
projectSelectEl.innerHTML = "";
|
| 150 |
+
if (!state.projects.length) {
|
| 151 |
+
const option = document.createElement("option");
|
| 152 |
+
option.value = "";
|
| 153 |
+
option.textContent = "No projects";
|
| 154 |
+
projectSelectEl.appendChild(option);
|
| 155 |
+
projectSelectEl.disabled = true;
|
| 156 |
+
return;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
projectSelectEl.disabled = false;
|
| 160 |
+
for (const project of state.projects) {
|
| 161 |
+
const option = document.createElement("option");
|
| 162 |
+
option.value = project;
|
| 163 |
+
option.textContent = project;
|
| 164 |
+
option.selected = project === state.selectedProject;
|
| 165 |
+
projectSelectEl.appendChild(option);
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function renderRunList() {
|
| 170 |
+
runListEl.innerHTML = "";
|
| 171 |
+
if (!state.runs.length) {
|
| 172 |
+
const empty = document.createElement("div");
|
| 173 |
+
empty.className = "sidebar-empty";
|
| 174 |
+
empty.textContent = "No runs yet";
|
| 175 |
+
runListEl.appendChild(empty);
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
for (const run of state.runs) {
|
| 180 |
+
const wrapper = document.createElement("label");
|
| 181 |
+
wrapper.className = "run-option";
|
| 182 |
+
|
| 183 |
+
const input = document.createElement("input");
|
| 184 |
+
input.type = "checkbox";
|
| 185 |
+
input.checked = state.selectedRunIds.includes(runKey(run));
|
| 186 |
+
input.addEventListener("change", async () => {
|
| 187 |
+
if (input.checked) {
|
| 188 |
+
state.selectedRunIds = [...new Set([...state.selectedRunIds, runKey(run)])];
|
| 189 |
+
} else {
|
| 190 |
+
state.selectedRunIds = state.selectedRunIds.filter((id) => id !== runKey(run));
|
| 191 |
+
}
|
| 192 |
+
setQueryParams({
|
| 193 |
+
project: state.selectedProject,
|
| 194 |
+
run_ids: state.selectedRunIds,
|
| 195 |
+
});
|
| 196 |
+
await renderDashboard();
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
const marker = document.createElement("span");
|
| 200 |
+
marker.className = "run-color-dot";
|
| 201 |
+
marker.style.backgroundColor = colorForRun(run);
|
| 202 |
+
|
| 203 |
+
const text = document.createElement("span");
|
| 204 |
+
text.className = "run-option-text";
|
| 205 |
+
text.innerHTML = `<strong>${run.name || "Unnamed run"}</strong>`;
|
| 206 |
+
|
| 207 |
+
wrapper.appendChild(input);
|
| 208 |
+
wrapper.appendChild(marker);
|
| 209 |
+
wrapper.appendChild(text);
|
| 210 |
+
runListEl.appendChild(wrapper);
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function chartPoints(rows, width, height, padding, min, max) {
|
| 215 |
+
const span = max - min || 1;
|
| 216 |
+
return rows.map((row, index) => {
|
| 217 |
+
const x = padding + (index / Math.max(rows.length - 1, 1)) * (width - padding * 2);
|
| 218 |
+
const y = height - padding - ((row.value - min) / span) * (height - padding * 2);
|
| 219 |
+
return [x, y];
|
| 220 |
+
});
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
function pathFromPoints(points) {
|
| 224 |
+
return points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x} ${y}`).join(" ");
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function renderMetricCard(metricName, seriesByRun) {
|
| 228 |
+
const card = document.createElement("article");
|
| 229 |
+
card.className = "metric-card";
|
| 230 |
+
const nonEmptySeries = seriesByRun.filter((entry) => entry.rows.length);
|
| 231 |
+
if (!nonEmptySeries.length) {
|
| 232 |
+
card.innerHTML = `
|
| 233 |
+
<div class="metric-card-head">
|
| 234 |
+
<div>
|
| 235 |
+
<h3>${metricName}</h3>
|
| 236 |
+
<div class="metric-run">Selected runs</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
<div class="metric-empty">No numeric values logged for this metric.</div>
|
| 240 |
+
`;
|
| 241 |
+
return card;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
const width = 640;
|
| 245 |
+
const height = 220;
|
| 246 |
+
const padding = 20;
|
| 247 |
+
const values = nonEmptySeries.flatMap((entry) => entry.rows.map((row) => row.value));
|
| 248 |
+
const min = Math.min(...values);
|
| 249 |
+
const max = Math.max(...values);
|
| 250 |
+
const lineMarkup = nonEmptySeries
|
| 251 |
+
.map((entry) => {
|
| 252 |
+
const points = chartPoints(entry.rows, width, height, padding, min, max);
|
| 253 |
+
const markers = points
|
| 254 |
+
.map(([x, y]) => `<circle class="plot-marker" cx="${x}" cy="${y}" r="3.5" style="stroke:${entry.color}"></circle>`)
|
| 255 |
+
.join("");
|
| 256 |
+
return `
|
| 257 |
+
<path class="plot-line" d="${pathFromPoints(points)}" stroke="${entry.color}"></path>
|
| 258 |
+
${markers}
|
| 259 |
+
`;
|
| 260 |
+
})
|
| 261 |
+
.join("");
|
| 262 |
+
const legendMarkup = nonEmptySeries
|
| 263 |
+
.map(
|
| 264 |
+
(entry) => `
|
| 265 |
+
<span class="metric-legend-item">
|
| 266 |
+
<span class="metric-legend-dot" style="background:${entry.color}"></span>
|
| 267 |
+
${entry.runName}
|
| 268 |
+
</span>
|
| 269 |
+
`,
|
| 270 |
+
)
|
| 271 |
+
.join("");
|
| 272 |
+
const latestSummary = nonEmptySeries
|
| 273 |
+
.map((entry) => `${entry.runName}: ${formatValue(entry.rows.at(-1).value)}`)
|
| 274 |
+
.join(" | ");
|
| 275 |
+
|
| 276 |
+
card.innerHTML = `
|
| 277 |
+
<div class="metric-card-head">
|
| 278 |
+
<div>
|
| 279 |
+
<h3>${metricName}</h3>
|
| 280 |
+
<div class="metric-run">${nonEmptySeries.length} run${nonEmptySeries.length === 1 ? "" : "s"} overlaid</div>
|
| 281 |
+
</div>
|
| 282 |
+
<div class="metric-latest">${latestSummary}</div>
|
| 283 |
+
</div>
|
| 284 |
+
<div class="plot-shell">
|
| 285 |
+
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${metricName} line plot">
|
| 286 |
+
<line class="plot-axis" x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}"></line>
|
| 287 |
+
${lineMarkup}
|
| 288 |
+
</svg>
|
| 289 |
+
</div>
|
| 290 |
+
<div class="metric-legend">${legendMarkup}</div>
|
| 291 |
+
<div class="metric-meta">Comparing ${nonEmptySeries.length} selected runs on the same metric scale.</div>
|
| 292 |
+
`;
|
| 293 |
+
return card;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function textFromContent(content) {
|
| 297 |
+
if (typeof content === "string") return content;
|
| 298 |
+
if (Array.isArray(content)) {
|
| 299 |
+
return content
|
| 300 |
+
.map((part) => {
|
| 301 |
+
if (typeof part === "string") return part;
|
| 302 |
+
if (typeof part?.text === "string") return part.text;
|
| 303 |
+
if (typeof part?.content === "string") return part.content;
|
| 304 |
+
return "";
|
| 305 |
+
})
|
| 306 |
+
.filter(Boolean)
|
| 307 |
+
.join(" ");
|
| 308 |
+
}
|
| 309 |
+
if (typeof content?.text === "string") return content.text;
|
| 310 |
+
return "";
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function escapeHtml(value) {
|
| 314 |
+
return String(value)
|
| 315 |
+
.replaceAll("&", "&")
|
| 316 |
+
.replaceAll("<", "<")
|
| 317 |
+
.replaceAll(">", ">")
|
| 318 |
+
.replaceAll('"', """)
|
| 319 |
+
.replaceAll("'", "'");
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function renderMessageContent(content) {
|
| 323 |
+
if (typeof content === "string") {
|
| 324 |
+
return `<div class="trace-message-text">${escapeHtml(content)}</div>`;
|
| 325 |
+
}
|
| 326 |
+
if (Array.isArray(content)) {
|
| 327 |
+
const items = content
|
| 328 |
+
.map((part) => {
|
| 329 |
+
if (typeof part === "string") {
|
| 330 |
+
return `<div class="trace-message-text">${escapeHtml(part)}</div>`;
|
| 331 |
+
}
|
| 332 |
+
if (typeof part?.text === "string") {
|
| 333 |
+
return `<div class="trace-message-text">${escapeHtml(part.text)}</div>`;
|
| 334 |
+
}
|
| 335 |
+
if (typeof part?.content === "string") {
|
| 336 |
+
return `<div class="trace-message-text">${escapeHtml(part.content)}</div>`;
|
| 337 |
+
}
|
| 338 |
+
return `<div class="trace-message-text trace-message-muted">[non-text content]</div>`;
|
| 339 |
+
})
|
| 340 |
+
.join("");
|
| 341 |
+
return items || '<div class="trace-message-text trace-message-muted">(empty)</div>';
|
| 342 |
+
}
|
| 343 |
+
if (typeof content?.text === "string") {
|
| 344 |
+
return `<div class="trace-message-text">${escapeHtml(content.text)}</div>`;
|
| 345 |
+
}
|
| 346 |
+
return '<div class="trace-message-text trace-message-muted">(empty)</div>';
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function renderTraceDetail(trace) {
|
| 350 |
+
const messages = Array.isArray(trace.messages) ? trace.messages : [];
|
| 351 |
+
if (!messages.length) {
|
| 352 |
+
return '<div class="trace-message-text trace-message-muted">No trace messages.</div>';
|
| 353 |
+
}
|
| 354 |
+
return messages
|
| 355 |
+
.map((message) => {
|
| 356 |
+
const role = escapeHtml(message?.role || "unknown");
|
| 357 |
+
return `
|
| 358 |
+
<div class="trace-message">
|
| 359 |
+
<div class="trace-message-role">${role}</div>
|
| 360 |
+
${renderMessageContent(message?.content)}
|
| 361 |
+
</div>
|
| 362 |
+
`;
|
| 363 |
+
})
|
| 364 |
+
.join("");
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
function formatTraceTime(timestamp) {
|
| 368 |
+
if (!timestamp) return "—";
|
| 369 |
+
const date = new Date(timestamp);
|
| 370 |
+
if (Number.isNaN(date.getTime())) {
|
| 371 |
+
return timestamp;
|
| 372 |
+
}
|
| 373 |
+
return date.toLocaleString();
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
function renderTraceRows(traces) {
|
| 377 |
+
tracesBodyEl.innerHTML = "";
|
| 378 |
+
if (!traces.length) {
|
| 379 |
+
const row = document.createElement("tr");
|
| 380 |
+
row.innerHTML = '<td colspan="5" class="empty-row">No traces for the selected runs.</td>';
|
| 381 |
+
tracesBodyEl.appendChild(row);
|
| 382 |
+
return;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
for (const trace of traces) {
|
| 386 |
+
const request = textFromContent(
|
| 387 |
+
(trace.messages || []).find((message) => message?.role === "user")?.content,
|
| 388 |
+
) || "(no user message)";
|
| 389 |
+
const row = document.createElement("tr");
|
| 390 |
+
row.className = "trace-summary-row";
|
| 391 |
+
row.setAttribute("role", "button");
|
| 392 |
+
row.setAttribute("tabindex", "0");
|
| 393 |
+
row.setAttribute("aria-expanded", "false");
|
| 394 |
+
row.innerHTML = `
|
| 395 |
+
<td><span class="trace-id">${trace.id}</span></td>
|
| 396 |
+
<td class="trace-request">${request}</td>
|
| 397 |
+
<td>${trace.run || "—"}</td>
|
| 398 |
+
<td>${trace.step ?? "—"}</td>
|
| 399 |
+
<td>${formatTraceTime(trace.timestamp)}</td>
|
| 400 |
+
`;
|
| 401 |
+
const detailRow = document.createElement("tr");
|
| 402 |
+
detailRow.className = "trace-detail-row";
|
| 403 |
+
detailRow.hidden = true;
|
| 404 |
+
detailRow.innerHTML = `
|
| 405 |
+
<td colspan="5">
|
| 406 |
+
<div class="trace-detail-shell">
|
| 407 |
+
<div class="trace-detail-head">
|
| 408 |
+
<div>
|
| 409 |
+
<strong>${escapeHtml(trace.id)}</strong>
|
| 410 |
+
<div class="trace-detail-meta">${escapeHtml(trace.run || "—")} | step ${escapeHtml(trace.step ?? "—")} | ${escapeHtml(formatTraceTime(trace.timestamp))}</div>
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
<div class="trace-message-list">
|
| 414 |
+
${renderTraceDetail(trace)}
|
| 415 |
+
</div>
|
| 416 |
+
</div>
|
| 417 |
+
</td>
|
| 418 |
+
`;
|
| 419 |
+
const toggleRow = () => {
|
| 420 |
+
const expanded = row.getAttribute("aria-expanded") === "true";
|
| 421 |
+
row.setAttribute("aria-expanded", expanded ? "false" : "true");
|
| 422 |
+
row.classList.toggle("expanded", !expanded);
|
| 423 |
+
detailRow.hidden = expanded;
|
| 424 |
+
};
|
| 425 |
+
row.addEventListener("click", toggleRow);
|
| 426 |
+
row.addEventListener("keydown", (event) => {
|
| 427 |
+
if (event.key === "Enter" || event.key === " ") {
|
| 428 |
+
event.preventDefault();
|
| 429 |
+
toggleRow();
|
| 430 |
+
}
|
| 431 |
+
});
|
| 432 |
+
tracesBodyEl.appendChild(row);
|
| 433 |
+
tracesBodyEl.appendChild(detailRow);
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
async function loadRuns() {
|
| 438 |
+
if (!state.selectedProject) {
|
| 439 |
+
state.runs = [];
|
| 440 |
+
state.selectedRunIds = [];
|
| 441 |
+
renderRunList();
|
| 442 |
+
await renderDashboard();
|
| 443 |
+
return;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
state.runs = await api("get_runs_for_project", { project: state.selectedProject });
|
| 447 |
+
state.selectedRunIds = pickInitialRunIds(state.runs);
|
| 448 |
+
renderRunList();
|
| 449 |
+
await renderDashboard();
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
async function renderDashboard() {
|
| 453 |
+
metricsGridEl.innerHTML = "";
|
| 454 |
+
tracesBodyEl.innerHTML = "";
|
| 455 |
+
|
| 456 |
+
const selectedRuns = state.runs.filter((run) => state.selectedRunIds.includes(runKey(run)));
|
| 457 |
+
metricsTitleEl.textContent = state.selectedProject || "Metrics";
|
| 458 |
+
|
| 459 |
+
if (!state.selectedProject) {
|
| 460 |
+
metricsSubtitleEl.textContent = "No Trackio projects found.";
|
| 461 |
+
tracesSubtitleEl.textContent = "No traces available.";
|
| 462 |
+
return;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
if (!selectedRuns.length) {
|
| 466 |
+
metricsSubtitleEl.textContent = "Select one or more runs in the sidebar.";
|
| 467 |
+
tracesSubtitleEl.textContent = "Select one or more runs to load traces.";
|
| 468 |
+
metricsGridEl.innerHTML = '<div class="empty-panel">No runs selected.</div>';
|
| 469 |
+
renderTraceRows([]);
|
| 470 |
+
return;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
metricsSubtitleEl.textContent = `Plot cards for ${selectedRuns.length} selected run${selectedRuns.length === 1 ? "" : "s"}.`;
|
| 474 |
+
tracesSubtitleEl.textContent = `Recent traces for ${selectedRuns.length} selected run${selectedRuns.length === 1 ? "" : "s"}.`;
|
| 475 |
+
|
| 476 |
+
const traceGroups = [];
|
| 477 |
+
const metricMap = new Map();
|
| 478 |
+
|
| 479 |
+
for (const run of selectedRuns) {
|
| 480 |
+
const metrics = await api("get_metrics_for_run", {
|
| 481 |
+
project: state.selectedProject,
|
| 482 |
+
run: run.name,
|
| 483 |
+
run_id: run.id,
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
const metricSeries = await Promise.all(
|
| 487 |
+
metrics.slice(0, 3).map(async (metricName) => ({
|
| 488 |
+
metricName,
|
| 489 |
+
rows: await api("get_metric_values", {
|
| 490 |
+
project: state.selectedProject,
|
| 491 |
+
run: run.name,
|
| 492 |
+
run_id: run.id,
|
| 493 |
+
metric_name: metricName,
|
| 494 |
+
}),
|
| 495 |
+
})),
|
| 496 |
+
);
|
| 497 |
+
|
| 498 |
+
metricSeries.forEach(({ metricName, rows }) => {
|
| 499 |
+
const numericRows = rows.filter((row) => typeof row.value === "number" && Number.isFinite(row.value));
|
| 500 |
+
if (!metricMap.has(metricName)) {
|
| 501 |
+
metricMap.set(metricName, []);
|
| 502 |
+
}
|
| 503 |
+
metricMap.get(metricName).push({
|
| 504 |
+
runName: run.name || "Unnamed run",
|
| 505 |
+
color: colorForRun(run),
|
| 506 |
+
rows: numericRows,
|
| 507 |
+
});
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
const runTraces = await api("get_traces", {
|
| 511 |
+
project: state.selectedProject,
|
| 512 |
+
run: run.name,
|
| 513 |
+
run_id: run.id,
|
| 514 |
+
sort: "request_time_desc",
|
| 515 |
+
limit: 6,
|
| 516 |
+
});
|
| 517 |
+
traceGroups.push(...runTraces);
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
for (const [metricName, seriesByRun] of metricMap.entries()) {
|
| 521 |
+
metricsGridEl.appendChild(renderMetricCard(metricName, seriesByRun));
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
if (!metricsGridEl.children.length) {
|
| 525 |
+
metricsGridEl.innerHTML = '<div class="empty-panel">No numeric metrics available.</div>';
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
traceGroups.sort((left, right) => String(right.timestamp || "").localeCompare(String(left.timestamp || "")));
|
| 529 |
+
renderTraceRows(traceGroups.slice(0, 12));
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
async function load() {
|
| 533 |
+
bindNavigation();
|
| 534 |
+
projectSelectEl.addEventListener("change", async () => {
|
| 535 |
+
state.selectedProject = projectSelectEl.value || null;
|
| 536 |
+
setQueryParams({ project: state.selectedProject, run_ids: null });
|
| 537 |
+
await loadRuns();
|
| 538 |
+
renderProjectSelect();
|
| 539 |
+
});
|
| 540 |
+
try {
|
| 541 |
+
state.projects = await api("get_all_projects");
|
| 542 |
+
state.selectedProject = pickInitialProject(state.projects);
|
| 543 |
+
renderProjectSelect();
|
| 544 |
+
await loadRuns();
|
| 545 |
+
} catch (error) {
|
| 546 |
+
projectSelectEl.innerHTML = '<option value="">Error</option>';
|
| 547 |
+
projectSelectEl.disabled = true;
|
| 548 |
+
metricsSubtitleEl.textContent = "Could not load Trackio data.";
|
| 549 |
+
metricsGridEl.innerHTML = '<div class="empty-panel">The starter could not reach the Trackio API.</div>';
|
| 550 |
+
tracesSubtitleEl.textContent = "Could not load traces.";
|
| 551 |
+
renderTraceRows([]);
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
load();
|
trackio/frontend_templates/starter/index.html
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Starter</title>
|
| 7 |
+
<link rel="stylesheet" href="./styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div class="app-shell">
|
| 11 |
+
<aside class="sidebar">
|
| 12 |
+
<div class="sidebar-scroll">
|
| 13 |
+
<div class="logo-section">
|
| 14 |
+
<img
|
| 15 |
+
src="/static/trackio/trackio_logo_type_light_transparent.png"
|
| 16 |
+
alt="Trackio"
|
| 17 |
+
class="logo"
|
| 18 |
+
/>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<section class="sidebar-section">
|
| 22 |
+
<div class="section-label">Project</div>
|
| 23 |
+
<div class="dropdown-wrap">
|
| 24 |
+
<select id="project-select" class="project-select" aria-label="Project"></select>
|
| 25 |
+
<div class="dropdown-icon" aria-hidden="true">
|
| 26 |
+
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
|
| 27 |
+
<path d="M5.25 7.5L9 11.25L12.75 7.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
| 28 |
+
</svg>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</section>
|
| 32 |
+
|
| 33 |
+
<section class="sidebar-section">
|
| 34 |
+
<div class="section-label">Runs</div>
|
| 35 |
+
<div id="run-list" class="run-list"></div>
|
| 36 |
+
</section>
|
| 37 |
+
|
| 38 |
+
<section class="sidebar-section">
|
| 39 |
+
<div class="section-label">Notes</div>
|
| 40 |
+
<p class="sidebar-note">
|
| 41 |
+
This starter mirrors the Trackio dashboard structure, but stays plain
|
| 42 |
+
HTML, CSS, and JavaScript so you can replace pieces quickly.
|
| 43 |
+
</p>
|
| 44 |
+
</section>
|
| 45 |
+
</div>
|
| 46 |
+
</aside>
|
| 47 |
+
|
| 48 |
+
<main class="main-shell">
|
| 49 |
+
<nav class="navbar">
|
| 50 |
+
<div class="nav-spacer"></div>
|
| 51 |
+
<div class="nav-tabs">
|
| 52 |
+
<button class="nav-link active" data-page-target="metrics">Metrics</button>
|
| 53 |
+
<button class="nav-link" data-page-target="traces">Traces</button>
|
| 54 |
+
<!--
|
| 55 |
+
Trackio's full dashboard also includes tabs like these.
|
| 56 |
+
Uncomment them when you implement the corresponding page sections.
|
| 57 |
+
|
| 58 |
+
<button class="nav-link" data-page-target="system">System Metrics</button>
|
| 59 |
+
<button class="nav-link" data-page-target="media">Media & Tables</button>
|
| 60 |
+
<button class="nav-link" data-page-target="reports">Alerts & Reports</button>
|
| 61 |
+
<button class="nav-link" data-page-target="runs">Runs</button>
|
| 62 |
+
<button class="nav-link" data-page-target="files">Files</button>
|
| 63 |
+
<button class="nav-link" data-page-target="settings">Settings</button>
|
| 64 |
+
-->
|
| 65 |
+
</div>
|
| 66 |
+
</nav>
|
| 67 |
+
|
| 68 |
+
<section class="page active" data-page="metrics">
|
| 69 |
+
<header class="page-header">
|
| 70 |
+
<div>
|
| 71 |
+
<p class="eyebrow">Starter Dashboard</p>
|
| 72 |
+
<h1 id="metrics-title">Metrics</h1>
|
| 73 |
+
<p id="metrics-subtitle" class="page-subtitle">Loading Trackio data.</p>
|
| 74 |
+
</div>
|
| 75 |
+
</header>
|
| 76 |
+
<div id="metrics-grid" class="metrics-grid"></div>
|
| 77 |
+
</section>
|
| 78 |
+
|
| 79 |
+
<section class="page" data-page="traces">
|
| 80 |
+
<header class="page-header">
|
| 81 |
+
<div>
|
| 82 |
+
<p class="eyebrow">Starter Dashboard</p>
|
| 83 |
+
<h1>Traces</h1>
|
| 84 |
+
<p id="traces-subtitle" class="page-subtitle">Recent traces for the selected runs.</p>
|
| 85 |
+
</div>
|
| 86 |
+
</header>
|
| 87 |
+
<div class="traces-table-wrap">
|
| 88 |
+
<table class="traces-table">
|
| 89 |
+
<thead>
|
| 90 |
+
<tr>
|
| 91 |
+
<th>Trace ID</th>
|
| 92 |
+
<th>Request</th>
|
| 93 |
+
<th>Run</th>
|
| 94 |
+
<th>Step</th>
|
| 95 |
+
<th>Request time</th>
|
| 96 |
+
</tr>
|
| 97 |
+
</thead>
|
| 98 |
+
<tbody id="traces-body"></tbody>
|
| 99 |
+
</table>
|
| 100 |
+
</div>
|
| 101 |
+
</section>
|
| 102 |
+
|
| 103 |
+
<!--
|
| 104 |
+
Future page shells you may want to add:
|
| 105 |
+
|
| 106 |
+
<section class="page" data-page="system">
|
| 107 |
+
Build this from /api/get_system_metrics_for_run and /api/get_system_logs.
|
| 108 |
+
</section>
|
| 109 |
+
|
| 110 |
+
<section class="page" data-page="media">
|
| 111 |
+
Build this from /api/get_logs, /api/get_snapshot, /api/get_project_files, and /file?path=...
|
| 112 |
+
</section>
|
| 113 |
+
|
| 114 |
+
<section class="page" data-page="reports">
|
| 115 |
+
Build this from /api/get_alerts and /api/query_project.
|
| 116 |
+
</section>
|
| 117 |
+
|
| 118 |
+
<section class="page" data-page="runs">
|
| 119 |
+
Build this from /api/get_runs_for_project, /api/get_run_summary, /api/delete_run, and /api/rename_run.
|
| 120 |
+
</section>
|
| 121 |
+
|
| 122 |
+
<section class="page" data-page="files">
|
| 123 |
+
Build this from /api/get_project_files and /file?path=...
|
| 124 |
+
</section>
|
| 125 |
+
|
| 126 |
+
<section class="page" data-page="settings">
|
| 127 |
+
Build this from /api/get_settings, /api/force_sync, and /api/get_run_mutation_status.
|
| 128 |
+
</section>
|
| 129 |
+
-->
|
| 130 |
+
</main>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<script type="module" src="./app.js"></script>
|
| 134 |
+
</body>
|
| 135 |
+
</html>
|
trackio/frontend_templates/starter/styles.css
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
--background-fill-primary: #ffffff;
|
| 4 |
+
--background-fill-secondary: #f9fafb;
|
| 5 |
+
--background-fill-tertiary: #f3f4f6;
|
| 6 |
+
--border-color-primary: #e5e7eb;
|
| 7 |
+
--border-color-accent: #d1d5db;
|
| 8 |
+
--body-text-color: #111827;
|
| 9 |
+
--body-text-color-subdued: #6b7280;
|
| 10 |
+
--body-text-color-soft: #9ca3af;
|
| 11 |
+
--accent: #1d4ed8;
|
| 12 |
+
--shadow-soft: 0 1px 2px rgba(16, 24, 40, 0.04);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
* {
|
| 16 |
+
box-sizing: border-box;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
html,
|
| 20 |
+
body {
|
| 21 |
+
margin: 0;
|
| 22 |
+
min-height: 100%;
|
| 23 |
+
background: var(--background-fill-secondary);
|
| 24 |
+
color: var(--body-text-color);
|
| 25 |
+
font-family:
|
| 26 |
+
ui-sans-serif,
|
| 27 |
+
system-ui,
|
| 28 |
+
-apple-system,
|
| 29 |
+
BlinkMacSystemFont,
|
| 30 |
+
"Segoe UI",
|
| 31 |
+
sans-serif;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
button,
|
| 35 |
+
input,
|
| 36 |
+
select,
|
| 37 |
+
table {
|
| 38 |
+
font: inherit;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.app-shell {
|
| 42 |
+
display: grid;
|
| 43 |
+
grid-template-columns: 320px minmax(0, 1fr);
|
| 44 |
+
min-height: 100vh;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.sidebar {
|
| 48 |
+
border-right: 1px solid var(--border-color-primary);
|
| 49 |
+
background: var(--background-fill-primary);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.sidebar-scroll {
|
| 53 |
+
height: 100vh;
|
| 54 |
+
overflow-y: auto;
|
| 55 |
+
padding: 18px 16px 28px;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.logo-section {
|
| 59 |
+
padding: 10px 10px 18px;
|
| 60 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.logo {
|
| 64 |
+
display: block;
|
| 65 |
+
width: 138px;
|
| 66 |
+
max-width: 100%;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.sidebar-section {
|
| 70 |
+
padding: 18px 10px 0;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.section-label,
|
| 74 |
+
.eyebrow {
|
| 75 |
+
margin: 0 0 10px;
|
| 76 |
+
color: var(--body-text-color-subdued);
|
| 77 |
+
font-size: 12px;
|
| 78 |
+
font-weight: 600;
|
| 79 |
+
letter-spacing: 0.08em;
|
| 80 |
+
text-transform: uppercase;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.run-list {
|
| 84 |
+
display: grid;
|
| 85 |
+
gap: 8px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.dropdown-wrap {
|
| 89 |
+
position: relative;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.project-select {
|
| 93 |
+
width: 100%;
|
| 94 |
+
padding: 10px 40px 10px 12px;
|
| 95 |
+
border: 1px solid var(--border-color-primary);
|
| 96 |
+
border-radius: 10px;
|
| 97 |
+
background: var(--background-fill-primary);
|
| 98 |
+
color: var(--body-text-color);
|
| 99 |
+
appearance: none;
|
| 100 |
+
-webkit-appearance: none;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.project-select:focus {
|
| 104 |
+
outline: none;
|
| 105 |
+
border-color: var(--border-color-accent);
|
| 106 |
+
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.08);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.dropdown-icon {
|
| 110 |
+
position: absolute;
|
| 111 |
+
top: 50%;
|
| 112 |
+
right: 12px;
|
| 113 |
+
transform: translateY(-50%);
|
| 114 |
+
color: var(--body-text-color-subdued);
|
| 115 |
+
pointer-events: none;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.run-option {
|
| 119 |
+
display: grid;
|
| 120 |
+
grid-template-columns: 18px 10px minmax(0, 1fr);
|
| 121 |
+
gap: 10px;
|
| 122 |
+
align-items: start;
|
| 123 |
+
padding: 10px 12px;
|
| 124 |
+
border: 1px solid var(--border-color-primary);
|
| 125 |
+
border-radius: 10px;
|
| 126 |
+
background: var(--background-fill-primary);
|
| 127 |
+
cursor: pointer;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.run-option input {
|
| 131 |
+
margin: 2px 0 0;
|
| 132 |
+
accent-color: var(--accent);
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.run-color-dot {
|
| 136 |
+
width: 10px;
|
| 137 |
+
height: 10px;
|
| 138 |
+
margin-top: 5px;
|
| 139 |
+
border-radius: 999px;
|
| 140 |
+
background: var(--accent);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.run-option-text strong,
|
| 144 |
+
.run-option-text span {
|
| 145 |
+
display: block;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.run-option-text strong {
|
| 149 |
+
color: var(--body-text-color);
|
| 150 |
+
font-size: 14px;
|
| 151 |
+
font-weight: 600;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.run-option-text span,
|
| 155 |
+
.sidebar-note,
|
| 156 |
+
.sidebar-empty {
|
| 157 |
+
color: var(--body-text-color-subdued);
|
| 158 |
+
font-size: 13px;
|
| 159 |
+
line-height: 1.5;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.main-shell {
|
| 163 |
+
display: flex;
|
| 164 |
+
flex-direction: column;
|
| 165 |
+
min-width: 0;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.navbar {
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: stretch;
|
| 171 |
+
min-height: 44px;
|
| 172 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 173 |
+
background: var(--background-fill-primary);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.nav-spacer {
|
| 177 |
+
flex: 1 1 0;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.nav-tabs {
|
| 181 |
+
display: flex;
|
| 182 |
+
padding-right: 8px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.nav-link {
|
| 186 |
+
padding: 10px 16px;
|
| 187 |
+
border: none;
|
| 188 |
+
border-bottom: 2px solid transparent;
|
| 189 |
+
background: none;
|
| 190 |
+
color: var(--body-text-color-subdued);
|
| 191 |
+
cursor: pointer;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.nav-link.active {
|
| 195 |
+
border-bottom-color: var(--body-text-color);
|
| 196 |
+
color: var(--body-text-color);
|
| 197 |
+
font-weight: 500;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.page {
|
| 201 |
+
display: none;
|
| 202 |
+
min-width: 0;
|
| 203 |
+
padding: 24px 28px 36px;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.page.active {
|
| 207 |
+
display: block;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.page-header {
|
| 211 |
+
margin-bottom: 22px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.page-header h1 {
|
| 215 |
+
margin: 0;
|
| 216 |
+
font-size: 32px;
|
| 217 |
+
line-height: 1.1;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.page-subtitle {
|
| 221 |
+
margin: 8px 0 0;
|
| 222 |
+
color: var(--body-text-color-subdued);
|
| 223 |
+
font-size: 15px;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.metrics-grid {
|
| 227 |
+
display: grid;
|
| 228 |
+
gap: 18px;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.metric-card {
|
| 232 |
+
padding: 18px;
|
| 233 |
+
border: 1px solid var(--border-color-primary);
|
| 234 |
+
border-radius: 14px;
|
| 235 |
+
background: var(--background-fill-primary);
|
| 236 |
+
box-shadow: var(--shadow-soft);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.metric-card-head {
|
| 240 |
+
display: flex;
|
| 241 |
+
align-items: start;
|
| 242 |
+
justify-content: space-between;
|
| 243 |
+
gap: 16px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.metric-card h3 {
|
| 247 |
+
margin: 0;
|
| 248 |
+
font-size: 18px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.metric-run,
|
| 252 |
+
.metric-meta,
|
| 253 |
+
.metric-empty,
|
| 254 |
+
.metric-latest {
|
| 255 |
+
color: var(--body-text-color-subdued);
|
| 256 |
+
font-size: 13px;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.metric-latest {
|
| 260 |
+
color: var(--body-text-color);
|
| 261 |
+
max-width: 50%;
|
| 262 |
+
font-size: 13px;
|
| 263 |
+
font-weight: 600;
|
| 264 |
+
text-align: right;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.plot-shell {
|
| 268 |
+
margin-top: 14px;
|
| 269 |
+
padding: 10px 12px;
|
| 270 |
+
border: 1px solid var(--border-color-primary);
|
| 271 |
+
border-radius: 12px;
|
| 272 |
+
background: linear-gradient(180deg, #ffffff, #f9fafb);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.plot-shell svg {
|
| 276 |
+
display: block;
|
| 277 |
+
width: 100%;
|
| 278 |
+
height: auto;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.plot-axis {
|
| 282 |
+
stroke: var(--border-color-accent);
|
| 283 |
+
stroke-width: 1.2;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.plot-line {
|
| 287 |
+
fill: none;
|
| 288 |
+
stroke-width: 2.25;
|
| 289 |
+
stroke-linecap: round;
|
| 290 |
+
stroke-linejoin: round;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.plot-marker {
|
| 294 |
+
fill: var(--background-fill-primary);
|
| 295 |
+
stroke: var(--body-text-color);
|
| 296 |
+
stroke-width: 1.5;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.metric-legend {
|
| 300 |
+
display: flex;
|
| 301 |
+
flex-wrap: wrap;
|
| 302 |
+
gap: 10px 14px;
|
| 303 |
+
margin-top: 12px;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.metric-legend-item {
|
| 307 |
+
display: inline-flex;
|
| 308 |
+
align-items: center;
|
| 309 |
+
gap: 8px;
|
| 310 |
+
color: var(--body-text-color-subdued);
|
| 311 |
+
font-size: 13px;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.metric-legend-dot {
|
| 315 |
+
width: 10px;
|
| 316 |
+
height: 10px;
|
| 317 |
+
border-radius: 999px;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.traces-table-wrap {
|
| 321 |
+
overflow: auto;
|
| 322 |
+
border: 1px solid var(--border-color-primary);
|
| 323 |
+
border-radius: 14px;
|
| 324 |
+
background: var(--background-fill-primary);
|
| 325 |
+
box-shadow: var(--shadow-soft);
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.traces-table {
|
| 329 |
+
width: 100%;
|
| 330 |
+
border-collapse: collapse;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.traces-table thead {
|
| 334 |
+
background: var(--background-fill-secondary);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.traces-table th,
|
| 338 |
+
.traces-table td {
|
| 339 |
+
padding: 14px 16px;
|
| 340 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 341 |
+
text-align: left;
|
| 342 |
+
vertical-align: top;
|
| 343 |
+
font-size: 14px;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.traces-table th {
|
| 347 |
+
color: var(--body-text-color-subdued);
|
| 348 |
+
font-size: 12px;
|
| 349 |
+
font-weight: 600;
|
| 350 |
+
letter-spacing: 0.04em;
|
| 351 |
+
text-transform: uppercase;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.trace-summary-row {
|
| 355 |
+
cursor: pointer;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.trace-summary-row:hover {
|
| 359 |
+
background: var(--background-fill-secondary);
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.trace-summary-row.expanded {
|
| 363 |
+
background: var(--background-fill-secondary);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.trace-id {
|
| 367 |
+
color: var(--body-text-color);
|
| 368 |
+
font-family:
|
| 369 |
+
ui-monospace,
|
| 370 |
+
SFMono-Regular,
|
| 371 |
+
Menlo,
|
| 372 |
+
monospace;
|
| 373 |
+
font-size: 12px;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.trace-request {
|
| 377 |
+
max-width: 520px;
|
| 378 |
+
color: var(--body-text-color);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.trace-detail-row td {
|
| 382 |
+
padding: 0;
|
| 383 |
+
background: var(--background-fill-secondary);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.trace-detail-shell {
|
| 387 |
+
padding: 18px 20px;
|
| 388 |
+
border-top: 1px solid var(--border-color-primary);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.trace-detail-head strong {
|
| 392 |
+
display: block;
|
| 393 |
+
color: var(--body-text-color);
|
| 394 |
+
font-size: 14px;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.trace-detail-meta {
|
| 398 |
+
margin-top: 4px;
|
| 399 |
+
color: var(--body-text-color-subdued);
|
| 400 |
+
font-size: 12px;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.trace-message-list {
|
| 404 |
+
display: grid;
|
| 405 |
+
gap: 12px;
|
| 406 |
+
margin-top: 16px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.trace-message {
|
| 410 |
+
padding: 12px 14px;
|
| 411 |
+
border: 1px solid var(--border-color-primary);
|
| 412 |
+
border-radius: 12px;
|
| 413 |
+
background: var(--background-fill-primary);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.trace-message-role {
|
| 417 |
+
margin-bottom: 8px;
|
| 418 |
+
color: var(--body-text-color-subdued);
|
| 419 |
+
font-size: 12px;
|
| 420 |
+
font-weight: 600;
|
| 421 |
+
letter-spacing: 0.04em;
|
| 422 |
+
text-transform: uppercase;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.trace-message-text {
|
| 426 |
+
color: var(--body-text-color);
|
| 427 |
+
font-size: 14px;
|
| 428 |
+
line-height: 1.55;
|
| 429 |
+
white-space: pre-wrap;
|
| 430 |
+
overflow-wrap: anywhere;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.trace-message-muted {
|
| 434 |
+
color: var(--body-text-color-subdued);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.empty-row,
|
| 438 |
+
.empty-panel {
|
| 439 |
+
color: var(--body-text-color-subdued);
|
| 440 |
+
text-align: center;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.empty-panel {
|
| 444 |
+
padding: 48px 20px;
|
| 445 |
+
border: 1px dashed var(--border-color-accent);
|
| 446 |
+
border-radius: 14px;
|
| 447 |
+
background: var(--background-fill-primary);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
@media (max-width: 960px) {
|
| 451 |
+
.app-shell {
|
| 452 |
+
grid-template-columns: 1fr;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.sidebar {
|
| 456 |
+
border-right: none;
|
| 457 |
+
border-bottom: 1px solid var(--border-color-primary);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.sidebar-scroll {
|
| 461 |
+
height: auto;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.page {
|
| 465 |
+
padding: 18px;
|
| 466 |
+
}
|
| 467 |
+
}
|
trackio/gpu.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import threading
|
| 3 |
+
import warnings
|
| 4 |
+
from typing import TYPE_CHECKING, Any
|
| 5 |
+
|
| 6 |
+
if TYPE_CHECKING:
|
| 7 |
+
from trackio.run import Run
|
| 8 |
+
|
| 9 |
+
pynvml: Any = None
|
| 10 |
+
PYNVML_AVAILABLE = False
|
| 11 |
+
_nvml_initialized = False
|
| 12 |
+
_nvml_lock = threading.Lock()
|
| 13 |
+
_energy_baseline: dict[int, float] = {}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _ensure_pynvml():
|
| 17 |
+
global PYNVML_AVAILABLE, pynvml
|
| 18 |
+
if PYNVML_AVAILABLE:
|
| 19 |
+
return pynvml
|
| 20 |
+
try:
|
| 21 |
+
import pynvml as _pynvml
|
| 22 |
+
|
| 23 |
+
pynvml = _pynvml
|
| 24 |
+
PYNVML_AVAILABLE = True
|
| 25 |
+
return pynvml
|
| 26 |
+
except ImportError:
|
| 27 |
+
raise ImportError(
|
| 28 |
+
"nvidia-ml-py is required for GPU monitoring. "
|
| 29 |
+
"Install it with: pip install nvidia-ml-py"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _init_nvml() -> bool:
|
| 34 |
+
global _nvml_initialized
|
| 35 |
+
with _nvml_lock:
|
| 36 |
+
if _nvml_initialized:
|
| 37 |
+
return True
|
| 38 |
+
try:
|
| 39 |
+
nvml = _ensure_pynvml()
|
| 40 |
+
nvml.nvmlInit()
|
| 41 |
+
_nvml_initialized = True
|
| 42 |
+
return True
|
| 43 |
+
except Exception:
|
| 44 |
+
return False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_gpu_count() -> tuple[int, list[int]]:
|
| 48 |
+
"""
|
| 49 |
+
Get the number of GPUs visible to this process and their physical indices.
|
| 50 |
+
Respects CUDA_VISIBLE_DEVICES environment variable.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Tuple of (count, physical_indices) where:
|
| 54 |
+
- count: Number of visible GPUs
|
| 55 |
+
- physical_indices: List mapping logical index to physical GPU index.
|
| 56 |
+
e.g., if CUDA_VISIBLE_DEVICES=2,3 returns (2, [2, 3])
|
| 57 |
+
meaning logical GPU 0 = physical GPU 2, logical GPU 1 = physical GPU 3
|
| 58 |
+
"""
|
| 59 |
+
if not _init_nvml():
|
| 60 |
+
return 0, []
|
| 61 |
+
|
| 62 |
+
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES")
|
| 63 |
+
if cuda_visible is not None and cuda_visible.strip():
|
| 64 |
+
try:
|
| 65 |
+
indices = [int(x.strip()) for x in cuda_visible.split(",") if x.strip()]
|
| 66 |
+
return len(indices), indices
|
| 67 |
+
except ValueError:
|
| 68 |
+
pass
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
total = pynvml.nvmlDeviceGetCount()
|
| 72 |
+
return total, list(range(total))
|
| 73 |
+
except Exception:
|
| 74 |
+
return 0, []
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def get_all_gpu_count() -> tuple[int, list[int]]:
|
| 78 |
+
"""
|
| 79 |
+
Get the total number of physical GPUs on the machine, ignoring CUDA_VISIBLE_DEVICES.
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Tuple of (count, physical_indices) for ALL GPUs on the machine.
|
| 83 |
+
e.g., on a 4-GPU machine returns (4, [0, 1, 2, 3]) regardless of
|
| 84 |
+
CUDA_VISIBLE_DEVICES setting.
|
| 85 |
+
"""
|
| 86 |
+
if not _init_nvml():
|
| 87 |
+
return 0, []
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
total = pynvml.nvmlDeviceGetCount()
|
| 91 |
+
return total, list(range(total))
|
| 92 |
+
except Exception:
|
| 93 |
+
return 0, []
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def gpu_available() -> bool:
|
| 97 |
+
"""
|
| 98 |
+
Check if GPU monitoring is available.
|
| 99 |
+
|
| 100 |
+
Returns True if nvidia-ml-py is installed and at least one NVIDIA GPU is detected.
|
| 101 |
+
This is used for auto-detection of GPU logging.
|
| 102 |
+
"""
|
| 103 |
+
try:
|
| 104 |
+
_ensure_pynvml()
|
| 105 |
+
count, _ = get_gpu_count()
|
| 106 |
+
return count > 0
|
| 107 |
+
except ImportError:
|
| 108 |
+
return False
|
| 109 |
+
except Exception:
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def reset_energy_baseline():
|
| 114 |
+
"""Reset the energy baseline for all GPUs. Called when a new run starts."""
|
| 115 |
+
global _energy_baseline
|
| 116 |
+
_energy_baseline = {}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def collect_gpu_metrics(device: int | None = None, all_gpus: bool = False) -> dict:
|
| 120 |
+
"""
|
| 121 |
+
Collect GPU metrics for visible GPUs.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
device: CUDA device index to collect metrics from. If None, collects
|
| 125 |
+
from all GPUs visible to this process (respects CUDA_VISIBLE_DEVICES).
|
| 126 |
+
The device index is the logical CUDA index (0, 1, 2...), not the
|
| 127 |
+
physical GPU index.
|
| 128 |
+
all_gpus: If True and device is None, collect metrics for ALL physical GPUs
|
| 129 |
+
on the machine, ignoring CUDA_VISIBLE_DEVICES. Used by GpuMonitor
|
| 130 |
+
to report system-wide GPU metrics in distributed training.
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Dictionary of GPU metrics. Keys use device indices (gpu/0/, gpu/1/, etc.).
|
| 134 |
+
"""
|
| 135 |
+
if not _init_nvml():
|
| 136 |
+
return {}
|
| 137 |
+
|
| 138 |
+
if all_gpus and device is None:
|
| 139 |
+
gpu_count, visible_gpus = get_all_gpu_count()
|
| 140 |
+
else:
|
| 141 |
+
gpu_count, visible_gpus = get_gpu_count()
|
| 142 |
+
if gpu_count == 0:
|
| 143 |
+
return {}
|
| 144 |
+
|
| 145 |
+
if device is not None:
|
| 146 |
+
if device < 0 or device >= gpu_count:
|
| 147 |
+
return {}
|
| 148 |
+
gpu_indices = [(device, visible_gpus[device])]
|
| 149 |
+
else:
|
| 150 |
+
gpu_indices = list(enumerate(visible_gpus))
|
| 151 |
+
|
| 152 |
+
metrics = {}
|
| 153 |
+
total_util = 0.0
|
| 154 |
+
total_mem_used_gib = 0.0
|
| 155 |
+
total_power = 0.0
|
| 156 |
+
max_temp = 0.0
|
| 157 |
+
valid_util_count = 0
|
| 158 |
+
|
| 159 |
+
for logical_idx, physical_idx in gpu_indices:
|
| 160 |
+
prefix = f"gpu/{logical_idx}"
|
| 161 |
+
try:
|
| 162 |
+
handle = pynvml.nvmlDeviceGetHandleByIndex(physical_idx)
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
|
| 166 |
+
metrics[f"{prefix}/utilization"] = util.gpu
|
| 167 |
+
metrics[f"{prefix}/memory_utilization"] = util.memory
|
| 168 |
+
total_util += util.gpu
|
| 169 |
+
valid_util_count += 1
|
| 170 |
+
except Exception:
|
| 171 |
+
pass
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
| 175 |
+
mem_used_gib = mem.used / (1024**3)
|
| 176 |
+
mem_total_gib = mem.total / (1024**3)
|
| 177 |
+
metrics[f"{prefix}/allocated_memory"] = mem_used_gib
|
| 178 |
+
metrics[f"{prefix}/total_memory"] = mem_total_gib
|
| 179 |
+
if mem.total > 0:
|
| 180 |
+
metrics[f"{prefix}/memory_usage"] = mem.used / mem.total
|
| 181 |
+
total_mem_used_gib += mem_used_gib
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
|
| 185 |
+
try:
|
| 186 |
+
power_mw = pynvml.nvmlDeviceGetPowerUsage(handle)
|
| 187 |
+
power_w = power_mw / 1000.0
|
| 188 |
+
metrics[f"{prefix}/power"] = power_w
|
| 189 |
+
total_power += power_w
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
power_limit_mw = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
|
| 195 |
+
power_limit_w = power_limit_mw / 1000.0
|
| 196 |
+
metrics[f"{prefix}/power_limit"] = power_limit_w
|
| 197 |
+
if power_limit_w > 0 and f"{prefix}/power" in metrics:
|
| 198 |
+
metrics[f"{prefix}/power_percent"] = (
|
| 199 |
+
metrics[f"{prefix}/power"] / power_limit_w
|
| 200 |
+
) * 100
|
| 201 |
+
except Exception:
|
| 202 |
+
pass
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
temp = pynvml.nvmlDeviceGetTemperature(
|
| 206 |
+
handle, pynvml.NVML_TEMPERATURE_GPU
|
| 207 |
+
)
|
| 208 |
+
metrics[f"{prefix}/temp"] = temp
|
| 209 |
+
max_temp = max(max_temp, temp)
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
try:
|
| 214 |
+
sm_clock = pynvml.nvmlDeviceGetClockInfo(handle, pynvml.NVML_CLOCK_SM)
|
| 215 |
+
metrics[f"{prefix}/sm_clock"] = sm_clock
|
| 216 |
+
except Exception:
|
| 217 |
+
pass
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
mem_clock = pynvml.nvmlDeviceGetClockInfo(handle, pynvml.NVML_CLOCK_MEM)
|
| 221 |
+
metrics[f"{prefix}/memory_clock"] = mem_clock
|
| 222 |
+
except Exception:
|
| 223 |
+
pass
|
| 224 |
+
|
| 225 |
+
try:
|
| 226 |
+
fan_speed = pynvml.nvmlDeviceGetFanSpeed(handle)
|
| 227 |
+
metrics[f"{prefix}/fan_speed"] = fan_speed
|
| 228 |
+
except Exception:
|
| 229 |
+
pass
|
| 230 |
+
|
| 231 |
+
try:
|
| 232 |
+
pstate = pynvml.nvmlDeviceGetPerformanceState(handle)
|
| 233 |
+
metrics[f"{prefix}/performance_state"] = pstate
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
|
| 237 |
+
try:
|
| 238 |
+
energy_mj = pynvml.nvmlDeviceGetTotalEnergyConsumption(handle)
|
| 239 |
+
if physical_idx not in _energy_baseline:
|
| 240 |
+
_energy_baseline[physical_idx] = energy_mj
|
| 241 |
+
energy_consumed_mj = energy_mj - _energy_baseline[physical_idx]
|
| 242 |
+
metrics[f"{prefix}/energy_consumed"] = energy_consumed_mj / 1000.0
|
| 243 |
+
except Exception:
|
| 244 |
+
pass
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
pcie_tx = pynvml.nvmlDeviceGetPcieThroughput(
|
| 248 |
+
handle, pynvml.NVML_PCIE_UTIL_TX_BYTES
|
| 249 |
+
)
|
| 250 |
+
pcie_rx = pynvml.nvmlDeviceGetPcieThroughput(
|
| 251 |
+
handle, pynvml.NVML_PCIE_UTIL_RX_BYTES
|
| 252 |
+
)
|
| 253 |
+
metrics[f"{prefix}/pcie_tx"] = pcie_tx / 1024.0
|
| 254 |
+
metrics[f"{prefix}/pcie_rx"] = pcie_rx / 1024.0
|
| 255 |
+
except Exception:
|
| 256 |
+
pass
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
throttle = pynvml.nvmlDeviceGetCurrentClocksThrottleReasons(handle)
|
| 260 |
+
metrics[f"{prefix}/throttle_thermal"] = int(
|
| 261 |
+
bool(throttle & pynvml.nvmlClocksThrottleReasonSwThermalSlowdown)
|
| 262 |
+
)
|
| 263 |
+
metrics[f"{prefix}/throttle_power"] = int(
|
| 264 |
+
bool(throttle & pynvml.nvmlClocksThrottleReasonSwPowerCap)
|
| 265 |
+
)
|
| 266 |
+
metrics[f"{prefix}/throttle_hw_slowdown"] = int(
|
| 267 |
+
bool(throttle & pynvml.nvmlClocksThrottleReasonHwSlowdown)
|
| 268 |
+
)
|
| 269 |
+
metrics[f"{prefix}/throttle_apps"] = int(
|
| 270 |
+
bool(
|
| 271 |
+
throttle
|
| 272 |
+
& pynvml.nvmlClocksThrottleReasonApplicationsClocksSetting
|
| 273 |
+
)
|
| 274 |
+
)
|
| 275 |
+
except Exception:
|
| 276 |
+
pass
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
ecc_corrected = pynvml.nvmlDeviceGetTotalEccErrors(
|
| 280 |
+
handle,
|
| 281 |
+
pynvml.NVML_MEMORY_ERROR_TYPE_CORRECTED,
|
| 282 |
+
pynvml.NVML_VOLATILE_ECC,
|
| 283 |
+
)
|
| 284 |
+
metrics[f"{prefix}/corrected_memory_errors"] = ecc_corrected
|
| 285 |
+
except Exception:
|
| 286 |
+
pass
|
| 287 |
+
|
| 288 |
+
try:
|
| 289 |
+
ecc_uncorrected = pynvml.nvmlDeviceGetTotalEccErrors(
|
| 290 |
+
handle,
|
| 291 |
+
pynvml.NVML_MEMORY_ERROR_TYPE_UNCORRECTED,
|
| 292 |
+
pynvml.NVML_VOLATILE_ECC,
|
| 293 |
+
)
|
| 294 |
+
metrics[f"{prefix}/uncorrected_memory_errors"] = ecc_uncorrected
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
|
| 298 |
+
except Exception:
|
| 299 |
+
continue
|
| 300 |
+
|
| 301 |
+
if valid_util_count > 0:
|
| 302 |
+
metrics["gpu/mean_utilization"] = total_util / valid_util_count
|
| 303 |
+
if total_mem_used_gib > 0:
|
| 304 |
+
metrics["gpu/total_allocated_memory"] = total_mem_used_gib
|
| 305 |
+
if total_power > 0:
|
| 306 |
+
metrics["gpu/total_power"] = total_power
|
| 307 |
+
if max_temp > 0:
|
| 308 |
+
metrics["gpu/max_temp"] = max_temp
|
| 309 |
+
|
| 310 |
+
return metrics
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
class GpuMonitor:
|
| 314 |
+
def __init__(self, run: "Run", interval: float = 10.0):
|
| 315 |
+
self._run = run
|
| 316 |
+
self._interval = interval
|
| 317 |
+
self._stop_flag = threading.Event()
|
| 318 |
+
self._thread: "threading.Thread | None" = None
|
| 319 |
+
|
| 320 |
+
def start(self):
|
| 321 |
+
count, _ = get_all_gpu_count()
|
| 322 |
+
if count == 0:
|
| 323 |
+
warnings.warn(
|
| 324 |
+
"auto_log_gpu=True but no NVIDIA GPUs detected. GPU logging disabled."
|
| 325 |
+
)
|
| 326 |
+
return
|
| 327 |
+
|
| 328 |
+
reset_energy_baseline()
|
| 329 |
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
| 330 |
+
self._thread.start()
|
| 331 |
+
|
| 332 |
+
def stop(self):
|
| 333 |
+
self._stop_flag.set()
|
| 334 |
+
if self._thread is not None:
|
| 335 |
+
self._thread.join(timeout=2.0)
|
| 336 |
+
|
| 337 |
+
def _monitor_loop(self):
|
| 338 |
+
while not self._stop_flag.is_set():
|
| 339 |
+
try:
|
| 340 |
+
metrics = collect_gpu_metrics(all_gpus=True)
|
| 341 |
+
if metrics:
|
| 342 |
+
self._run.log_system(metrics)
|
| 343 |
+
except Exception:
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
self._stop_flag.wait(timeout=self._interval)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def log_gpu(run: "Run | None" = None, device: int | None = None) -> dict:
|
| 350 |
+
"""
|
| 351 |
+
Log GPU metrics to the current or specified run as system metrics.
|
| 352 |
+
|
| 353 |
+
Args:
|
| 354 |
+
run: Optional Run instance. If None, uses current run from context.
|
| 355 |
+
device: CUDA device index to collect metrics from. If None, collects
|
| 356 |
+
from all GPUs visible to this process (respects CUDA_VISIBLE_DEVICES).
|
| 357 |
+
|
| 358 |
+
Returns:
|
| 359 |
+
dict: The GPU metrics that were logged.
|
| 360 |
+
|
| 361 |
+
Example:
|
| 362 |
+
```python
|
| 363 |
+
import trackio
|
| 364 |
+
|
| 365 |
+
run = trackio.init(project="my-project")
|
| 366 |
+
trackio.log({"loss": 0.5})
|
| 367 |
+
trackio.log_gpu() # logs all visible GPUs
|
| 368 |
+
trackio.log_gpu(device=0) # logs only CUDA device 0
|
| 369 |
+
```
|
| 370 |
+
"""
|
| 371 |
+
from trackio import context_vars
|
| 372 |
+
|
| 373 |
+
if run is None:
|
| 374 |
+
run = context_vars.current_run.get()
|
| 375 |
+
if run is None:
|
| 376 |
+
raise RuntimeError("Call trackio.init() before trackio.log_gpu().")
|
| 377 |
+
|
| 378 |
+
metrics = collect_gpu_metrics(device=device)
|
| 379 |
+
if metrics:
|
| 380 |
+
run.log_system(metrics)
|
| 381 |
+
return metrics
|