abidlabs HF Staff commited on
Commit
bd97edc
·
verified ·
1 Parent(s): abfcf1e

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. trackio/CHANGELOG.md +400 -0
  3. trackio/__init__.py +1216 -0
  4. trackio/_vendor/__init__.py +0 -0
  5. trackio/_vendor/gradio_exceptions.py +6 -0
  6. trackio/_vendor/networking.py +81 -0
  7. trackio/_vendor/tunneling.py +191 -0
  8. trackio/alerts.py +184 -0
  9. trackio/api.py +99 -0
  10. trackio/apple_gpu.py +206 -0
  11. trackio/artifact.py +552 -0
  12. trackio/asgi_app.py +531 -0
  13. trackio/assets/badge.png +3 -0
  14. trackio/assets/trackio_logo_dark.png +0 -0
  15. trackio/assets/trackio_logo_light.png +0 -0
  16. trackio/assets/trackio_logo_old.png +3 -0
  17. trackio/assets/trackio_logo_type_dark.png +0 -0
  18. trackio/assets/trackio_logo_type_dark_transparent.png +0 -0
  19. trackio/assets/trackio_logo_type_light.png +0 -0
  20. trackio/assets/trackio_logo_type_light_transparent.png +0 -0
  21. trackio/bucket_storage.py +208 -0
  22. trackio/cas.py +214 -0
  23. trackio/cli.py +2307 -0
  24. trackio/cli_helpers.py +268 -0
  25. trackio/commit_scheduler.py +310 -0
  26. trackio/compression.py +169 -0
  27. trackio/context_vars.py +21 -0
  28. trackio/cpu.py +303 -0
  29. trackio/deploy.py +1313 -0
  30. trackio/dummy_commit_scheduler.py +19 -0
  31. trackio/exceptions.py +2 -0
  32. trackio/fragments.py +368 -0
  33. trackio/frontend/dist/assets/index-m3jgYXoS.css +1 -0
  34. trackio/frontend/dist/assets/index-nILgi7uv.js +0 -0
  35. trackio/frontend/dist/index.html +14 -0
  36. trackio/frontend/eslint.config.js +42 -0
  37. trackio/frontend/index.html +13 -0
  38. trackio/frontend_config.py +175 -0
  39. trackio/frontend_server.py +175 -0
  40. trackio/frontend_templates/logbook/bucket-icon.svg +5 -0
  41. trackio/frontend_templates/logbook/index.html +54 -0
  42. trackio/frontend_templates/logbook/logbook.css +1602 -0
  43. trackio/frontend_templates/logbook/logbook.js +2275 -0
  44. trackio/frontend_templates/logbook/trackio-logo-light.png +0 -0
  45. trackio/frontend_templates/logbook/trackio-logo.png +0 -0
  46. trackio/frontend_templates/logbook/trackio-wordmark-dark.png +0 -0
  47. trackio/frontend_templates/starter/app.js +555 -0
  48. trackio/frontend_templates/starter/index.html +135 -0
  49. trackio/frontend_templates/starter/styles.css +467 -0
  50. 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

  • SHA256: 206b7847247e83279f498510a2760338a03116bb5141a658d71ec14429f9ea9e
  • Pointer size: 131 Bytes
  • Size of remote file: 170 kB
trackio/assets/trackio_logo_dark.png ADDED
trackio/assets/trackio_logo_light.png ADDED
trackio/assets/trackio_logo_old.png ADDED

Git LFS Details

  • SHA256: 3922c4d1e465270ad4d8abb12023f3beed5d9f7f338528a4c0ac21dcf358a1c8
  • Pointer size: 131 Bytes
  • Size of remote file: 487 kB
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, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;")
17
+ .replace(/'/g, "&#39;");
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(/&quot;|&#39;|&lt;|&gt;/);
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">&lt;/&gt;</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("&", "&amp;")
316
+ .replaceAll("<", "&lt;")
317
+ .replaceAll(">", "&gt;")
318
+ .replaceAll('"', "&quot;")
319
+ .replaceAll("'", "&#39;");
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