abidlabs HF Staff commited on
Commit
12bcff1
·
verified ·
1 Parent(s): e07d468

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 +304 -0
  3. trackio/__init__.py +1165 -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 +95 -0
  10. trackio/apple_gpu.py +198 -0
  11. trackio/asgi_app.py +507 -0
  12. trackio/assets/badge.png +3 -0
  13. trackio/assets/trackio_logo_dark.png +0 -0
  14. trackio/assets/trackio_logo_light.png +0 -0
  15. trackio/assets/trackio_logo_old.png +3 -0
  16. trackio/assets/trackio_logo_type_dark.png +0 -0
  17. trackio/assets/trackio_logo_type_dark_transparent.png +0 -0
  18. trackio/assets/trackio_logo_type_light.png +0 -0
  19. trackio/assets/trackio_logo_type_light_transparent.png +0 -0
  20. trackio/bucket_storage.py +151 -0
  21. trackio/cli.py +1419 -0
  22. trackio/cli_helpers.py +204 -0
  23. trackio/commit_scheduler.py +310 -0
  24. trackio/context_vars.py +21 -0
  25. trackio/cpu.py +294 -0
  26. trackio/deploy.py +1252 -0
  27. trackio/dummy_commit_scheduler.py +19 -0
  28. trackio/exceptions.py +2 -0
  29. trackio/frontend/dist/assets/index-DsqR87ZT.js +0 -0
  30. trackio/frontend/dist/assets/index-PpAq14vL.css +1 -0
  31. trackio/frontend/dist/index.html +14 -0
  32. trackio/frontend/eslint.config.js +42 -0
  33. trackio/frontend/index.html +13 -0
  34. trackio/frontend_config.py +175 -0
  35. trackio/frontend_server.py +145 -0
  36. trackio/frontend_templates/starter/app.js +555 -0
  37. trackio/frontend_templates/starter/index.html +135 -0
  38. trackio/frontend_templates/starter/styles.css +467 -0
  39. trackio/gpu.py +381 -0
  40. trackio/histogram.py +71 -0
  41. trackio/imports.py +305 -0
  42. trackio/launch.py +202 -0
  43. trackio/launch_utils.py +33 -0
  44. trackio/markdown.py +21 -0
  45. trackio/mcp_setup.py +156 -0
  46. trackio/media/__init__.py +27 -0
  47. trackio/media/audio.py +189 -0
  48. trackio/media/image.py +84 -0
  49. trackio/media/media.py +79 -0
  50. trackio/media/utils.py +60 -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,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # trackio
2
+
3
+ ## 0.26.0
4
+
5
+ ### Features
6
+
7
+ - [#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!
8
+ - [#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!
9
+ - [#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!
10
+ - [#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!
11
+ - [#538](https://github.com/gradio-app/trackio/pull/538) [`a15c1a8`](https://github.com/gradio-app/trackio/commit/a15c1a8877c07514e0596630bb7c7299299994a9) - Subdue empty dashboard tabs. Thanks @abidlabs!
12
+ - [#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!
13
+ - [#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!
14
+ - [#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!
15
+
16
+ ### Fixes
17
+
18
+ - [#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!
19
+
20
+ ## 0.25.1
21
+
22
+ ### Features
23
+
24
+ - [#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!
25
+
26
+ ## 0.25.0
27
+
28
+ ### Features
29
+
30
+ - [#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!
31
+ - [#518](https://github.com/gradio-app/trackio/pull/518) [`e7ed176`](https://github.com/gradio-app/trackio/commit/e7ed176da53d8b49290fddd890b3d18c0b9b958f) - Traces in Trackio. Thanks @abidlabs!
32
+ - [#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!
33
+
34
+ ## 0.24.2
35
+
36
+ ### Features
37
+
38
+ - [#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!
39
+ - [#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!
40
+ - [#529](https://github.com/gradio-app/trackio/pull/529) [`a77972b`](https://github.com/gradio-app/trackio/commit/a77972b68541ebe9e056824e69c9bbca3979ece4) - Remove pydub dependency. Thanks @abidlabs!
41
+
42
+ ## 0.24.1
43
+
44
+ ### Features
45
+
46
+ - [#524](https://github.com/gradio-app/trackio/pull/524) [`65a6897`](https://github.com/gradio-app/trackio/commit/65a6897561b465fc8f05550562f8da1ba3c99060) - Fix `trackio skills add`. Thanks @abidlabs!
47
+ - [#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!
48
+ - [#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!
49
+
50
+ ## 0.24.0
51
+
52
+ ### Features
53
+
54
+ - [#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!
55
+ - [#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!
56
+ - [#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!
57
+ - [#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!
58
+ - [#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!
59
+ - [#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!
60
+ - [#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!
61
+ - [#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!
62
+ - [#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!
63
+ - [#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!
64
+
65
+ ## 0.23.0
66
+
67
+ ### Features
68
+
69
+ - [#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!
70
+ - [#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!
71
+ - [#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!
72
+ - [#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!
73
+ - [#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!
74
+
75
+ ## 0.22.0
76
+
77
+ ### Features
78
+
79
+ - [#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!
80
+ - [#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!
81
+
82
+ ## 0.21.2
83
+
84
+ ### Features
85
+
86
+ - [#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!
87
+
88
+ ## 0.21.1
89
+
90
+ ### Features
91
+
92
+ - [#475](https://github.com/gradio-app/trackio/pull/475) [`fcb476c`](https://github.com/gradio-app/trackio/commit/fcb476cd37a40923e9679aaf966f41d582a878a8) - Tweaks. Thanks @abidlabs!
93
+ - [#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!
94
+
95
+ ## 0.21.0
96
+
97
+ ### Features
98
+
99
+ - [#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!
100
+ - [#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!
101
+ - [#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!
102
+ - [#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!
103
+ - [#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!
104
+ - [#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!
105
+
106
+ ## 0.20.2
107
+
108
+ ### Features
109
+
110
+ - [#464](https://github.com/gradio-app/trackio/pull/464) [`c89ebb3`](https://github.com/gradio-app/trackio/commit/c89ebb3b50f695bc7f16cbc6f46dce86f79a01e9) - Improve rendering of curves. Thanks @abidlabs!
111
+ - [#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!
112
+
113
+ ## 0.20.1
114
+
115
+ ### Features
116
+
117
+ - [#454](https://github.com/gradio-app/trackio/pull/454) [`22881db`](https://github.com/gradio-app/trackio/commit/22881dbbbb6b81197a00a19853771007093d61e4) - Bar chart single point. Thanks @abidlabs!
118
+ - [#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!
119
+
120
+ ## 0.20.0
121
+
122
+ ### Features
123
+
124
+ - [#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!
125
+
126
+ ## 0.19.0
127
+
128
+ ### Features
129
+
130
+ - [#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!
131
+ - [#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!
132
+
133
+ ## 0.18.0
134
+
135
+ ### Features
136
+
137
+ - [#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!
138
+ - [#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!
139
+ - [#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!
140
+ - [#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!
141
+ - [#434](https://github.com/gradio-app/trackio/pull/434) [`4193223`](https://github.com/gradio-app/trackio/commit/41932230a3a2e1c16405dba08ecba5a42f11d1a8) - fix: table slider crash. Thanks @Saba9!
142
+
143
+ ### Fixes
144
+
145
+ - [#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!
146
+
147
+ ## 0.17.0
148
+
149
+ ### Features
150
+
151
+ - [#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!
152
+ - [#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!
153
+ - [#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!
154
+
155
+ ## 0.16.1
156
+
157
+ ### Features
158
+
159
+ - [#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!
160
+
161
+ ## 0.16.0
162
+
163
+ ### Features
164
+
165
+ - [#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!
166
+ - [#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!
167
+ - [#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!
168
+ - [#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!
169
+ - [#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!
170
+
171
+ ## 0.15.0
172
+
173
+ ### Features
174
+
175
+ - [#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!
176
+ - [#396](https://github.com/gradio-app/trackio/pull/396) [`4a4d1ab`](https://github.com/gradio-app/trackio/commit/4a4d1ab85e63d923132a3fa7afa5d90e16431bec) - Fix run selection issue. Thanks @abidlabs!
177
+ - [#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!
178
+ - [#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!
179
+
180
+ ## 0.14.2
181
+
182
+ ### Features
183
+
184
+ - [#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!
185
+
186
+ ## 0.14.1
187
+
188
+ ### Features
189
+
190
+ - [#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!
191
+ - [#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!
192
+
193
+ ## 0.14.0
194
+
195
+ ### Features
196
+
197
+ - [#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!
198
+ - [#374](https://github.com/gradio-app/trackio/pull/374) [`388e26b`](https://github.com/gradio-app/trackio/commit/388e26b9e9f24cd7ad203affe9b709be885b3d24) - Save Optimized Parquet files. Thanks @lhoestq!
199
+ - [#371](https://github.com/gradio-app/trackio/pull/371) [`fbace9c`](https://github.com/gradio-app/trackio/commit/fbace9cd7732c166f34d268f54b05bb06846cc5d) - Add GPU metrics logging. Thanks @kashif!
200
+ - [#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!
201
+
202
+ ## 0.13.1
203
+
204
+ ### Features
205
+
206
+ - [#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!
207
+
208
+ ## 0.13.0
209
+
210
+ ### Features
211
+
212
+ - [#358](https://github.com/gradio-app/trackio/pull/358) [`073715d`](https://github.com/gradio-app/trackio/commit/073715d1caf8282f68890117f09c3ac301205312) - Improvements to `trackio.sync()`. Thanks @abidlabs!
213
+
214
+ ## 0.12.0
215
+
216
+ ### Features
217
+
218
+ - [#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!
219
+ - [#359](https://github.com/gradio-app/trackio/pull/359) [`08fe9c9`](https://github.com/gradio-app/trackio/commit/08fe9c9ddd7fe99ee811555fdfb62df9ab88e939) - docs: Improve docstrings. Thanks @qgallouedec!
220
+
221
+ ## 0.11.0
222
+
223
+ ### Features
224
+
225
+ - [#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!
226
+ - [#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!
227
+ - [#351](https://github.com/gradio-app/trackio/pull/351) [`8a8957e`](https://github.com/gradio-app/trackio/commit/8a8957e530dd7908d1fef7f2df030303f808101f) - Add `trackio.save()`. Thanks @abidlabs!
228
+
229
+ ## 0.10.0
230
+
231
+ ### Features
232
+
233
+ - [#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!
234
+
235
+ ## 0.9.1
236
+
237
+ ### Features
238
+
239
+ - [#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!
240
+
241
+ ## 0.9.0
242
+
243
+ ### Features
244
+
245
+ - [#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!
246
+ - [#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!
247
+ - [#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!
248
+
249
+ ## 0.8.1
250
+
251
+ ### Features
252
+
253
+ - [#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!
254
+
255
+ ## 0.8.0
256
+
257
+ ### Features
258
+
259
+ - [#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!
260
+ - [#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!
261
+ - [#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!
262
+ - [#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!
263
+ - [#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!
264
+
265
+ ## 0.7.0
266
+
267
+ ### Features
268
+
269
+ - [#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!
270
+ - [#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!
271
+ - [#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!
272
+
273
+ ## 0.6.0
274
+
275
+ ### Features
276
+
277
+ - [#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!
278
+ - [#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!
279
+ - [#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!
280
+ - [#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!
281
+ - [#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!
282
+
283
+ ## 0.5.3
284
+
285
+ ### Features
286
+
287
+ - [#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!
288
+
289
+ ## 0.5.2
290
+
291
+ ### Features
292
+
293
+ - [#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!
294
+ - [#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!
295
+
296
+ ### Fixes
297
+
298
+ - [#291](https://github.com/gradio-app/trackio/pull/291) [`3b5adc3`](https://github.com/gradio-app/trackio/commit/3b5adc3d1f452dbab7a714d235f4974782f93730) - Fix the wheel build. Thanks @pngwn!
299
+
300
+ ## 0.5.1
301
+
302
+ ### Fixes
303
+
304
+ - [#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,1165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.cpu import log_cpu as _log_cpu
23
+ from trackio.deploy import freeze, sync
24
+ from trackio.frontend_config import resolve_frontend_dir
25
+ from trackio.gpu import gpu_available
26
+ from trackio.gpu import log_gpu as _log_nvidia_gpu
27
+ from trackio.histogram import Histogram
28
+ from trackio.imports import import_csv, import_tf_events
29
+ from trackio.launch import launch_trackio_dashboard
30
+ from trackio.markdown import Markdown
31
+ from trackio.media import (
32
+ TrackioAudio,
33
+ TrackioImage,
34
+ TrackioVideo,
35
+ get_project_media_path,
36
+ )
37
+ from trackio.remote_client import RemoteClient
38
+ from trackio.run import Run
39
+ from trackio.server import TrackioDashboardApp, build_starlette_app_only
40
+ from trackio.sqlite_storage import SQLiteStorage
41
+ from trackio.table import Table
42
+ from trackio.trace import Trace
43
+ from trackio.typehints import UploadEntry
44
+ from trackio.utils import TRACKIO_DIR, TRACKIO_LOGO_DIR, _emit_nonfatal_warning
45
+
46
+ logging.getLogger("httpx").setLevel(logging.WARNING)
47
+
48
+ __version__ = json.loads(Path(__file__).parent.joinpath("package.json").read_text())[
49
+ "version"
50
+ ]
51
+
52
+
53
+ class _TupleNoPrint(tuple):
54
+ def __repr__(self) -> str:
55
+ return ""
56
+
57
+
58
+ __all__ = [
59
+ "init",
60
+ "log",
61
+ "log_system",
62
+ "log_gpu",
63
+ "log_cpu",
64
+ "finish",
65
+ "alert",
66
+ "AlertLevel",
67
+ "show",
68
+ "sync",
69
+ "freeze",
70
+ "delete_project",
71
+ "import_csv",
72
+ "import_tf_events",
73
+ "save",
74
+ "Image",
75
+ "Video",
76
+ "Audio",
77
+ "Table",
78
+ "Trace",
79
+ "Histogram",
80
+ "Markdown",
81
+ "Api",
82
+ "TRACKIO_LOGO_DIR",
83
+ ]
84
+
85
+ Audio = TrackioAudio
86
+ Image = TrackioImage
87
+ Video = TrackioVideo
88
+
89
+
90
+ config = {}
91
+
92
+ _atexit_registered = False
93
+ _projects_notified_auto_log_hw: set[str] = set()
94
+
95
+
96
+ def _cleanup_current_run():
97
+ run = context_vars.current_run.get()
98
+ if run is not None:
99
+ try:
100
+ run.finish()
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ def _safe_get_runs_for_init(
106
+ project: str,
107
+ space_id: str | None,
108
+ server_base_url: str | None,
109
+ write_token: str | None,
110
+ resume: str,
111
+ remote_client: RemoteClient | None = None,
112
+ check_existing_for_never: bool = False,
113
+ ) -> list[str]:
114
+ if space_id is not None:
115
+ if resume == "never" and not check_existing_for_never:
116
+ return []
117
+ try:
118
+ client = remote_client or RemoteClient(
119
+ space_id,
120
+ hf_token=huggingface_hub.utils.get_token(),
121
+ verbose=False,
122
+ )
123
+ runs = client.predict(project=project, api_name="/get_runs_for_project")
124
+ return runs if isinstance(runs, list) else []
125
+ except Exception as e:
126
+ _emit_nonfatal_warning(
127
+ f"trackio.init() could not inspect existing runs for project '{project}' on Space '{space_id}': {e}. Continuing without resume metadata."
128
+ )
129
+ return []
130
+ if server_base_url is not None:
131
+ if resume == "never" and not check_existing_for_never:
132
+ return []
133
+ try:
134
+ client = remote_client or RemoteClient(
135
+ server_base_url,
136
+ hf_token=None,
137
+ write_token=write_token,
138
+ verbose=False,
139
+ )
140
+ runs = client.predict(project=project, api_name="/get_runs_for_project")
141
+ return runs if isinstance(runs, list) else []
142
+ except Exception as e:
143
+ _emit_nonfatal_warning(
144
+ f"trackio.init() could not inspect existing runs for project '{project}' on self-hosted server '{server_base_url}': {e}. Continuing without resume metadata."
145
+ )
146
+ return []
147
+ try:
148
+ return SQLiteStorage.get_runs(project)
149
+ except Exception as e:
150
+ _emit_nonfatal_warning(
151
+ f"trackio.init() could not inspect existing runs for project '{project}': {e}. Continuing without resume metadata."
152
+ )
153
+ return []
154
+
155
+
156
+ def _safe_get_latest_run_for_init(
157
+ project: str,
158
+ name: str,
159
+ space_id: str | None = None,
160
+ server_base_url: str | None = None,
161
+ write_token: str | None = None,
162
+ remote_client: RemoteClient | None = None,
163
+ ) -> dict | None:
164
+ if space_id is not None:
165
+ try:
166
+ client = remote_client or RemoteClient(
167
+ space_id,
168
+ hf_token=huggingface_hub.utils.get_token(),
169
+ verbose=False,
170
+ )
171
+ runs = client.predict(project=project, api_name="/get_runs_for_project")
172
+ if not isinstance(runs, list):
173
+ return None
174
+ matches = [r for r in runs if isinstance(r, dict) and r.get("name") == name]
175
+ if not matches:
176
+ return None
177
+ matches.sort(key=lambda r: r.get("created_at") or "", reverse=True)
178
+ return matches[0]
179
+ except Exception as e:
180
+ _emit_nonfatal_warning(
181
+ f"trackio.init() could not inspect existing runs for project '{project}' on Space '{space_id}': {e}. Continuing without resume metadata."
182
+ )
183
+ return None
184
+ if server_base_url is not None:
185
+ try:
186
+ client = remote_client or RemoteClient(
187
+ server_base_url,
188
+ hf_token=None,
189
+ write_token=write_token,
190
+ verbose=False,
191
+ )
192
+ runs = client.predict(project=project, api_name="/get_runs_for_project")
193
+ if not isinstance(runs, list):
194
+ return None
195
+ matches = [r for r in runs if isinstance(r, dict) and r.get("name") == name]
196
+ if not matches:
197
+ return None
198
+ matches.sort(key=lambda r: r.get("created_at") or "", reverse=True)
199
+ return matches[0]
200
+ except Exception as e:
201
+ _emit_nonfatal_warning(
202
+ f"trackio.init() could not inspect existing runs for project '{project}' on self-hosted server '{server_base_url}': {e}. Continuing without resume metadata."
203
+ )
204
+ return None
205
+ try:
206
+ return SQLiteStorage.get_latest_run_record_by_name(project, name)
207
+ except Exception as e:
208
+ _emit_nonfatal_warning(
209
+ f"trackio.init() could not inspect existing runs for project '{project}': {e}. Continuing without resume metadata."
210
+ )
211
+ return None
212
+
213
+
214
+ def _safe_get_last_step_for_init(
215
+ project: str,
216
+ run_name: str,
217
+ space_id: str | None,
218
+ server_base_url: str | None,
219
+ write_token: str | None,
220
+ resumed: bool,
221
+ run_id: str | None = None,
222
+ remote_client: RemoteClient | None = None,
223
+ ) -> int | None:
224
+ if not resumed:
225
+ return None
226
+ if space_id is not None:
227
+ try:
228
+ client = remote_client or RemoteClient(
229
+ space_id,
230
+ hf_token=huggingface_hub.utils.get_token(),
231
+ verbose=False,
232
+ )
233
+ summary_kwargs: dict[str, Any] = {
234
+ "project": project,
235
+ "api_name": "/get_run_summary",
236
+ }
237
+ if run_id is not None:
238
+ summary_kwargs["run_id"] = run_id
239
+ else:
240
+ summary_kwargs["run"] = run_name
241
+ summary = client.predict(**summary_kwargs)
242
+ if isinstance(summary, dict):
243
+ last_step = summary.get("last_step")
244
+ return last_step if isinstance(last_step, int) else None
245
+ return None
246
+ except Exception as e:
247
+ _emit_nonfatal_warning(
248
+ f"trackio.init() could not recover the previous step for run '{run_name}' on Space '{space_id}': {e}. Continuing from step 0."
249
+ )
250
+ return None
251
+ if server_base_url is not None:
252
+ try:
253
+ client = remote_client or RemoteClient(
254
+ server_base_url,
255
+ hf_token=None,
256
+ write_token=write_token,
257
+ verbose=False,
258
+ )
259
+ summary_kwargs = {
260
+ "project": project,
261
+ "api_name": "/get_run_summary",
262
+ }
263
+ if run_id is not None:
264
+ summary_kwargs["run_id"] = run_id
265
+ else:
266
+ summary_kwargs["run"] = run_name
267
+ summary = client.predict(**summary_kwargs)
268
+ if isinstance(summary, dict):
269
+ last_step = summary.get("last_step")
270
+ return last_step if isinstance(last_step, int) else None
271
+ return None
272
+ except Exception as e:
273
+ _emit_nonfatal_warning(
274
+ f"trackio.init() could not recover the previous step for run '{run_name}' on self-hosted server '{server_base_url}': {e}. Continuing from step 0."
275
+ )
276
+ return None
277
+ try:
278
+ return SQLiteStorage.get_max_step_for_run(project, run_name, run_id=run_id)
279
+ except Exception as e:
280
+ _emit_nonfatal_warning(
281
+ f"trackio.init() could not recover the previous step for run '{run_name}': {e}. Continuing from step 0."
282
+ )
283
+ return None
284
+
285
+
286
+ def init(
287
+ project: str,
288
+ name: str | None = None,
289
+ group: str | None = None,
290
+ space_id: str | None = None,
291
+ server_url: str | None = None,
292
+ space_storage: SpaceStorage | None = None,
293
+ dataset_id: str | None = None,
294
+ bucket_id: str | None = None,
295
+ config: dict | None = None,
296
+ resume: str = "never",
297
+ settings: Any = None,
298
+ private: bool | None = None,
299
+ embed: bool = True,
300
+ auto_log_gpu: bool | None = None,
301
+ gpu_log_interval: float = 10.0,
302
+ auto_log_cpu: bool = False,
303
+ cpu_log_interval: float = 10.0,
304
+ webhook_url: str | None = None,
305
+ webhook_min_level: AlertLevel | str | None = None,
306
+ ) -> Run:
307
+ """
308
+ Creates a new Trackio project and returns a [`Run`] object.
309
+
310
+ Args:
311
+ project (`str`):
312
+ The name of the project (can be an existing project to continue tracking or
313
+ a new project to start tracking from scratch).
314
+ name (`str`, *optional*):
315
+ The name of the run (if not provided, a default name will be generated).
316
+ group (`str`, *optional*):
317
+ The name of the group which this run belongs to in order to help organize
318
+ related runs together. You can toggle the entire group's visibility in the
319
+ dashboard.
320
+ space_id (`str`, *optional*):
321
+ If provided, the project will be logged to a Hugging Face Space instead of
322
+ a local directory. Should be a complete Space name like
323
+ `"username/reponame"` or `"orgname/reponame"`, or just `"reponame"` in which
324
+ case the Space will be created in the currently-logged-in Hugging Face
325
+ user's namespace. If the Space does not exist, it will be created. If the
326
+ Space already exists, the project will be logged to it. Can also be set
327
+ via the `TRACKIO_SPACE_ID` environment variable. You cannot log to a
328
+ Space that has been **frozen** (converted to the static SDK); use
329
+ ``trackio.sync(..., sdk="static")`` only after you are done logging.
330
+ Takes precedence over `server_url` and `TRACKIO_SERVER_URL` when more than
331
+ one is set.
332
+ server_url (`str`, *optional*):
333
+ Base URL of a self-hosted Trackio server (``http://`` or ``https://``), or the
334
+ write-access URL from ``trackio.show()`` which may include a ``write_token`` query
335
+ parameter. The client sends that token on each request (``X-Trackio-Write-Token``);
336
+ you can also set ``TRACKIO_WRITE_TOKEN`` instead of embedding the token in the URL.
337
+ When set, metrics are sent to that server over HTTP instead of creating or syncing
338
+ to a Hugging Face Space. Can also be set via the ``TRACKIO_SERVER_URL`` environment
339
+ variable. Ignored when ``space_id`` or ``TRACKIO_SPACE_ID`` is set.
340
+ space_storage ([`~huggingface_hub.SpaceStorage`], *optional*):
341
+ Choice of persistent storage tier.
342
+ dataset_id (`str`, *optional*):
343
+ Deprecated. Use `bucket_id` instead.
344
+ bucket_id (`str`, *optional*):
345
+ The ID of the Hugging Face Bucket to use for metric persistence. By default,
346
+ when a `space_id` is provided and `bucket_id` is not explicitly set, a
347
+ bucket is auto-generated from the space_id. Buckets provide
348
+ S3-like storage without git overhead - the SQLite database is stored directly
349
+ via `hf-mount` in the Space. Specify a Bucket with name like
350
+ `"username/bucketname"` or just `"bucketname"`.
351
+ config (`dict`, *optional*):
352
+ A dictionary of configuration options. Provided for compatibility with
353
+ `wandb.init()`.
354
+ resume (`str`, *optional*, defaults to `"never"`):
355
+ Controls how to handle resuming a run. Can be one of:
356
+
357
+ - `"must"`: Must resume the run with the given name, raises error if run
358
+ doesn't exist
359
+ - `"allow"`: Resume the run if it exists, otherwise create a new run
360
+ - `"never"`: Never resume a run, always create a new one
361
+ private (`bool`, *optional*):
362
+ Whether to make the Space private. If None (default), the repo will be
363
+ public unless the organization's default is private. This value is ignored
364
+ if the repo already exists.
365
+ settings (`Any`, *optional*):
366
+ Not used. Provided for compatibility with `wandb.init()`.
367
+ embed (`bool`, *optional*, defaults to `True`):
368
+ If running inside a Jupyter/Colab notebook, whether the dashboard should
369
+ automatically be embedded in the cell when trackio.init() is called. For
370
+ local runs, this launches a local Trackio dashboard and embeds it. For Space runs,
371
+ this embeds the Space URL. In Colab, the local dashboard will be accessible
372
+ via a public share URL when `share=True`.
373
+ auto_log_gpu (`bool` or `None`, *optional*, defaults to `None`):
374
+ Controls automatic GPU metrics logging. If `None` (default), GPU logging
375
+ is automatically enabled when `nvidia-ml-py` is installed and an NVIDIA
376
+ GPU or Apple M series is detected. Set to `True` to force enable or
377
+ `False` to disable.
378
+ gpu_log_interval (`float`, *optional*, defaults to `10.0`):
379
+ The interval in seconds between automatic GPU metric logs.
380
+ Only used when `auto_log_gpu=True`.
381
+ auto_log_cpu (`bool`, *optional*, defaults to `False`):
382
+ Controls automatic CPU and RAM metrics logging (utilization, memory,
383
+ disk I/O, network I/O, and sensors). Requires `psutil`, which can be
384
+ installed with `pip install trackio[cpu]`. On Apple Silicon, CPU and
385
+ RAM metrics are already included in the Apple system metrics logged by
386
+ `auto_log_gpu`, so leave this disabled to avoid duplicate entries.
387
+ cpu_log_interval (`float`, *optional*, defaults to `10.0`):
388
+ The interval in seconds between automatic CPU metric logs.
389
+ Only used when `auto_log_cpu=True`.
390
+ webhook_url (`str`, *optional*):
391
+ A webhook URL to POST alert payloads to when `trackio.alert()` is
392
+ called. Supports Slack and Discord webhook URLs natively (payloads
393
+ are formatted automatically). Can also be set via the
394
+ `TRACKIO_WEBHOOK_URL` environment variable. Individual alerts can
395
+ override this URL by passing `webhook_url` to `trackio.alert()`.
396
+ webhook_min_level (`AlertLevel` or `str`, *optional*):
397
+ Minimum alert level that should trigger webhook delivery.
398
+ For example, `AlertLevel.WARN` sends only `WARN` and `ERROR`
399
+ alerts to the webhook destination. Can also be set via
400
+ `TRACKIO_WEBHOOK_MIN_LEVEL`.
401
+ Returns:
402
+ `Run`: A [`Run`] object that can be used to log metrics and finish the run.
403
+ """
404
+ if settings is not None:
405
+ _emit_nonfatal_warning(
406
+ "* 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."
407
+ )
408
+
409
+ previous_run = context_vars.current_run.get()
410
+ if previous_run is not None:
411
+ try:
412
+ previous_run.finish()
413
+ except Exception as e:
414
+ _emit_nonfatal_warning(
415
+ f"trackio.init() could not finish the previous run '{previous_run.name}': {e}. Continuing with new run."
416
+ )
417
+ context_vars.current_run.set(None)
418
+
419
+ bucket_id_was_explicit = bucket_id is not None
420
+ space_id, server_url = utils.resolve_space_id_and_server_url(space_id, server_url)
421
+ if bucket_id is None and utils.on_spaces():
422
+ bucket_id = os.environ.get("TRACKIO_BUCKET_ID")
423
+ if server_url is not None and not server_url.startswith(("http://", "https://")):
424
+ raise ValueError(
425
+ f"`server_url` must be a full URL starting with http:// or https://, got: {server_url!r}"
426
+ )
427
+ server_base_url: str | None = None
428
+ write_token_resolved: str | None = None
429
+ if server_url is not None:
430
+ server_base_url, tok = utils.parse_trackio_server_url(server_url)
431
+ write_token_resolved = tok or os.environ.get("TRACKIO_WRITE_TOKEN")
432
+ if not write_token_resolved:
433
+ raise ValueError(
434
+ "Self-hosted logging requires a write token: add write_token to the server URL, "
435
+ "or set the TRACKIO_WRITE_TOKEN environment variable."
436
+ )
437
+ if server_url is not None and (dataset_id is not None or bucket_id is not None):
438
+ raise ValueError(
439
+ "`dataset_id` and `bucket_id` are Hugging Face Spaces concepts and are not "
440
+ "compatible with `server_url`. Configure storage on the self-hosted server."
441
+ )
442
+ if space_id is None and dataset_id is not None:
443
+ raise ValueError("Must provide a `space_id` when `dataset_id` is provided.")
444
+ if dataset_id is not None and bucket_id is not None:
445
+ raise ValueError("Cannot provide both `dataset_id` and `bucket_id`.")
446
+ try:
447
+ space_id, dataset_id, bucket_id = utils.preprocess_space_and_dataset_ids(
448
+ space_id, dataset_id, bucket_id
449
+ )
450
+ if (
451
+ space_id is not None
452
+ and dataset_id is None
453
+ and bucket_id is not None
454
+ and not bucket_id_was_explicit
455
+ and not utils.on_spaces()
456
+ ):
457
+ bucket_id = deploy.resolve_auto_bucket_id(space_id, bucket_id)
458
+ except LocalTokenNotFoundError as e:
459
+ raise LocalTokenNotFoundError(
460
+ f"You must be logged in to Hugging Face locally when `space_id` is provided to deploy to a Space. {e}"
461
+ ) from e
462
+
463
+ if space_id is None and bucket_id is not None:
464
+ _emit_nonfatal_warning(
465
+ "trackio.init() has `bucket_id` set but `space_id` is None: metrics will be logged "
466
+ "locally only. Pass `space_id` to create or use a Hugging Face Space, which will be "
467
+ "attached to the Hugging Face Bucket.",
468
+ UserWarning,
469
+ stacklevel=2,
470
+ )
471
+
472
+ if space_id is not None:
473
+ deploy.raise_if_space_is_frozen_for_logging(space_id)
474
+
475
+ remote_source = space_id or server_base_url
476
+
477
+ if remote_source is not None:
478
+ url = remote_source
479
+ context_vars.current_server.set(url)
480
+ if space_id is not None:
481
+ context_vars.current_space_id.set(space_id)
482
+ context_vars.current_server_write_token.set(None)
483
+ else:
484
+ context_vars.current_space_id.set(None)
485
+ context_vars.current_server_write_token.set(write_token_resolved)
486
+ else:
487
+ url = None
488
+ context_vars.current_server.set(None)
489
+ context_vars.current_space_id.set(None)
490
+ context_vars.current_server_write_token.set(None)
491
+
492
+ _should_embed_local = False
493
+
494
+ if (
495
+ context_vars.current_project.get() is None
496
+ or context_vars.current_project.get() != project
497
+ ):
498
+ print(f"* Trackio project initialized: {project}")
499
+
500
+ if bucket_id is not None:
501
+ if utils.on_spaces():
502
+ os.environ["TRACKIO_BUCKET_ID"] = bucket_id
503
+ bucket_url = f"https://huggingface.co/buckets/{bucket_id}"
504
+ print(
505
+ f"* Trackio metrics will be synced to Hugging Face Bucket: {bucket_url}"
506
+ )
507
+ elif dataset_id is not None:
508
+ if utils.on_spaces():
509
+ os.environ["TRACKIO_DATASET_ID"] = dataset_id
510
+ print(
511
+ f"* Trackio metrics will be synced to Hugging Face Dataset: {dataset_id}"
512
+ )
513
+ if remote_source is None:
514
+ print(f"* Trackio metrics logged to: {TRACKIO_DIR}")
515
+ _should_embed_local = embed and utils.is_in_notebook()
516
+ if not _should_embed_local:
517
+ utils.print_dashboard_instructions(project)
518
+ elif server_base_url is not None:
519
+ print(
520
+ f"* Trackio metrics will be sent to self-hosted server: {server_base_url}"
521
+ )
522
+ if utils.is_in_notebook() and embed:
523
+ utils.embed_url_in_notebook(server_base_url)
524
+ else:
525
+ try:
526
+ deploy.create_space_if_not_exists(
527
+ space_id,
528
+ space_storage,
529
+ dataset_id,
530
+ bucket_id,
531
+ private,
532
+ )
533
+ user_name, space_name = space_id.split("/")
534
+ space_url = deploy.SPACE_HOST_URL.format(
535
+ user_name=user_name, space_name=space_name
536
+ )
537
+ if utils.is_in_notebook() and embed:
538
+ utils.embed_url_in_notebook(space_url)
539
+ except Exception as e:
540
+ _emit_nonfatal_warning(
541
+ f"trackio.init() could not prepare Space '{space_id}': {e}. Logging will continue in local fallback mode until the Space is reachable."
542
+ )
543
+ context_vars.current_project.set(project)
544
+
545
+ remote_client = None
546
+ if space_id is not None:
547
+ try:
548
+ remote_client = RemoteClient(
549
+ space_id,
550
+ hf_token=huggingface_hub.utils.get_token(),
551
+ verbose=False,
552
+ )
553
+ except Exception as e:
554
+ _emit_nonfatal_warning(
555
+ f"trackio.init() could not create a remote client for Space '{space_id}': {e}. Continuing with local fallback metadata lookups."
556
+ )
557
+ elif server_base_url is not None:
558
+ try:
559
+ remote_client = RemoteClient(
560
+ server_base_url,
561
+ hf_token=None,
562
+ write_token=write_token_resolved,
563
+ verbose=False,
564
+ )
565
+ except Exception as e:
566
+ _emit_nonfatal_warning(
567
+ f"trackio.init() could not create a remote client for '{server_base_url}': {e}. Continuing with local fallback metadata lookups."
568
+ )
569
+
570
+ existing_run_records = _safe_get_runs_for_init(
571
+ project,
572
+ space_id,
573
+ server_base_url,
574
+ write_token_resolved,
575
+ resume,
576
+ remote_client=remote_client,
577
+ check_existing_for_never=name is not None,
578
+ )
579
+ existing_runs = [
580
+ r["name"] if isinstance(r, dict) else r for r in existing_run_records
581
+ ]
582
+
583
+ existing_run = (
584
+ _safe_get_latest_run_for_init(
585
+ project,
586
+ name,
587
+ space_id=space_id,
588
+ server_base_url=server_base_url,
589
+ write_token=write_token_resolved,
590
+ remote_client=remote_client,
591
+ )
592
+ if name is not None
593
+ else None
594
+ )
595
+ resolved_run_id = None
596
+
597
+ if resume == "must":
598
+ if name is None:
599
+ raise ValueError("Must provide a run name when resume='must'")
600
+ if existing_run is None:
601
+ raise ValueError(f"Run '{name}' does not exist in project '{project}'")
602
+ resumed = True
603
+ resolved_run_id = existing_run["id"]
604
+ elif resume == "allow":
605
+ resumed = existing_run is not None
606
+ if resumed:
607
+ resolved_run_id = existing_run["id"]
608
+ elif resume == "never":
609
+ resumed = False
610
+ else:
611
+ raise ValueError("resume must be one of: 'must', 'allow', or 'never'")
612
+
613
+ initial_last_step = (
614
+ _safe_get_last_step_for_init(
615
+ project,
616
+ name,
617
+ space_id,
618
+ server_base_url,
619
+ write_token_resolved,
620
+ resumed,
621
+ run_id=resolved_run_id,
622
+ remote_client=remote_client,
623
+ )
624
+ if name is not None
625
+ else None
626
+ )
627
+
628
+ if auto_log_gpu is None:
629
+ nvidia_available = gpu_available()
630
+ apple_available = apple_gpu_available()
631
+ auto_log_gpu = nvidia_available or apple_available
632
+ if project not in _projects_notified_auto_log_hw:
633
+ if nvidia_available:
634
+ print("* NVIDIA GPU detected, enabling automatic GPU metrics logging")
635
+ elif apple_available:
636
+ print(
637
+ "* Apple Silicon detected, enabling automatic system metrics logging"
638
+ )
639
+ if nvidia_available or apple_available:
640
+ _projects_notified_auto_log_hw.add(project)
641
+
642
+ run = Run(
643
+ url=url,
644
+ project=project,
645
+ client=None,
646
+ name=name,
647
+ run_id=resolved_run_id,
648
+ group=group,
649
+ config=config,
650
+ space_id=space_id,
651
+ server_base_url=server_base_url,
652
+ write_token=write_token_resolved,
653
+ existing_runs=existing_runs,
654
+ initial_last_step=initial_last_step,
655
+ auto_log_gpu=auto_log_gpu,
656
+ gpu_log_interval=gpu_log_interval,
657
+ auto_log_cpu=auto_log_cpu,
658
+ cpu_log_interval=cpu_log_interval,
659
+ webhook_url=webhook_url,
660
+ webhook_min_level=webhook_min_level,
661
+ )
662
+
663
+ if space_id is not None:
664
+ try:
665
+ SQLiteStorage.set_project_metadata(project, "space_id", space_id)
666
+ except Exception as e:
667
+ _emit_nonfatal_warning(
668
+ f"trackio.init() could not persist Space metadata for project '{project}': {e}. Logging will continue."
669
+ )
670
+ try:
671
+ if SQLiteStorage.has_pending_data(project):
672
+ run._has_local_buffer = True
673
+ except Exception as e:
674
+ _emit_nonfatal_warning(
675
+ f"trackio.init() could not inspect pending buffered data for project '{project}': {e}. Logging will continue."
676
+ )
677
+
678
+ global _atexit_registered
679
+ if not _atexit_registered:
680
+ atexit.register(_cleanup_current_run)
681
+ _atexit_registered = True
682
+
683
+ if resumed:
684
+ print(f"* Resumed existing run: {run.name}")
685
+ else:
686
+ print(f"* Created new run: {run.name}")
687
+
688
+ context_vars.current_run.set(run)
689
+ globals()["config"] = run.config
690
+
691
+ if _should_embed_local:
692
+ try:
693
+ show(project=project, open_browser=False, block_thread=False)
694
+ except Exception as e:
695
+ _emit_nonfatal_warning(
696
+ f"trackio.init() could not auto-launch the dashboard: {e}. Logging will continue."
697
+ )
698
+
699
+ return run
700
+
701
+
702
+ def log(metrics: dict, step: int | None = None) -> None:
703
+ """
704
+ Logs metrics to the current run.
705
+
706
+ Args:
707
+ metrics (`dict`):
708
+ A dictionary of metrics to log.
709
+ step (`int`, *optional*):
710
+ The step number. If not provided, the step will be incremented
711
+ automatically.
712
+ """
713
+ run = context_vars.current_run.get()
714
+ if run is None:
715
+ raise RuntimeError("Call trackio.init() before trackio.log().")
716
+ run.log(
717
+ metrics=metrics,
718
+ step=step,
719
+ )
720
+
721
+
722
+ def log_system(metrics: dict) -> None:
723
+ """
724
+ Logs system metrics (GPU, etc.) to the current run using timestamps instead of steps.
725
+
726
+ Args:
727
+ metrics (`dict`):
728
+ A dictionary of system metrics to log.
729
+ """
730
+ run = context_vars.current_run.get()
731
+ if run is None:
732
+ raise RuntimeError("Call trackio.init() before trackio.log_system().")
733
+ run.log_system(metrics=metrics)
734
+
735
+
736
+ def log_gpu(run: Run | None = None, device: int | None = None) -> dict:
737
+ """
738
+ Log GPU metrics to the current or specified run as system metrics.
739
+ Automatically detects whether an NVIDIA or Apple GPU is available and calls
740
+ the appropriate logging method.
741
+
742
+ Args:
743
+ run: Optional Run instance. If None, uses current run from context.
744
+ device: CUDA device index to collect metrics from (NVIDIA GPUs only).
745
+ If None, collects from all GPUs visible to this process.
746
+ This parameter is ignored for Apple GPUs.
747
+
748
+ Returns:
749
+ dict: The GPU metrics that were logged.
750
+
751
+ Example:
752
+ ```python
753
+ import trackio
754
+
755
+ run = trackio.init(project="my-project")
756
+ trackio.log({"loss": 0.5})
757
+ trackio.log_gpu()
758
+ trackio.log_gpu(device=0)
759
+ ```
760
+ """
761
+ if run is None:
762
+ run = context_vars.current_run.get()
763
+ if run is None:
764
+ raise RuntimeError("Call trackio.init() before trackio.log_gpu().")
765
+
766
+ if gpu_available():
767
+ return _log_nvidia_gpu(run=run, device=device)
768
+ elif apple_gpu_available():
769
+ return _log_apple_gpu(run=run)
770
+ else:
771
+ _emit_nonfatal_warning(
772
+ "No GPU detected. Install nvidia-ml-py for NVIDIA GPU support "
773
+ "or psutil for Apple Silicon support."
774
+ )
775
+ return {}
776
+
777
+
778
+ def log_cpu(run: Run | None = None) -> dict:
779
+ """
780
+ Log CPU, RAM, disk, network, and sensor metrics to the current or specified run
781
+ as system metrics.
782
+
783
+ Args:
784
+ run: Optional Run instance. If None, uses current run from context.
785
+
786
+ Returns:
787
+ dict: The CPU and system metrics that were logged.
788
+
789
+ Example:
790
+ ```python
791
+ import trackio
792
+
793
+ run = trackio.init(project="my-project")
794
+ trackio.log({"loss": 0.5})
795
+ trackio.log_cpu()
796
+ ```
797
+ """
798
+ if run is None:
799
+ run = context_vars.current_run.get()
800
+ if run is None:
801
+ raise RuntimeError("Call trackio.init() before trackio.log_cpu().")
802
+
803
+ return _log_cpu(run=run)
804
+
805
+
806
+ def finish():
807
+ """
808
+ Finishes the current run.
809
+ """
810
+ run = context_vars.current_run.get()
811
+ if run is None:
812
+ raise RuntimeError("Call trackio.init() before trackio.finish().")
813
+ try:
814
+ run.finish()
815
+ finally:
816
+ context_vars.current_run.set(None)
817
+
818
+
819
+ def alert(
820
+ title: str,
821
+ text: str | None = None,
822
+ level: AlertLevel = AlertLevel.WARN,
823
+ webhook_url: str | None = None,
824
+ ) -> None:
825
+ """
826
+ Fires an alert immediately on the current run. The alert is printed to the
827
+ terminal, stored in the database, and displayed in the dashboard. If a
828
+ webhook URL is configured (via `trackio.init()`, the `TRACKIO_WEBHOOK_URL`
829
+ environment variable, or the `webhook_url` parameter here), the alert is
830
+ also POSTed to that URL.
831
+
832
+ Args:
833
+ title (`str`):
834
+ A short title for the alert.
835
+ text (`str`, *optional*):
836
+ A longer description with details about the alert.
837
+ level (`AlertLevel`, *optional*, defaults to `AlertLevel.WARN`):
838
+ The severity level. One of `AlertLevel.INFO`, `AlertLevel.WARN`,
839
+ or `AlertLevel.ERROR`.
840
+ webhook_url (`str`, *optional*):
841
+ A webhook URL to send this specific alert to. Overrides any
842
+ URL set in `trackio.init()` or the `TRACKIO_WEBHOOK_URL`
843
+ environment variable. Supports Slack and Discord webhook
844
+ URLs natively.
845
+ """
846
+ run = context_vars.current_run.get()
847
+ if run is None:
848
+ raise RuntimeError("Call trackio.init() before trackio.alert().")
849
+ run.alert(title=title, text=text, level=level, webhook_url=webhook_url)
850
+
851
+
852
+ def delete_project(project: str, force: bool = False) -> bool:
853
+ """
854
+ Deletes a project by removing its local SQLite database.
855
+
856
+ Args:
857
+ project (`str`):
858
+ The name of the project to delete.
859
+ force (`bool`, *optional*, defaults to `False`):
860
+ If `True`, deletes the project without prompting for confirmation.
861
+ If `False`, prompts the user to confirm before deleting.
862
+
863
+ Returns:
864
+ `bool`: `True` if the project was deleted, `False` otherwise.
865
+ """
866
+ db_path = SQLiteStorage.get_project_db_path(project)
867
+
868
+ if not db_path.exists():
869
+ print(f"* Project '{project}' does not exist.")
870
+ return False
871
+
872
+ if not force:
873
+ response = input(
874
+ f"Are you sure you want to delete project '{project}'? "
875
+ f"This will permanently delete all runs and metrics. (y/N): "
876
+ )
877
+ if response.lower() not in ["y", "yes"]:
878
+ print("* Deletion cancelled.")
879
+ return False
880
+
881
+ try:
882
+ db_path.unlink()
883
+
884
+ for suffix in ("-wal", "-shm"):
885
+ sidecar = Path(str(db_path) + suffix)
886
+ if sidecar.exists():
887
+ sidecar.unlink()
888
+
889
+ print(f"* Project '{project}' has been deleted.")
890
+ return True
891
+ except Exception as e:
892
+ print(f"* Error deleting project '{project}': {e}")
893
+ return False
894
+
895
+
896
+ def save(
897
+ glob_str: str | Path,
898
+ project: str | None = None,
899
+ ) -> str:
900
+ """
901
+ Saves files to a project (not linked to a specific run). If Trackio is running
902
+ locally, the file(s) will be copied to the project's files directory. If Trackio is
903
+ running in a Space, the file(s) will be uploaded to the Space's files directory.
904
+
905
+ Args:
906
+ glob_str (`str` or `Path`):
907
+ The file path or glob pattern to save. Can be a single file or a pattern
908
+ matching multiple files (e.g., `"*.py"`, `"models/**/*.pth"`).
909
+ project (`str`, *optional*):
910
+ The name of the project to save files to. If not provided, uses the current
911
+ project from `trackio.init()`. If no project is initialized, raises an
912
+ error.
913
+
914
+ Returns:
915
+ `str`: The path where the file(s) were saved (project's files directory).
916
+
917
+ Example:
918
+ ```python
919
+ import trackio
920
+
921
+ trackio.init(project="my-project")
922
+ trackio.save("config.yaml")
923
+ trackio.save("models/*.pth")
924
+ ```
925
+ """
926
+ if project is None:
927
+ project = context_vars.current_project.get()
928
+ if project is None:
929
+ raise RuntimeError(
930
+ "No project specified. Either call trackio.init() first or provide a "
931
+ "project parameter to trackio.save()."
932
+ )
933
+
934
+ glob_str = Path(glob_str)
935
+ base_path = Path.cwd().resolve()
936
+
937
+ matched_files = []
938
+ if glob_str.is_file():
939
+ matched_files = [glob_str.resolve()]
940
+ else:
941
+ pattern = str(glob_str)
942
+ if not glob_str.is_absolute():
943
+ pattern = str((Path.cwd() / glob_str).resolve())
944
+ matched_files = [
945
+ Path(f).resolve()
946
+ for f in glob.glob(pattern, recursive=True)
947
+ if Path(f).is_file()
948
+ ]
949
+
950
+ if not matched_files:
951
+ raise ValueError(f"No files found matching pattern: {glob_str}")
952
+
953
+ current_run = context_vars.current_run.get()
954
+ is_local = (
955
+ current_run._is_local
956
+ if current_run is not None
957
+ else (
958
+ context_vars.current_space_id.get() is None
959
+ and context_vars.current_server.get() is None
960
+ )
961
+ )
962
+
963
+ if is_local:
964
+ for file_path in matched_files:
965
+ try:
966
+ relative_to_base = file_path.relative_to(base_path)
967
+ except ValueError:
968
+ relative_to_base = Path(file_path.name)
969
+
970
+ if current_run is not None:
971
+ current_run._queue_upload(
972
+ file_path,
973
+ step=None,
974
+ relative_path=str(relative_to_base.parent),
975
+ use_run_name=False,
976
+ )
977
+ else:
978
+ media_path = get_project_media_path(
979
+ project=project,
980
+ run=None,
981
+ step=None,
982
+ relative_path=str(relative_to_base),
983
+ )
984
+ shutil.copy(str(file_path), str(media_path))
985
+ else:
986
+ url = context_vars.current_server.get()
987
+
988
+ upload_entries = []
989
+ for file_path in matched_files:
990
+ try:
991
+ relative_to_base = file_path.relative_to(base_path)
992
+ except ValueError:
993
+ relative_to_base = Path(file_path.name)
994
+
995
+ if current_run is not None:
996
+ current_run._queue_upload(
997
+ file_path,
998
+ step=None,
999
+ relative_path=str(relative_to_base.parent),
1000
+ use_run_name=False,
1001
+ )
1002
+ else:
1003
+ upload_entry: UploadEntry = {
1004
+ "project": project,
1005
+ "run": None,
1006
+ "step": None,
1007
+ "relative_path": str(relative_to_base),
1008
+ "uploaded_file": handle_file(file_path),
1009
+ }
1010
+ upload_entries.append(upload_entry)
1011
+
1012
+ if upload_entries:
1013
+ if url is None:
1014
+ raise RuntimeError(
1015
+ "No server available. Call trackio.init() before trackio.save() to start the server."
1016
+ )
1017
+
1018
+ try:
1019
+ wt = context_vars.current_server_write_token.get()
1020
+ if wt is not None:
1021
+ client = RemoteClient(
1022
+ url,
1023
+ hf_token=None,
1024
+ write_token=wt,
1025
+ httpx_kwargs={"timeout": 90},
1026
+ )
1027
+ else:
1028
+ client = RemoteClient(
1029
+ url,
1030
+ hf_token=huggingface_hub.utils.get_token(),
1031
+ httpx_kwargs={"timeout": 90},
1032
+ )
1033
+ client.predict(
1034
+ api_name="/bulk_upload_media",
1035
+ uploads=upload_entries,
1036
+ hf_token=huggingface_hub.utils.get_token() if wt is None else None,
1037
+ )
1038
+ except Exception as e:
1039
+ _emit_nonfatal_warning(
1040
+ f"Failed to upload files: {e}. "
1041
+ "Files may not be available in the dashboard."
1042
+ )
1043
+
1044
+ return str(utils.MEDIA_DIR / project / "files")
1045
+
1046
+
1047
+ def show(
1048
+ project: str | None = None,
1049
+ *,
1050
+ theme: Any = None,
1051
+ mcp_server: bool | None = None,
1052
+ footer: bool = True,
1053
+ color_palette: list[str] | None = None,
1054
+ open_browser: bool = True,
1055
+ block_thread: bool | None = None,
1056
+ host: str | None = None,
1057
+ share: bool | None = None,
1058
+ server_port: int | None = None,
1059
+ frontend_dir: str | Path | None = None,
1060
+ ):
1061
+ """
1062
+ Launches the Trackio dashboard.
1063
+
1064
+ Args:
1065
+ project (`str`, *optional*):
1066
+ The name of the project whose runs to show. If not provided, all projects
1067
+ will be shown and the user can select one.
1068
+ theme (`Any`, *optional*):
1069
+ Ignored. Kept for backward compatibility; Trackio no longer uses Gradio themes.
1070
+ mcp_server (`bool`, *optional*):
1071
+ If `True`, the dashboard exposes an MCP server at `/mcp` when the optional
1072
+ `trackio[mcp]` dependency is installed. If `None` (default), the
1073
+ `GRADIO_MCP_SERVER` environment variable is used (e.g. on Spaces).
1074
+ footer (`bool`, *optional*, defaults to `True`):
1075
+ Whether to include `footer=false` in the write-token URL when `False`.
1076
+ This can also be controlled via the `footer` query parameter in the URL.
1077
+ color_palette (`list[str]`, *optional*):
1078
+ A list of hex color codes to use for plot lines. If not provided, the
1079
+ `TRACKIO_COLOR_PALETTE` environment variable will be used (comma-separated
1080
+ hex codes), or if that is not set, the default color palette will be used.
1081
+ Example: `['#FF0000', '#00FF00', '#0000FF']`
1082
+ open_browser (`bool`, *optional*, defaults to `True`):
1083
+ If `True` and not in a notebook, a new browser tab will be opened with the
1084
+ dashboard. If `False`, the browser will not be opened.
1085
+ block_thread (`bool`, *optional*):
1086
+ If `True`, the main thread will be blocked until the dashboard is closed.
1087
+ If `None` (default behavior), then the main thread will not be blocked if the
1088
+ dashboard is launched in a notebook, otherwise the main thread will be blocked.
1089
+ host (`str`, *optional*):
1090
+ The host to bind the server to. If not provided, defaults to `'127.0.0.1'`
1091
+ (localhost only). Set to `'0.0.0.0'` to allow remote access.
1092
+ share (`bool`, *optional*):
1093
+ If `True`, creates a temporary public URL (Gradio-compatible tunnel). On Colab
1094
+ or hosted notebooks, defaults to `True` unless overridden.
1095
+ server_port (`int`, *optional*):
1096
+ Port to bind. If not set, scans from `GRADIO_SERVER_PORT` (default 7860).
1097
+ frontend_dir (`str | Path`, *optional*):
1098
+ Directory containing a custom static frontend. Must contain `index.html`.
1099
+ If not provided, Trackio checks `TRACKIO_FRONTEND_DIR`, then the persistent
1100
+ Trackio config, then the bundled frontend. If an explicit `frontend_dir`
1101
+ points to a missing or empty directory, Trackio copies in the starter
1102
+ template and serves that directory.
1103
+
1104
+ Returns:
1105
+ `app`: The dashboard handle (`.close()` stops the server).
1106
+ `url`: The local URL of the dashboard.
1107
+ `share_url`: The public share URL, if any.
1108
+ `full_url`: The full URL including the write token (share URL when sharing, else local).
1109
+ """
1110
+ if theme is not None and theme != "default":
1111
+ warnings.warn(
1112
+ "The theme argument is ignored; Trackio no longer depends on Gradio themes.",
1113
+ UserWarning,
1114
+ stacklevel=2,
1115
+ )
1116
+
1117
+ if color_palette is not None:
1118
+ os.environ["TRACKIO_COLOR_PALETTE"] = ",".join(color_palette)
1119
+
1120
+ _mcp_server = (
1121
+ mcp_server
1122
+ if mcp_server is not None
1123
+ else os.environ.get("GRADIO_MCP_SERVER", "False") == "True"
1124
+ )
1125
+
1126
+ resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
1127
+ starlette_app, wt = build_starlette_app_only(
1128
+ mcp_server=_mcp_server,
1129
+ frontend_dir=str(resolved_frontend.path),
1130
+ )
1131
+ local_url, share_url, _local_api_url, uv_server = launch_trackio_dashboard(
1132
+ starlette_app,
1133
+ server_name=host,
1134
+ server_port=server_port,
1135
+ share=share,
1136
+ mcp_server=_mcp_server,
1137
+ quiet=True,
1138
+ )
1139
+ server = TrackioDashboardApp(starlette_app, uv_server, wt)
1140
+
1141
+ base_root = (share_url or local_url).rstrip("/")
1142
+ base_url = base_root + "/"
1143
+ dashboard_url = base_url
1144
+ if project:
1145
+ dashboard_url += f"?project={project}"
1146
+ full_url = utils.get_full_url(
1147
+ base_root,
1148
+ project=project,
1149
+ write_token=wt,
1150
+ footer=footer,
1151
+ )
1152
+
1153
+ if not utils.is_in_notebook():
1154
+ print(f"\033[1m\033[38;5;208m* Trackio UI launched at: {dashboard_url}\033[0m")
1155
+ utils.print_write_token_instructions(full_url)
1156
+ if open_browser:
1157
+ webbrowser.open(full_url)
1158
+ block_thread = block_thread if block_thread is not None else True
1159
+ else:
1160
+ utils.embed_url_in_notebook(full_url)
1161
+ block_thread = block_thread if block_thread is not None else False
1162
+
1163
+ if block_thread:
1164
+ utils.block_main_thread_until_keyboard_interrupt()
1165
+ 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,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(self.project, str(record["name"]), run_id=str(record["id"]))
60
+ for record in records
61
+ ]
62
+
63
+ def __iter__(self) -> Iterator[Run]:
64
+ self._load_runs()
65
+ return iter(self._runs)
66
+
67
+ def __getitem__(self, index: int) -> Run:
68
+ self._load_runs()
69
+ return self._runs[index]
70
+
71
+ def __len__(self) -> int:
72
+ self._load_runs()
73
+ return len(self._runs)
74
+
75
+ def __repr__(self) -> str:
76
+ self._load_runs()
77
+ return f"<Runs project={self.project} count={len(self._runs)}>"
78
+
79
+
80
+ class Api:
81
+ def runs(self, project: str) -> Runs:
82
+ if not SQLiteStorage.get_project_db_path(project).exists():
83
+ raise ValueError(f"Project '{project}' does not exist")
84
+ return Runs(project)
85
+
86
+ def alerts(
87
+ self,
88
+ project: str,
89
+ run: str | None = None,
90
+ level: str | None = None,
91
+ since: str | None = None,
92
+ ) -> list[dict]:
93
+ if not SQLiteStorage.get_project_db_path(project).exists():
94
+ raise ValueError(f"Project '{project}' does not exist")
95
+ return SQLiteStorage.get_alerts(project, run_name=run, level=level, since=since)
trackio/apple_gpu.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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() -> 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
+ from trackio.cpu import collect_cpu_metrics
114
+
115
+ metrics = collect_cpu_metrics(include_static=False)
116
+
117
+ gpu_info = get_gpu_info()
118
+ if gpu_info.get("detected"):
119
+ metrics["gpu/detected"] = 1
120
+
121
+ return metrics
122
+
123
+
124
+ class AppleGpuMonitor:
125
+ def __init__(self, run: "Run", interval: float = 10.0):
126
+ self._run = run
127
+ self._interval = interval
128
+ self._stop_flag = threading.Event()
129
+ self._thread: "threading.Thread | None" = None
130
+
131
+ def start(self):
132
+ if not is_apple_silicon():
133
+ warnings.warn(
134
+ "auto_log_gpu=True but not running on Apple Silicon. "
135
+ "Apple GPU logging disabled."
136
+ )
137
+ return
138
+
139
+ if not PSUTIL_AVAILABLE:
140
+ try:
141
+ _ensure_psutil()
142
+ except ImportError:
143
+ warnings.warn(
144
+ "auto_log_gpu=True but psutil not installed. "
145
+ "Install with: pip install psutil"
146
+ )
147
+ return
148
+
149
+ self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
150
+ self._thread.start()
151
+
152
+ def stop(self):
153
+ self._stop_flag.set()
154
+ if self._thread is not None:
155
+ self._thread.join(timeout=2.0)
156
+
157
+ def _monitor_loop(self):
158
+ while not self._stop_flag.is_set():
159
+ try:
160
+ metrics = collect_apple_metrics()
161
+ if metrics:
162
+ self._run.log_system(metrics)
163
+ except Exception:
164
+ pass
165
+
166
+ self._stop_flag.wait(timeout=self._interval)
167
+
168
+
169
+ def log_apple_gpu(run: "Run | None" = None) -> dict:
170
+ """
171
+ Log Apple Silicon system metrics to the current or specified run.
172
+
173
+ Args:
174
+ run: Optional Run instance. If None, uses current run from context.
175
+
176
+ Returns:
177
+ dict: The system metrics that were logged.
178
+
179
+ Example:
180
+ ```python
181
+ import trackio
182
+
183
+ run = trackio.init(project="my-project")
184
+ trackio.log({"loss": 0.5})
185
+ trackio.log_apple_gpu()
186
+ ```
187
+ """
188
+ from trackio import context_vars
189
+
190
+ if run is None:
191
+ run = context_vars.current_run.get()
192
+ if run is None:
193
+ raise RuntimeError("Call trackio.init() before trackio.log_apple_gpu().")
194
+
195
+ metrics = collect_apple_metrics()
196
+ if metrics:
197
+ run.log_system(metrics)
198
+ return metrics
trackio/asgi_app.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.exceptions import TrackioAPIError
21
+ from trackio.remote_client import HTTP_API_VERSION
22
+ from trackio.utils import on_spaces
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 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
+ allowed_roots = getattr(request.app.state, "allowed_file_roots", ())
428
+ if fp.is_file() and _is_allowed_file_path(fp, allowed_roots):
429
+ return FileResponse(str(fp))
430
+ return Response("Not found", status_code=404)
431
+
432
+
433
+ def create_trackio_starlette_app(
434
+ oauth_routes: list[Route],
435
+ api_registry: dict[str, Any],
436
+ extra_routes: list[Any] | None = None,
437
+ mcp_lifespan: Any = None,
438
+ mcp_enabled: bool = False,
439
+ allowed_file_roots: list[str | Path] | None = None,
440
+ upload_authorizer: Callable[[Request], None] | None = None,
441
+ ) -> Starlette:
442
+ routes: list[Any] = list(oauth_routes)
443
+ routes.extend(
444
+ [
445
+ Route("/version", endpoint=version_handler, methods=["GET"]),
446
+ Route("/api/upload", endpoint=upload_handler, methods=["POST"]),
447
+ Route("/api/{api_name:str}", endpoint=api_handler, methods=["POST"]),
448
+ Route("/file", endpoint=file_handler, methods=["GET"]),
449
+ ]
450
+ )
451
+ if on_spaces():
452
+ routes.extend(
453
+ [
454
+ Route(
455
+ "/gradio_api/info",
456
+ endpoint=gradio_api_info_handler,
457
+ methods=["GET"],
458
+ ),
459
+ Route(
460
+ "/gradio_api/info/",
461
+ endpoint=gradio_api_info_handler,
462
+ methods=["GET"],
463
+ ),
464
+ Route(
465
+ "/gradio_api/upload",
466
+ endpoint=gradio_upload_alias_handler,
467
+ methods=["POST"],
468
+ ),
469
+ Route(
470
+ "/gradio_api/upload/",
471
+ endpoint=gradio_upload_alias_handler,
472
+ methods=["POST"],
473
+ ),
474
+ Route(
475
+ "/gradio_api/call/{api_name:str}",
476
+ endpoint=gradio_call_post_handler,
477
+ methods=["POST"],
478
+ ),
479
+ Route(
480
+ "/gradio_api/call/{api_name:str}/",
481
+ endpoint=gradio_call_post_handler,
482
+ methods=["POST"],
483
+ ),
484
+ Route(
485
+ "/gradio_api/call/{api_name:str}/{event_id:str}",
486
+ endpoint=gradio_call_poll_handler,
487
+ methods=["GET"],
488
+ ),
489
+ Route(
490
+ "/gradio_api/call/{api_name:str}/{event_id:str}/",
491
+ endpoint=gradio_call_poll_handler,
492
+ methods=["GET"],
493
+ ),
494
+ ]
495
+ )
496
+ routes.extend(extra_routes or [])
497
+ app = Starlette(routes=routes, lifespan=mcp_lifespan)
498
+ app.state.api_registry = api_registry
499
+ app.state.mcp_enabled = mcp_enabled
500
+ app.state.allowed_file_roots = _normalize_allowed_file_roots(allowed_file_roots)
501
+ app.state.upload_authorizer = upload_authorizer
502
+ app.state.uploaded_temp_files = set()
503
+ app.state.uploaded_temp_files_lock = threading.Lock()
504
+ if on_spaces():
505
+ app.state.gradio_call_events = {}
506
+ app.state.gradio_call_events_lock = threading.Lock()
507
+ 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,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import huggingface_hub
6
+
7
+ from trackio.sqlite_storage import SQLiteStorage
8
+ from trackio.utils import MEDIA_DIR, TRACKIO_DIR
9
+
10
+
11
+ def create_bucket_if_not_exists(bucket_id: str, private: bool | None = None) -> None:
12
+ huggingface_hub.create_bucket(bucket_id, private=private, exist_ok=True)
13
+
14
+
15
+ def _list_bucket_file_paths(bucket_id: str, prefix: str | None = None) -> list[str]:
16
+ items = huggingface_hub.list_bucket_tree(bucket_id, prefix=prefix, recursive=True)
17
+ return [
18
+ item.path
19
+ for item in items
20
+ if getattr(item, "type", None) == "file" and getattr(item, "path", None)
21
+ ]
22
+
23
+
24
+ def download_bucket_to_trackio_dir(bucket_id: str) -> None:
25
+ TRACKIO_DIR.mkdir(parents=True, exist_ok=True)
26
+ huggingface_hub.sync_bucket(
27
+ source=f"hf://buckets/{bucket_id}/trackio",
28
+ dest=str(TRACKIO_DIR),
29
+ quiet=True,
30
+ )
31
+
32
+
33
+ def upload_project_to_bucket(project: str, bucket_id: str) -> None:
34
+ db_path = SQLiteStorage.get_project_db_path(project)
35
+ if not db_path.exists():
36
+ raise FileNotFoundError(f"No database found for project '{project}'")
37
+
38
+ with SQLiteStorage._get_connection(
39
+ db_path, configure_pragmas=False, row_factory=None
40
+ ) as conn:
41
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
42
+
43
+ files_to_add = [(str(db_path), f"trackio/{db_path.name}")]
44
+
45
+ media_dir = MEDIA_DIR / project
46
+ if media_dir.exists():
47
+ for media_file in media_dir.rglob("*"):
48
+ if media_file.is_file():
49
+ rel = media_file.relative_to(TRACKIO_DIR)
50
+ files_to_add.append((str(media_file), f"trackio/{rel}"))
51
+
52
+ huggingface_hub.batch_bucket_files(bucket_id, add=files_to_add)
53
+
54
+
55
+ def _download_db_from_bucket(
56
+ project: str, bucket_id: str, dest_path: Path | None = None
57
+ ) -> bool:
58
+ db_filename = SQLiteStorage.get_project_db_filename(project)
59
+ remote_path = f"trackio/{db_filename}"
60
+ local_path = dest_path or SQLiteStorage.get_project_db_path(project)
61
+ local_path.parent.mkdir(parents=True, exist_ok=True)
62
+ try:
63
+ huggingface_hub.download_bucket_files(
64
+ bucket_id,
65
+ files=[(remote_path, str(local_path))],
66
+ token=huggingface_hub.utils.get_token(),
67
+ )
68
+ return local_path.exists()
69
+ except Exception:
70
+ return False
71
+
72
+
73
+ def _local_db_has_data(project: str) -> bool:
74
+ db_path = SQLiteStorage.get_project_db_path(project)
75
+ if not db_path.exists() or db_path.stat().st_size == 0:
76
+ return False
77
+ try:
78
+ with SQLiteStorage._get_connection(
79
+ db_path, configure_pragmas=False, row_factory=None
80
+ ) as conn:
81
+ count = conn.execute("SELECT COUNT(*) FROM metrics").fetchone()[0]
82
+ return count > 0
83
+ except Exception:
84
+ return False
85
+
86
+
87
+ def _export_and_upload_static(
88
+ project: str,
89
+ dest_bucket_id: str,
90
+ db_path: Path,
91
+ media_dir: Path | None = None,
92
+ ) -> None:
93
+ with tempfile.TemporaryDirectory() as tmp_dir:
94
+ output_dir = Path(tmp_dir)
95
+ SQLiteStorage.export_for_static_space(
96
+ project, output_dir, db_path_override=db_path
97
+ )
98
+
99
+ if media_dir and media_dir.exists():
100
+ shutil.copytree(media_dir, output_dir / "media")
101
+
102
+ files_to_add = []
103
+ for f in output_dir.rglob("*"):
104
+ if f.is_file():
105
+ rel = f.relative_to(output_dir)
106
+ files_to_add.append((str(f), str(rel)))
107
+
108
+ huggingface_hub.batch_bucket_files(dest_bucket_id, add=files_to_add)
109
+
110
+
111
+ def _copy_project_media_between_buckets(
112
+ source_bucket_id: str, dest_bucket_id: str, project: str
113
+ ) -> None:
114
+ source_media_prefix = f"trackio/media/{project}/"
115
+ media_to_copy = _list_bucket_file_paths(
116
+ source_bucket_id, prefix=source_media_prefix
117
+ )
118
+ if not media_to_copy:
119
+ return
120
+
121
+ huggingface_hub.copy_files(
122
+ f"hf://buckets/{source_bucket_id}/{source_media_prefix}",
123
+ f"hf://buckets/{dest_bucket_id}/media/",
124
+ )
125
+
126
+
127
+ def upload_project_to_bucket_for_static(project: str, bucket_id: str) -> None:
128
+ if not _local_db_has_data(project):
129
+ _download_db_from_bucket(project, bucket_id)
130
+
131
+ db_path = SQLiteStorage.get_project_db_path(project)
132
+ _export_and_upload_static(project, bucket_id, db_path, MEDIA_DIR / project)
133
+
134
+
135
+ def export_from_bucket_for_static(
136
+ source_bucket_id: str,
137
+ dest_bucket_id: str,
138
+ project: str,
139
+ ) -> None:
140
+ with tempfile.TemporaryDirectory() as work_dir:
141
+ work_path = Path(work_dir)
142
+ db_path = work_path / SQLiteStorage.get_project_db_filename(project)
143
+
144
+ if not _download_db_from_bucket(project, source_bucket_id, dest_path=db_path):
145
+ raise FileNotFoundError(
146
+ f"Could not download database for project '{project}' "
147
+ f"from bucket '{source_bucket_id}'."
148
+ )
149
+
150
+ _export_and_upload_static(project, dest_bucket_id, db_path)
151
+ _copy_project_media_between_buckets(source_bucket_id, dest_bucket_id, project)
trackio/cli.py ADDED
@@ -0,0 +1,1419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+
4
+ from trackio import freeze, show, sync
5
+ from trackio.cli_helpers import (
6
+ error_exit,
7
+ format_alerts,
8
+ format_json,
9
+ format_list,
10
+ format_metric_values,
11
+ format_project_summary,
12
+ format_query_result,
13
+ format_run_summary,
14
+ format_snapshot,
15
+ format_system_metric_names,
16
+ format_system_metrics,
17
+ )
18
+ from trackio.frontend_config import (
19
+ TRACKIO_CONFIG_PATH,
20
+ get_persisted_frontend_dir,
21
+ set_persisted_frontend_dir,
22
+ unset_persisted_frontend_dir,
23
+ )
24
+ from trackio.markdown import Markdown
25
+ from trackio.server import get_project_summary, get_run_summary
26
+ from trackio.sqlite_storage import SQLiteStorage
27
+
28
+
29
+ def _get_space(args):
30
+ return getattr(args, "space", None)
31
+
32
+
33
+ def _get_remote(args):
34
+ from trackio.remote_client import RemoteClient
35
+
36
+ space = _get_space(args)
37
+ if not space:
38
+ return None
39
+ hf_token = getattr(args, "hf_token", None)
40
+ return RemoteClient(space, hf_token=hf_token)
41
+
42
+
43
+ def _handle_status():
44
+ print("Reading local Trackio projects...\n")
45
+ projects = SQLiteStorage.get_projects()
46
+ if not projects:
47
+ print("No Trackio projects found.")
48
+ return
49
+
50
+ local_projects = []
51
+ synced_projects = []
52
+ unsynced_projects = []
53
+
54
+ for project in projects:
55
+ space_id = SQLiteStorage.get_space_id(project)
56
+ if space_id is None:
57
+ local_projects.append(project)
58
+ elif SQLiteStorage.has_pending_data(project):
59
+ unsynced_projects.append(project)
60
+ else:
61
+ synced_projects.append(project)
62
+
63
+ print("Finished reading Trackio projects")
64
+ if local_projects:
65
+ print(f" * {len(local_projects)} local trackio project(s) [OK]")
66
+ if synced_projects:
67
+ print(f" * {len(synced_projects)} trackio project(s) synced to Spaces [OK]")
68
+ if unsynced_projects:
69
+ print(
70
+ f" * {len(unsynced_projects)} trackio project(s) with unsynced changes [WARNING]:"
71
+ )
72
+ for p in unsynced_projects:
73
+ print(f" - {p}")
74
+
75
+ if unsynced_projects:
76
+ print(
77
+ f"\nRun `trackio sync --project {unsynced_projects[0]}` to sync. "
78
+ "Or run `trackio sync --all` to sync all unsynced changes."
79
+ )
80
+
81
+
82
+ def _handle_sync(args):
83
+ from trackio.deploy import sync_incremental
84
+
85
+ if args.sync_all and args.project:
86
+ error_exit("Cannot use --all and --project together.")
87
+ if not args.sync_all and not args.project:
88
+ error_exit("Must provide either --project or --all.")
89
+
90
+ if args.sync_all:
91
+ projects = SQLiteStorage.get_projects()
92
+ synced_any = False
93
+ for project in projects:
94
+ space_id = SQLiteStorage.get_space_id(project)
95
+ if space_id and SQLiteStorage.has_pending_data(project):
96
+ sync_incremental(
97
+ project,
98
+ space_id,
99
+ private=args.private,
100
+ pending_only=True,
101
+ frontend_dir=args.frontend,
102
+ )
103
+ synced_any = True
104
+ if not synced_any:
105
+ print("No projects with unsynced data found.")
106
+ else:
107
+ space_id = args.space_id
108
+ if space_id is None:
109
+ space_id = SQLiteStorage.get_space_id(args.project)
110
+ sync(
111
+ project=args.project,
112
+ space_id=space_id,
113
+ private=args.private,
114
+ force=args.force,
115
+ sdk=args.sdk,
116
+ frontend_dir=args.frontend,
117
+ )
118
+
119
+
120
+ def _handle_config(args):
121
+ if args.config_command == "get":
122
+ frontend_dir = get_persisted_frontend_dir()
123
+ if frontend_dir is None:
124
+ print("No Trackio frontend config is set.")
125
+ print(f"Config file: {TRACKIO_CONFIG_PATH}")
126
+ return
127
+ print(f"frontend: {frontend_dir}")
128
+ print(f"config: {TRACKIO_CONFIG_PATH}")
129
+ return
130
+
131
+ if args.config_command == "set":
132
+ try:
133
+ frontend_dir = set_persisted_frontend_dir(args.frontend)
134
+ except ValueError as e:
135
+ error_exit(str(e))
136
+ print(f"Saved Trackio default frontend: {frontend_dir}")
137
+ print("Reset with `trackio config unset frontend`.")
138
+ return
139
+
140
+ if args.config_command == "unset":
141
+ removed = unset_persisted_frontend_dir()
142
+ if removed:
143
+ print("Removed Trackio default frontend.")
144
+ else:
145
+ print("No Trackio default frontend was set.")
146
+ return
147
+
148
+
149
+ def _extract_reports(
150
+ run: str, logs: list[dict], report_name: str | None = None
151
+ ) -> list[dict]:
152
+ reports = []
153
+ for log in logs:
154
+ timestamp = log.get("timestamp")
155
+ step = log.get("step")
156
+ for key, value in log.items():
157
+ if report_name is not None and key != report_name:
158
+ continue
159
+ if isinstance(value, dict) and value.get("_type") == Markdown.TYPE:
160
+ content = value.get("_value")
161
+ if isinstance(content, str):
162
+ reports.append(
163
+ {
164
+ "run": run,
165
+ "report": key,
166
+ "step": step,
167
+ "timestamp": timestamp,
168
+ "content": content,
169
+ }
170
+ )
171
+ return reports
172
+
173
+
174
+ def _handle_query(args):
175
+ remote = _get_remote(args)
176
+ try:
177
+ if remote:
178
+ result = remote.predict(args.project, args.sql, api_name="/query_project")
179
+ else:
180
+ result = SQLiteStorage.query_project(args.project, args.sql)
181
+ except FileNotFoundError as e:
182
+ error_exit(str(e))
183
+ except ValueError as e:
184
+ error_exit(str(e))
185
+
186
+ if args.json:
187
+ print(format_json(result))
188
+ else:
189
+ print(format_query_result(result))
190
+
191
+
192
+ def main():
193
+ parser = argparse.ArgumentParser(description="Trackio CLI")
194
+ parser.add_argument(
195
+ "--space",
196
+ required=False,
197
+ help="HF Space ID (e.g. 'user/space') or Space URL to query remotely.",
198
+ )
199
+ parser.add_argument(
200
+ "--hf-token",
201
+ required=False,
202
+ help="HF token for accessing private Spaces.",
203
+ )
204
+ subparsers = parser.add_subparsers(dest="command")
205
+
206
+ ui_parser = subparsers.add_parser(
207
+ "show", help="Show the Trackio dashboard UI for a project"
208
+ )
209
+ ui_parser.add_argument(
210
+ "--project", required=False, help="Project name to show in the dashboard"
211
+ )
212
+ ui_parser.add_argument(
213
+ "--theme",
214
+ required=False,
215
+ default="default",
216
+ 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').",
217
+ )
218
+ ui_parser.add_argument(
219
+ "--mcp-server",
220
+ action="store_true",
221
+ 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.",
222
+ )
223
+ ui_parser.add_argument(
224
+ "--footer",
225
+ action="store_true",
226
+ default=True,
227
+ help="Show the Gradio footer. Use --no-footer to hide it.",
228
+ )
229
+ ui_parser.add_argument(
230
+ "--no-footer",
231
+ dest="footer",
232
+ action="store_false",
233
+ help="Hide the Gradio footer.",
234
+ )
235
+ ui_parser.add_argument(
236
+ "--color-palette",
237
+ required=False,
238
+ 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.",
239
+ )
240
+ ui_parser.add_argument(
241
+ "--host",
242
+ required=False,
243
+ 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).",
244
+ )
245
+ ui_parser.add_argument(
246
+ "--frontend",
247
+ required=False,
248
+ help="Custom frontend directory to serve. Must contain index.html.",
249
+ )
250
+
251
+ subparsers.add_parser(
252
+ "status",
253
+ help="Show the status of all local Trackio projects, including sync status.",
254
+ )
255
+
256
+ sync_parser = subparsers.add_parser(
257
+ "sync",
258
+ help="Sync a local project's database to a Hugging Face Space. If the Space does not exist, it will be created.",
259
+ )
260
+ sync_parser.add_argument(
261
+ "--project",
262
+ required=False,
263
+ help="The name of the local project.",
264
+ )
265
+ sync_parser.add_argument(
266
+ "--space-id",
267
+ required=False,
268
+ 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.",
269
+ )
270
+ sync_parser.add_argument(
271
+ "--all",
272
+ action="store_true",
273
+ dest="sync_all",
274
+ help="Sync all projects that have unsynced data to their configured Spaces.",
275
+ )
276
+ sync_parser.add_argument(
277
+ "--private",
278
+ action="store_true",
279
+ 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.",
280
+ )
281
+ sync_parser.add_argument(
282
+ "--force",
283
+ action="store_true",
284
+ help="Overwrite the existing database without prompting for confirmation.",
285
+ )
286
+ sync_parser.add_argument(
287
+ "--sdk",
288
+ choices=["gradio", "static"],
289
+ default="gradio",
290
+ 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.",
291
+ )
292
+ sync_parser.add_argument(
293
+ "--frontend",
294
+ required=False,
295
+ help="Custom frontend directory to deploy. Must contain index.html.",
296
+ )
297
+
298
+ freeze_parser = subparsers.add_parser(
299
+ "freeze",
300
+ help="Create a one-time static Space snapshot from a project's data.",
301
+ )
302
+ freeze_parser.add_argument(
303
+ "--space-id",
304
+ required=True,
305
+ help="The source Gradio Space ID (e.g. username/space_id).",
306
+ )
307
+ freeze_parser.add_argument(
308
+ "--project",
309
+ required=True,
310
+ help="The name of the project to freeze into a static snapshot.",
311
+ )
312
+ freeze_parser.add_argument(
313
+ "--new-space-id",
314
+ required=False,
315
+ help="The Space ID for the new static Space. Defaults to {space_id}_static.",
316
+ )
317
+ freeze_parser.add_argument(
318
+ "--private",
319
+ action="store_true",
320
+ help="Make the new static Space private.",
321
+ )
322
+ freeze_parser.add_argument(
323
+ "--frontend",
324
+ required=False,
325
+ help="Custom frontend directory to deploy to the frozen static Space.",
326
+ )
327
+
328
+ config_parser = subparsers.add_parser(
329
+ "config",
330
+ help="Manage persistent Trackio configuration.",
331
+ )
332
+ config_subparsers = config_parser.add_subparsers(
333
+ dest="config_command",
334
+ required=True,
335
+ )
336
+ config_subparsers.add_parser("get", help="Show current Trackio config.")
337
+ config_set_parser = config_subparsers.add_parser(
338
+ "set",
339
+ help="Set a persistent Trackio config value.",
340
+ )
341
+ config_set_parser.add_argument(
342
+ "key",
343
+ choices=["frontend"],
344
+ help="Config key to set.",
345
+ )
346
+ config_set_parser.add_argument(
347
+ "frontend",
348
+ help="Frontend directory to persist.",
349
+ )
350
+ config_unset_parser = config_subparsers.add_parser(
351
+ "unset",
352
+ help="Unset a persistent Trackio config value.",
353
+ )
354
+ config_unset_parser.add_argument(
355
+ "key",
356
+ choices=["frontend"],
357
+ help="Config key to unset.",
358
+ )
359
+
360
+ list_parser = subparsers.add_parser(
361
+ "list",
362
+ help="List projects, runs, or metrics",
363
+ )
364
+ list_subparsers = list_parser.add_subparsers(dest="list_type", required=True)
365
+
366
+ list_projects_parser = list_subparsers.add_parser(
367
+ "projects",
368
+ help="List all projects",
369
+ )
370
+ list_projects_parser.add_argument(
371
+ "--json",
372
+ action="store_true",
373
+ help="Output in JSON format",
374
+ )
375
+
376
+ list_runs_parser = list_subparsers.add_parser(
377
+ "runs",
378
+ help="List runs for a project",
379
+ )
380
+ list_runs_parser.add_argument(
381
+ "--project",
382
+ required=True,
383
+ help="Project name",
384
+ )
385
+ list_runs_parser.add_argument(
386
+ "--json",
387
+ action="store_true",
388
+ help="Output in JSON format",
389
+ )
390
+
391
+ list_metrics_parser = list_subparsers.add_parser(
392
+ "metrics",
393
+ help="List metrics for a run",
394
+ )
395
+ list_metrics_parser.add_argument(
396
+ "--project",
397
+ required=True,
398
+ help="Project name",
399
+ )
400
+ list_metrics_parser.add_argument(
401
+ "--run",
402
+ required=True,
403
+ help="Run name",
404
+ )
405
+ list_metrics_parser.add_argument(
406
+ "--json",
407
+ action="store_true",
408
+ help="Output in JSON format",
409
+ )
410
+
411
+ list_system_metrics_parser = list_subparsers.add_parser(
412
+ "system-metrics",
413
+ help="List system metrics for a run",
414
+ )
415
+ list_system_metrics_parser.add_argument(
416
+ "--project",
417
+ required=True,
418
+ help="Project name",
419
+ )
420
+ list_system_metrics_parser.add_argument(
421
+ "--run",
422
+ required=True,
423
+ help="Run name",
424
+ )
425
+ list_system_metrics_parser.add_argument(
426
+ "--json",
427
+ action="store_true",
428
+ help="Output in JSON format",
429
+ )
430
+
431
+ list_alerts_parser = list_subparsers.add_parser(
432
+ "alerts",
433
+ help="List alerts for a project or run",
434
+ )
435
+ list_alerts_parser.add_argument(
436
+ "--project",
437
+ required=True,
438
+ help="Project name",
439
+ )
440
+ list_alerts_parser.add_argument(
441
+ "--run",
442
+ required=False,
443
+ help="Run name (optional)",
444
+ )
445
+ list_alerts_parser.add_argument(
446
+ "--level",
447
+ required=False,
448
+ help="Filter by alert level (info, warn, error)",
449
+ )
450
+ list_alerts_parser.add_argument(
451
+ "--json",
452
+ action="store_true",
453
+ help="Output in JSON format",
454
+ )
455
+ list_alerts_parser.add_argument(
456
+ "--since",
457
+ required=False,
458
+ help="Only show alerts after this ISO 8601 timestamp",
459
+ )
460
+
461
+ list_reports_parser = list_subparsers.add_parser(
462
+ "reports",
463
+ help="List markdown reports for a project or run",
464
+ )
465
+ list_reports_parser.add_argument(
466
+ "--project",
467
+ required=True,
468
+ help="Project name",
469
+ )
470
+ list_reports_parser.add_argument(
471
+ "--run",
472
+ required=False,
473
+ help="Run name (optional)",
474
+ )
475
+ list_reports_parser.add_argument(
476
+ "--json",
477
+ action="store_true",
478
+ help="Output in JSON format",
479
+ )
480
+
481
+ get_parser = subparsers.add_parser(
482
+ "get",
483
+ help="Get project, run, or metric information",
484
+ )
485
+ get_subparsers = get_parser.add_subparsers(dest="get_type", required=True)
486
+
487
+ get_project_parser = get_subparsers.add_parser(
488
+ "project",
489
+ help="Get project summary",
490
+ )
491
+ get_project_parser.add_argument(
492
+ "--project",
493
+ required=True,
494
+ help="Project name",
495
+ )
496
+ get_project_parser.add_argument(
497
+ "--json",
498
+ action="store_true",
499
+ help="Output in JSON format",
500
+ )
501
+
502
+ get_run_parser = get_subparsers.add_parser(
503
+ "run",
504
+ help="Get run summary",
505
+ )
506
+ get_run_parser.add_argument(
507
+ "--project",
508
+ required=True,
509
+ help="Project name",
510
+ )
511
+ get_run_parser.add_argument(
512
+ "--run",
513
+ required=True,
514
+ help="Run name",
515
+ )
516
+ get_run_parser.add_argument(
517
+ "--json",
518
+ action="store_true",
519
+ help="Output in JSON format",
520
+ )
521
+
522
+ get_metric_parser = get_subparsers.add_parser(
523
+ "metric",
524
+ help="Get metric values for a run",
525
+ )
526
+ get_metric_parser.add_argument(
527
+ "--project",
528
+ required=True,
529
+ help="Project name",
530
+ )
531
+ get_metric_parser.add_argument(
532
+ "--run",
533
+ required=True,
534
+ help="Run name",
535
+ )
536
+ get_metric_parser.add_argument(
537
+ "--metric",
538
+ required=True,
539
+ help="Metric name",
540
+ )
541
+ get_metric_parser.add_argument(
542
+ "--step",
543
+ type=int,
544
+ required=False,
545
+ help="Get metric at exactly this step",
546
+ )
547
+ get_metric_parser.add_argument(
548
+ "--around",
549
+ type=int,
550
+ required=False,
551
+ help="Get metrics around this step (use with --window)",
552
+ )
553
+ get_metric_parser.add_argument(
554
+ "--at-time",
555
+ required=False,
556
+ help="Get metrics around this ISO 8601 timestamp (use with --window)",
557
+ )
558
+ get_metric_parser.add_argument(
559
+ "--window",
560
+ type=int,
561
+ required=False,
562
+ default=10,
563
+ help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)",
564
+ )
565
+ get_metric_parser.add_argument(
566
+ "--json",
567
+ action="store_true",
568
+ help="Output in JSON format",
569
+ )
570
+
571
+ get_snapshot_parser = get_subparsers.add_parser(
572
+ "snapshot",
573
+ help="Get all metrics at/around a step or timestamp",
574
+ )
575
+ get_snapshot_parser.add_argument(
576
+ "--project",
577
+ required=True,
578
+ help="Project name",
579
+ )
580
+ get_snapshot_parser.add_argument(
581
+ "--run",
582
+ required=True,
583
+ help="Run name",
584
+ )
585
+ get_snapshot_parser.add_argument(
586
+ "--step",
587
+ type=int,
588
+ required=False,
589
+ help="Get all metrics at exactly this step",
590
+ )
591
+ get_snapshot_parser.add_argument(
592
+ "--around",
593
+ type=int,
594
+ required=False,
595
+ help="Get all metrics around this step (use with --window)",
596
+ )
597
+ get_snapshot_parser.add_argument(
598
+ "--at-time",
599
+ required=False,
600
+ help="Get all metrics around this ISO 8601 timestamp (use with --window)",
601
+ )
602
+ get_snapshot_parser.add_argument(
603
+ "--window",
604
+ type=int,
605
+ required=False,
606
+ default=10,
607
+ help="Window size: ±steps for --around, ±seconds for --at-time (default: 10)",
608
+ )
609
+ get_snapshot_parser.add_argument(
610
+ "--json",
611
+ action="store_true",
612
+ help="Output in JSON format",
613
+ )
614
+
615
+ get_system_metric_parser = get_subparsers.add_parser(
616
+ "system-metric",
617
+ help="Get system metric values for a run",
618
+ )
619
+ get_system_metric_parser.add_argument(
620
+ "--project",
621
+ required=True,
622
+ help="Project name",
623
+ )
624
+ get_system_metric_parser.add_argument(
625
+ "--run",
626
+ required=True,
627
+ help="Run name",
628
+ )
629
+ get_system_metric_parser.add_argument(
630
+ "--metric",
631
+ required=False,
632
+ help="System metric name (optional, if not provided returns all system metrics)",
633
+ )
634
+ get_system_metric_parser.add_argument(
635
+ "--json",
636
+ action="store_true",
637
+ help="Output in JSON format",
638
+ )
639
+
640
+ get_alerts_parser = get_subparsers.add_parser(
641
+ "alerts",
642
+ help="Get alerts for a project or run",
643
+ )
644
+ get_alerts_parser.add_argument(
645
+ "--project",
646
+ required=True,
647
+ help="Project name",
648
+ )
649
+ get_alerts_parser.add_argument(
650
+ "--run",
651
+ required=False,
652
+ help="Run name (optional)",
653
+ )
654
+ get_alerts_parser.add_argument(
655
+ "--level",
656
+ required=False,
657
+ help="Filter by alert level (info, warn, error)",
658
+ )
659
+ get_alerts_parser.add_argument(
660
+ "--json",
661
+ action="store_true",
662
+ help="Output in JSON format",
663
+ )
664
+ get_alerts_parser.add_argument(
665
+ "--since",
666
+ required=False,
667
+ help="Only show alerts after this ISO 8601 timestamp",
668
+ )
669
+
670
+ get_report_parser = get_subparsers.add_parser(
671
+ "report",
672
+ help="Get markdown report entries for a run",
673
+ )
674
+ get_report_parser.add_argument(
675
+ "--project",
676
+ required=True,
677
+ help="Project name",
678
+ )
679
+ get_report_parser.add_argument(
680
+ "--run",
681
+ required=True,
682
+ help="Run name",
683
+ )
684
+ get_report_parser.add_argument(
685
+ "--report",
686
+ required=True,
687
+ help="Report metric name",
688
+ )
689
+ get_report_parser.add_argument(
690
+ "--json",
691
+ action="store_true",
692
+ help="Output in JSON format",
693
+ )
694
+
695
+ query_parser = subparsers.add_parser(
696
+ "query",
697
+ help="Run a read-only SQL query against a project database",
698
+ )
699
+ query_subparsers = query_parser.add_subparsers(dest="query_type", required=True)
700
+ query_project_parser = query_subparsers.add_parser(
701
+ "project",
702
+ help="Run a read-only SQL query against a project's SQLite database",
703
+ )
704
+ query_project_parser.add_argument(
705
+ "--project",
706
+ required=True,
707
+ help="Project name",
708
+ )
709
+ query_project_parser.add_argument(
710
+ "--sql",
711
+ required=True,
712
+ help="Read-only SQL query to execute",
713
+ )
714
+ query_project_parser.add_argument(
715
+ "--json",
716
+ action="store_true",
717
+ help="Output in JSON format",
718
+ )
719
+
720
+ skills_parser = subparsers.add_parser(
721
+ "skills",
722
+ help="Manage Trackio skills for AI coding assistants",
723
+ )
724
+ skills_subparsers = skills_parser.add_subparsers(
725
+ dest="skills_action", required=True
726
+ )
727
+ skills_add_parser = skills_subparsers.add_parser(
728
+ "add",
729
+ help="Download and install the Trackio skill for an AI assistant",
730
+ )
731
+ skills_add_parser.add_argument(
732
+ "--cursor",
733
+ action="store_true",
734
+ help="Install for Cursor",
735
+ )
736
+ skills_add_parser.add_argument(
737
+ "--claude",
738
+ action="store_true",
739
+ help="Install for Claude Code",
740
+ )
741
+ skills_add_parser.add_argument(
742
+ "--codex",
743
+ action="store_true",
744
+ help="Install for Codex",
745
+ )
746
+ skills_add_parser.add_argument(
747
+ "--opencode",
748
+ action="store_true",
749
+ help="Install for OpenCode",
750
+ )
751
+ skills_add_parser.add_argument(
752
+ "--global",
753
+ dest="global_",
754
+ action="store_true",
755
+ help="Install globally (user-level) instead of in the current project directory",
756
+ )
757
+ skills_add_parser.add_argument(
758
+ "--dest",
759
+ type=str,
760
+ required=False,
761
+ help="Install into a custom destination (path to skills directory)",
762
+ )
763
+ skills_add_parser.add_argument(
764
+ "--force",
765
+ action="store_true",
766
+ help="Overwrite existing skill if it already exists",
767
+ )
768
+
769
+ args, unknown_args = parser.parse_known_args()
770
+ if unknown_args:
771
+ trailing_global_parser = argparse.ArgumentParser(add_help=False)
772
+ trailing_global_parser.add_argument("--space", required=False)
773
+ trailing_global_parser.add_argument("--hf-token", required=False)
774
+ trailing_globals, remaining_unknown = trailing_global_parser.parse_known_args(
775
+ unknown_args
776
+ )
777
+ if remaining_unknown:
778
+ parser.error(f"unrecognized arguments: {' '.join(remaining_unknown)}")
779
+ if trailing_globals.space is not None:
780
+ args.space = trailing_globals.space
781
+ if trailing_globals.hf_token is not None:
782
+ args.hf_token = trailing_globals.hf_token
783
+
784
+ if args.command in ("show", "status", "sync", "freeze", "skills") and _get_space(
785
+ args
786
+ ):
787
+ error_exit(
788
+ f"The '{args.command}' command does not support --space (remote mode)."
789
+ )
790
+
791
+ if args.command == "show":
792
+ color_palette = None
793
+ if args.color_palette:
794
+ color_palette = [color.strip() for color in args.color_palette.split(",")]
795
+ show(
796
+ project=args.project,
797
+ theme=args.theme,
798
+ mcp_server=args.mcp_server,
799
+ footer=args.footer,
800
+ color_palette=color_palette,
801
+ host=args.host,
802
+ frontend_dir=args.frontend,
803
+ )
804
+ elif args.command == "status":
805
+ _handle_status()
806
+ elif args.command == "sync":
807
+ _handle_sync(args)
808
+ elif args.command == "freeze":
809
+ freeze(
810
+ space_id=args.space_id,
811
+ project=args.project,
812
+ new_space_id=args.new_space_id,
813
+ private=args.private,
814
+ frontend_dir=args.frontend,
815
+ )
816
+ elif args.command == "config":
817
+ _handle_config(args)
818
+ elif args.command == "list":
819
+ remote = _get_remote(args)
820
+ if args.list_type == "projects":
821
+ if remote:
822
+ projects = remote.predict(api_name="/get_all_projects")
823
+ else:
824
+ projects = SQLiteStorage.get_projects()
825
+ if args.json:
826
+ print(format_json({"projects": projects}))
827
+ else:
828
+ print(format_list(projects, "Projects"))
829
+ elif args.list_type == "runs":
830
+ if remote:
831
+ run_records = remote.predict(
832
+ args.project, api_name="/get_runs_for_project"
833
+ )
834
+ runs = [r["name"] if isinstance(r, dict) else r for r in run_records]
835
+ else:
836
+ db_path = SQLiteStorage.get_project_db_path(args.project)
837
+ if not db_path.exists():
838
+ error_exit(f"Project '{args.project}' not found.")
839
+ runs = SQLiteStorage.get_runs(args.project)
840
+ if args.json:
841
+ print(format_json({"project": args.project, "runs": runs}))
842
+ else:
843
+ print(format_list(runs, f"Runs in '{args.project}'"))
844
+ elif args.list_type == "metrics":
845
+ if remote:
846
+ metrics = remote.predict(
847
+ args.project, args.run, api_name="/get_metrics_for_run"
848
+ )
849
+ else:
850
+ db_path = SQLiteStorage.get_project_db_path(args.project)
851
+ if not db_path.exists():
852
+ error_exit(f"Project '{args.project}' not found.")
853
+ runs = SQLiteStorage.get_runs(args.project)
854
+ if args.run not in runs:
855
+ error_exit(
856
+ f"Run '{args.run}' not found in project '{args.project}'."
857
+ )
858
+ metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run)
859
+ if args.json:
860
+ print(
861
+ format_json(
862
+ {"project": args.project, "run": args.run, "metrics": metrics}
863
+ )
864
+ )
865
+ else:
866
+ print(
867
+ format_list(
868
+ metrics, f"Metrics for '{args.run}' in '{args.project}'"
869
+ )
870
+ )
871
+ elif args.list_type == "system-metrics":
872
+ if remote:
873
+ system_metrics = remote.predict(
874
+ args.project, args.run, api_name="/get_system_metrics_for_run"
875
+ )
876
+ else:
877
+ db_path = SQLiteStorage.get_project_db_path(args.project)
878
+ if not db_path.exists():
879
+ error_exit(f"Project '{args.project}' not found.")
880
+ runs = SQLiteStorage.get_runs(args.project)
881
+ if args.run not in runs:
882
+ error_exit(
883
+ f"Run '{args.run}' not found in project '{args.project}'."
884
+ )
885
+ system_metrics = SQLiteStorage.get_all_system_metrics_for_run(
886
+ args.project, args.run
887
+ )
888
+ if args.json:
889
+ print(
890
+ format_json(
891
+ {
892
+ "project": args.project,
893
+ "run": args.run,
894
+ "system_metrics": system_metrics,
895
+ }
896
+ )
897
+ )
898
+ else:
899
+ print(format_system_metric_names(system_metrics))
900
+ elif args.list_type == "alerts":
901
+ if remote:
902
+ alerts = remote.predict(
903
+ args.project,
904
+ args.run,
905
+ args.level,
906
+ args.since,
907
+ api_name="/get_alerts",
908
+ )
909
+ else:
910
+ db_path = SQLiteStorage.get_project_db_path(args.project)
911
+ if not db_path.exists():
912
+ error_exit(f"Project '{args.project}' not found.")
913
+ alerts = SQLiteStorage.get_alerts(
914
+ args.project,
915
+ run_name=args.run,
916
+ level=args.level,
917
+ since=args.since,
918
+ )
919
+ if args.json:
920
+ print(
921
+ format_json(
922
+ {
923
+ "project": args.project,
924
+ "run": args.run,
925
+ "level": args.level,
926
+ "since": args.since,
927
+ "alerts": alerts,
928
+ }
929
+ )
930
+ )
931
+ else:
932
+ print(format_alerts(alerts))
933
+ elif args.list_type == "reports":
934
+ if remote:
935
+ run_records = remote.predict(
936
+ args.project, api_name="/get_runs_for_project"
937
+ )
938
+ runs = [r["name"] if isinstance(r, dict) else r for r in run_records]
939
+ else:
940
+ db_path = SQLiteStorage.get_project_db_path(args.project)
941
+ if not db_path.exists():
942
+ error_exit(f"Project '{args.project}' not found.")
943
+ runs = SQLiteStorage.get_runs(args.project)
944
+ if args.run and args.run not in runs:
945
+ error_exit(f"Run '{args.run}' not found in project '{args.project}'.")
946
+
947
+ target_runs = [args.run] if args.run else runs
948
+ all_reports = []
949
+ for run_name in target_runs:
950
+ if remote:
951
+ logs = remote.predict(args.project, run_name, api_name="/get_logs")
952
+ else:
953
+ logs = SQLiteStorage.get_logs(args.project, run_name)
954
+ all_reports.extend(_extract_reports(run_name, logs))
955
+
956
+ if args.json:
957
+ print(
958
+ format_json(
959
+ {
960
+ "project": args.project,
961
+ "run": args.run,
962
+ "reports": all_reports,
963
+ }
964
+ )
965
+ )
966
+ else:
967
+ report_lines = [
968
+ f"{entry['run']} | {entry['report']} | step={entry['step']} | {entry['timestamp']}"
969
+ for entry in all_reports
970
+ ]
971
+ if args.run:
972
+ print(
973
+ format_list(
974
+ report_lines,
975
+ f"Reports for '{args.run}' in '{args.project}'",
976
+ )
977
+ )
978
+ else:
979
+ print(format_list(report_lines, f"Reports in '{args.project}'"))
980
+ elif args.command == "get":
981
+ remote = _get_remote(args)
982
+ if args.get_type == "project":
983
+ if remote:
984
+ summary = remote.predict(args.project, api_name="/get_project_summary")
985
+ else:
986
+ db_path = SQLiteStorage.get_project_db_path(args.project)
987
+ if not db_path.exists():
988
+ error_exit(f"Project '{args.project}' not found.")
989
+ summary = get_project_summary(args.project)
990
+ if args.json:
991
+ print(format_json(summary))
992
+ else:
993
+ print(format_project_summary(summary))
994
+ elif args.get_type == "run":
995
+ if remote:
996
+ summary = remote.predict(
997
+ args.project, args.run, api_name="/get_run_summary"
998
+ )
999
+ else:
1000
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1001
+ if not db_path.exists():
1002
+ error_exit(f"Project '{args.project}' not found.")
1003
+ runs = SQLiteStorage.get_runs(args.project)
1004
+ if args.run not in runs:
1005
+ error_exit(
1006
+ f"Run '{args.run}' not found in project '{args.project}'."
1007
+ )
1008
+ summary = get_run_summary(args.project, args.run)
1009
+ if args.json:
1010
+ print(format_json(summary))
1011
+ else:
1012
+ print(format_run_summary(summary))
1013
+ elif args.get_type == "metric":
1014
+ at_time = getattr(args, "at_time", None)
1015
+ if remote:
1016
+ values = remote.predict(
1017
+ args.project,
1018
+ args.run,
1019
+ args.metric,
1020
+ args.step,
1021
+ args.around,
1022
+ at_time,
1023
+ args.window,
1024
+ api_name="/get_metric_values",
1025
+ )
1026
+ else:
1027
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1028
+ if not db_path.exists():
1029
+ error_exit(f"Project '{args.project}' not found.")
1030
+ runs = SQLiteStorage.get_runs(args.project)
1031
+ if args.run not in runs:
1032
+ error_exit(
1033
+ f"Run '{args.run}' not found in project '{args.project}'."
1034
+ )
1035
+ metrics = SQLiteStorage.get_all_metrics_for_run(args.project, args.run)
1036
+ if args.metric not in metrics:
1037
+ error_exit(
1038
+ f"Metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
1039
+ )
1040
+ values = SQLiteStorage.get_metric_values(
1041
+ args.project,
1042
+ args.run,
1043
+ args.metric,
1044
+ step=args.step,
1045
+ around_step=args.around,
1046
+ at_time=at_time,
1047
+ window=args.window,
1048
+ )
1049
+ if args.json:
1050
+ print(
1051
+ format_json(
1052
+ {
1053
+ "project": args.project,
1054
+ "run": args.run,
1055
+ "metric": args.metric,
1056
+ "values": values,
1057
+ }
1058
+ )
1059
+ )
1060
+ else:
1061
+ print(format_metric_values(values))
1062
+ elif args.get_type == "snapshot":
1063
+ if not args.step and not args.around and not getattr(args, "at_time", None):
1064
+ error_exit(
1065
+ "Provide --step, --around (with --window), or --at-time (with --window)."
1066
+ )
1067
+ at_time = getattr(args, "at_time", None)
1068
+ if remote:
1069
+ snapshot = remote.predict(
1070
+ args.project,
1071
+ args.run,
1072
+ args.step,
1073
+ args.around,
1074
+ at_time,
1075
+ args.window,
1076
+ api_name="/get_snapshot",
1077
+ )
1078
+ else:
1079
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1080
+ if not db_path.exists():
1081
+ error_exit(f"Project '{args.project}' not found.")
1082
+ runs = SQLiteStorage.get_runs(args.project)
1083
+ if args.run not in runs:
1084
+ error_exit(
1085
+ f"Run '{args.run}' not found in project '{args.project}'."
1086
+ )
1087
+ snapshot = SQLiteStorage.get_snapshot(
1088
+ args.project,
1089
+ args.run,
1090
+ step=args.step,
1091
+ around_step=args.around,
1092
+ at_time=at_time,
1093
+ window=args.window,
1094
+ )
1095
+ if args.json:
1096
+ result = {
1097
+ "project": args.project,
1098
+ "run": args.run,
1099
+ "metrics": snapshot,
1100
+ }
1101
+ if args.step is not None:
1102
+ result["step"] = args.step
1103
+ if args.around is not None:
1104
+ result["around"] = args.around
1105
+ result["window"] = args.window
1106
+ if at_time is not None:
1107
+ result["at_time"] = at_time
1108
+ result["window"] = args.window
1109
+ print(format_json(result))
1110
+ else:
1111
+ print(format_snapshot(snapshot))
1112
+ elif args.get_type == "system-metric":
1113
+ if remote:
1114
+ system_metrics = remote.predict(
1115
+ args.project, args.run, api_name="/get_system_logs"
1116
+ )
1117
+ if args.metric:
1118
+ all_system_metric_names = remote.predict(
1119
+ args.project,
1120
+ args.run,
1121
+ api_name="/get_system_metrics_for_run",
1122
+ )
1123
+ if args.metric not in all_system_metric_names:
1124
+ error_exit(
1125
+ f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
1126
+ )
1127
+ filtered_metrics = [
1128
+ {
1129
+ k: v
1130
+ for k, v in entry.items()
1131
+ if k == "timestamp" or k == args.metric
1132
+ }
1133
+ for entry in system_metrics
1134
+ if args.metric in entry
1135
+ ]
1136
+ if args.json:
1137
+ print(
1138
+ format_json(
1139
+ {
1140
+ "project": args.project,
1141
+ "run": args.run,
1142
+ "metric": args.metric,
1143
+ "values": filtered_metrics,
1144
+ }
1145
+ )
1146
+ )
1147
+ else:
1148
+ print(format_system_metrics(filtered_metrics))
1149
+ else:
1150
+ if args.json:
1151
+ print(
1152
+ format_json(
1153
+ {
1154
+ "project": args.project,
1155
+ "run": args.run,
1156
+ "system_metrics": system_metrics,
1157
+ }
1158
+ )
1159
+ )
1160
+ else:
1161
+ print(format_system_metrics(system_metrics))
1162
+ else:
1163
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1164
+ if not db_path.exists():
1165
+ error_exit(f"Project '{args.project}' not found.")
1166
+ runs = SQLiteStorage.get_runs(args.project)
1167
+ if args.run not in runs:
1168
+ error_exit(
1169
+ f"Run '{args.run}' not found in project '{args.project}'."
1170
+ )
1171
+ if args.metric:
1172
+ system_metrics = SQLiteStorage.get_system_logs(
1173
+ args.project, args.run
1174
+ )
1175
+ all_system_metric_names = (
1176
+ SQLiteStorage.get_all_system_metrics_for_run(
1177
+ args.project, args.run
1178
+ )
1179
+ )
1180
+ if args.metric not in all_system_metric_names:
1181
+ error_exit(
1182
+ f"System metric '{args.metric}' not found in run '{args.run}' of project '{args.project}'."
1183
+ )
1184
+ filtered_metrics = [
1185
+ {
1186
+ k: v
1187
+ for k, v in entry.items()
1188
+ if k == "timestamp" or k == args.metric
1189
+ }
1190
+ for entry in system_metrics
1191
+ if args.metric in entry
1192
+ ]
1193
+ if args.json:
1194
+ print(
1195
+ format_json(
1196
+ {
1197
+ "project": args.project,
1198
+ "run": args.run,
1199
+ "metric": args.metric,
1200
+ "values": filtered_metrics,
1201
+ }
1202
+ )
1203
+ )
1204
+ else:
1205
+ print(format_system_metrics(filtered_metrics))
1206
+ else:
1207
+ system_metrics = SQLiteStorage.get_system_logs(
1208
+ args.project, args.run
1209
+ )
1210
+ if args.json:
1211
+ print(
1212
+ format_json(
1213
+ {
1214
+ "project": args.project,
1215
+ "run": args.run,
1216
+ "system_metrics": system_metrics,
1217
+ }
1218
+ )
1219
+ )
1220
+ else:
1221
+ print(format_system_metrics(system_metrics))
1222
+ elif args.get_type == "alerts":
1223
+ if remote:
1224
+ alerts = remote.predict(
1225
+ args.project,
1226
+ args.run,
1227
+ args.level,
1228
+ args.since,
1229
+ api_name="/get_alerts",
1230
+ )
1231
+ else:
1232
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1233
+ if not db_path.exists():
1234
+ error_exit(f"Project '{args.project}' not found.")
1235
+ alerts = SQLiteStorage.get_alerts(
1236
+ args.project,
1237
+ run_name=args.run,
1238
+ level=args.level,
1239
+ since=args.since,
1240
+ )
1241
+ if args.json:
1242
+ print(
1243
+ format_json(
1244
+ {
1245
+ "project": args.project,
1246
+ "run": args.run,
1247
+ "level": args.level,
1248
+ "since": args.since,
1249
+ "alerts": alerts,
1250
+ }
1251
+ )
1252
+ )
1253
+ else:
1254
+ print(format_alerts(alerts))
1255
+ elif args.get_type == "report":
1256
+ if remote:
1257
+ logs = remote.predict(args.project, args.run, api_name="/get_logs")
1258
+ else:
1259
+ db_path = SQLiteStorage.get_project_db_path(args.project)
1260
+ if not db_path.exists():
1261
+ error_exit(f"Project '{args.project}' not found.")
1262
+ runs = SQLiteStorage.get_runs(args.project)
1263
+ if args.run not in runs:
1264
+ error_exit(
1265
+ f"Run '{args.run}' not found in project '{args.project}'."
1266
+ )
1267
+ logs = SQLiteStorage.get_logs(args.project, args.run)
1268
+
1269
+ reports = _extract_reports(args.run, logs, report_name=args.report)
1270
+ if not reports:
1271
+ error_exit(
1272
+ f"Report '{args.report}' not found in run '{args.run}' of project '{args.project}'."
1273
+ )
1274
+
1275
+ if args.json:
1276
+ print(
1277
+ format_json(
1278
+ {
1279
+ "project": args.project,
1280
+ "run": args.run,
1281
+ "report": args.report,
1282
+ "values": reports,
1283
+ }
1284
+ )
1285
+ )
1286
+ else:
1287
+ output = []
1288
+ for idx, entry in enumerate(reports, start=1):
1289
+ output.append(
1290
+ f"Entry {idx} | step={entry['step']} | timestamp={entry['timestamp']}"
1291
+ )
1292
+ output.append(entry["content"])
1293
+ if idx < len(reports):
1294
+ output.append("-" * 80)
1295
+ print("\n".join(output))
1296
+ elif args.command == "query":
1297
+ if args.query_type == "project":
1298
+ _handle_query(args)
1299
+ elif args.command == "skills":
1300
+ if args.skills_action == "add":
1301
+ _handle_skills_add(args)
1302
+ else:
1303
+ parser.print_help()
1304
+
1305
+
1306
+ def _handle_skills_add(args):
1307
+ import shutil
1308
+ from pathlib import Path
1309
+
1310
+ CENTRAL_LOCAL = Path(".agents/skills")
1311
+ CENTRAL_GLOBAL = Path("~/.agents/skills")
1312
+ CLAUDE_LOCAL = Path(".claude/skills")
1313
+ CLAUDE_GLOBAL = Path("~/.claude/skills")
1314
+
1315
+ SKILL_ID = "trackio"
1316
+ GITHUB_RAW = "https://raw.githubusercontent.com/gradio-app/trackio/main"
1317
+ SKILL_PREFIX = ".agents/skills/trackio"
1318
+ SKILL_FILES = [
1319
+ "SKILL.md",
1320
+ "alerts.md",
1321
+ "logging_metrics.md",
1322
+ "retrieving_metrics.md",
1323
+ "storage_schema.md",
1324
+ ]
1325
+
1326
+ if not (args.cursor or args.claude or args.codex or args.opencode or args.dest):
1327
+ error_exit(
1328
+ "Pick a destination via --cursor, --claude, --codex, --opencode, or --dest."
1329
+ )
1330
+
1331
+ def download(url: str) -> str:
1332
+ from huggingface_hub.utils import get_session
1333
+
1334
+ try:
1335
+ response = get_session().get(url)
1336
+ response.raise_for_status()
1337
+ except Exception as e:
1338
+ error_exit(
1339
+ f"Failed to download {url}\n{e}\n\n"
1340
+ "Make sure you have internet access. The skill files are fetched from "
1341
+ "the Trackio GitHub repository."
1342
+ )
1343
+ return response.text
1344
+
1345
+ def remove_existing(path: Path, force: bool):
1346
+ if not (path.exists() or path.is_symlink()):
1347
+ return
1348
+ if not force:
1349
+ error_exit(
1350
+ f"Skill already exists at {path}.\nRe-run with --force to overwrite."
1351
+ )
1352
+ if path.is_dir() and not path.is_symlink():
1353
+ shutil.rmtree(path)
1354
+ else:
1355
+ path.unlink()
1356
+
1357
+ def install_to(skills_dir: Path, force: bool) -> Path:
1358
+ skills_dir = skills_dir.expanduser().resolve()
1359
+ skills_dir.mkdir(parents=True, exist_ok=True)
1360
+ dest = skills_dir / SKILL_ID
1361
+ remove_existing(dest, force)
1362
+ dest.mkdir()
1363
+ for fname in SKILL_FILES:
1364
+ content = download(f"{GITHUB_RAW}/{SKILL_PREFIX}/{fname}")
1365
+ (dest / fname).write_text(content, encoding="utf-8")
1366
+ return dest
1367
+
1368
+ def create_symlink(
1369
+ agent_skills_dir: Path, central_skill_path: Path, force: bool
1370
+ ) -> Path:
1371
+ agent_skills_dir = agent_skills_dir.expanduser().resolve()
1372
+ agent_skills_dir.mkdir(parents=True, exist_ok=True)
1373
+ link_path = agent_skills_dir / SKILL_ID
1374
+ remove_existing(link_path, force)
1375
+ link_path.symlink_to(os.path.relpath(central_skill_path, agent_skills_dir))
1376
+ return link_path
1377
+
1378
+ global_targets = {
1379
+ "cursor": Path("~/.cursor/skills"),
1380
+ "claude": CLAUDE_GLOBAL,
1381
+ "codex": Path("~/.codex/skills"),
1382
+ "opencode": Path("~/.opencode/skills"),
1383
+ }
1384
+ local_targets = {
1385
+ "cursor": Path(".cursor/skills"),
1386
+ "claude": CLAUDE_LOCAL,
1387
+ "codex": Path(".codex/skills"),
1388
+ "opencode": Path(".opencode/skills"),
1389
+ }
1390
+ targets_dict = global_targets if args.global_ else local_targets
1391
+
1392
+ if args.dest:
1393
+ if args.cursor or args.claude or args.codex or args.opencode or args.global_:
1394
+ error_exit("--dest cannot be combined with agent flags or --global.")
1395
+ skill_dest = install_to(Path(args.dest), args.force)
1396
+ print(f"Installed '{SKILL_ID}' to {skill_dest}")
1397
+ return
1398
+
1399
+ agent_targets = []
1400
+ if args.cursor:
1401
+ agent_targets.append(targets_dict["cursor"])
1402
+ if args.claude:
1403
+ agent_targets.append(targets_dict["claude"])
1404
+ if args.codex:
1405
+ agent_targets.append(targets_dict["codex"])
1406
+ if args.opencode:
1407
+ agent_targets.append(targets_dict["opencode"])
1408
+
1409
+ central_path = CENTRAL_GLOBAL if args.global_ else CENTRAL_LOCAL
1410
+ central_skill_path = install_to(central_path, args.force)
1411
+ print(f"Installed '{SKILL_ID}' to central location: {central_skill_path}")
1412
+
1413
+ for agent_target in agent_targets:
1414
+ link_path = create_symlink(agent_target, central_skill_path, args.force)
1415
+ print(f"Created symlink: {link_path}")
1416
+
1417
+
1418
+ if __name__ == "__main__":
1419
+ main()
trackio/cli_helpers.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import sys
3
+ from typing import Any
4
+
5
+
6
+ def format_json(data: Any) -> str:
7
+ """Format data as JSON."""
8
+ return json.dumps(data, indent=2)
9
+
10
+
11
+ def format_list(items: list[str], title: str | None = None) -> str:
12
+ """Format a list of items in human-readable format."""
13
+ if not items:
14
+ return f"No {title.lower() if title else 'items'} found."
15
+
16
+ output = []
17
+ if title:
18
+ output.append(f"{title}:")
19
+
20
+ for item in items:
21
+ output.append(f" - {item}")
22
+
23
+ return "\n".join(output)
24
+
25
+
26
+ def format_project_summary(summary: dict) -> str:
27
+ """Format project summary in human-readable format."""
28
+ output = [f"Project: {summary['project']}"]
29
+ output.append(f"Number of runs: {summary['num_runs']}")
30
+
31
+ if summary["runs"]:
32
+ output.append("\nRuns:")
33
+ for run in summary["runs"]:
34
+ output.append(f" - {run}")
35
+ else:
36
+ output.append("\nNo runs found.")
37
+
38
+ if summary.get("last_activity"):
39
+ output.append(f"\nLast activity (max step): {summary['last_activity']}")
40
+
41
+ return "\n".join(output)
42
+
43
+
44
+ def format_run_summary(summary: dict) -> str:
45
+ """Format run summary in human-readable format."""
46
+ output = [f"Project: {summary['project']}"]
47
+ output.append(f"Run: {summary['run']}")
48
+ output.append(f"Number of logs: {summary['num_logs']}")
49
+
50
+ if summary.get("last_step") is not None:
51
+ output.append(f"Last step: {summary['last_step']}")
52
+
53
+ if summary.get("metrics"):
54
+ output.append("\nMetrics:")
55
+ for metric in summary["metrics"]:
56
+ output.append(f" - {metric}")
57
+ else:
58
+ output.append("\nNo metrics found.")
59
+
60
+ config = summary.get("config")
61
+ if config:
62
+ output.append("\nConfig:")
63
+ config_display = {k: v for k, v in config.items() if not k.startswith("_")}
64
+ if config_display:
65
+ for key, value in config_display.items():
66
+ output.append(f" {key}: {value}")
67
+ else:
68
+ output.append(" (no config)")
69
+ else:
70
+ output.append("\nConfig: (no config)")
71
+
72
+ return "\n".join(output)
73
+
74
+
75
+ def format_metric_values(values: list[dict]) -> str:
76
+ """Format metric values in human-readable format."""
77
+ if not values:
78
+ return "No metric values found."
79
+
80
+ output = [f"Found {len(values)} value(s):\n"]
81
+ output.append("Step | Timestamp | Value")
82
+ output.append("-" * 50)
83
+
84
+ for value in values:
85
+ step = value.get("step", "N/A")
86
+ timestamp = value.get("timestamp", "N/A")
87
+ val = value.get("value", "N/A")
88
+ output.append(f"{step} | {timestamp} | {val}")
89
+
90
+ return "\n".join(output)
91
+
92
+
93
+ def format_system_metrics(metrics: list[dict]) -> str:
94
+ """Format system metrics in human-readable format."""
95
+ if not metrics:
96
+ return "No system metrics found."
97
+
98
+ output = [f"Found {len(metrics)} system metric entry/entries:\n"]
99
+
100
+ for i, entry in enumerate(metrics):
101
+ timestamp = entry.get("timestamp", "N/A")
102
+ output.append(f"\nEntry {i + 1} (Timestamp: {timestamp}):")
103
+ for key, value in entry.items():
104
+ if key != "timestamp":
105
+ output.append(f" {key}: {value}")
106
+
107
+ return "\n".join(output)
108
+
109
+
110
+ def format_system_metric_names(names: list[str]) -> str:
111
+ """Format system metric names in human-readable format."""
112
+ return format_list(names, "System Metrics")
113
+
114
+
115
+ def format_snapshot(snapshot: dict[str, list[dict]]) -> str:
116
+ """Format a metrics snapshot in human-readable format."""
117
+ if not snapshot:
118
+ return "No metrics found in the specified range."
119
+
120
+ output = []
121
+ for metric_name, values in sorted(snapshot.items()):
122
+ output.append(f"\n{metric_name}:")
123
+ output.append(" Step | Timestamp | Value")
124
+ output.append(" " + "-" * 48)
125
+ for v in values:
126
+ step = v.get("step", "N/A")
127
+ ts = v.get("timestamp", "N/A")
128
+ val = v.get("value", "N/A")
129
+ output.append(f" {step} | {ts} | {val}")
130
+
131
+ return "\n".join(output)
132
+
133
+
134
+ def format_alerts(alerts: list[dict]) -> str:
135
+ """Format alerts in human-readable format."""
136
+ if not alerts:
137
+ return "No alerts found."
138
+
139
+ output = [f"Found {len(alerts)} alert(s):\n"]
140
+ output.append("Timestamp | Run | Level | Title | Text | Step")
141
+ output.append("-" * 80)
142
+
143
+ for a in alerts:
144
+ ts = a.get("timestamp", "N/A")
145
+ run = a.get("run", "N/A")
146
+ level = a.get("level", "N/A").upper()
147
+ title = a.get("title", "")
148
+ text = a.get("text", "") or ""
149
+ step = a.get("step", "N/A")
150
+ output.append(f"{ts} | {run} | {level} | {title} | {text} | {step}")
151
+
152
+ return "\n".join(output)
153
+
154
+
155
+ def format_query_result(result: dict[str, Any]) -> str:
156
+ """Format SQL query results in human-readable format."""
157
+ columns = result.get("columns", [])
158
+ rows = result.get("rows", [])
159
+ row_count = result.get("row_count", 0)
160
+
161
+ if not columns:
162
+ return f"Query returned {row_count} row(s)."
163
+
164
+ rendered_rows = []
165
+ for row in rows:
166
+ rendered_rows.append(
167
+ [
168
+ "" if row.get(column) is None else str(row.get(column))
169
+ for column in columns
170
+ ]
171
+ )
172
+
173
+ widths = []
174
+ for idx, column in enumerate(columns):
175
+ cell_width = max(
176
+ (len(rendered_row[idx]) for rendered_row in rendered_rows), default=0
177
+ )
178
+ widths.append(max(len(column), cell_width))
179
+
180
+ header = " | ".join(
181
+ column.ljust(width) for column, width in zip(columns, widths, strict=False)
182
+ )
183
+ separator = "-+-".join("-" * width for width in widths)
184
+ output = [f"Query returned {row_count} row(s).", header, separator]
185
+
186
+ if not rendered_rows:
187
+ output.append("(no rows)")
188
+ return "\n".join(output)
189
+
190
+ for rendered_row in rendered_rows:
191
+ output.append(
192
+ " | ".join(
193
+ value.ljust(width)
194
+ for value, width in zip(rendered_row, widths, strict=False)
195
+ )
196
+ )
197
+
198
+ return "\n".join(output)
199
+
200
+
201
+ def error_exit(message: str, code: int = 1) -> None:
202
+ """Print error message and exit."""
203
+ print(f"Error: {message}", file=sys.stderr)
204
+ 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/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,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import time
3
+ import warnings
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ if TYPE_CHECKING:
7
+ from trackio.run import Run
8
+
9
+ psutil: Any = None
10
+ PSUTIL_AVAILABLE = False
11
+ _psutil_lock = threading.Lock()
12
+
13
+
14
+ def _ensure_psutil():
15
+ global PSUTIL_AVAILABLE, psutil
16
+ if PSUTIL_AVAILABLE:
17
+ return psutil
18
+ with _psutil_lock:
19
+ if PSUTIL_AVAILABLE:
20
+ return psutil
21
+ try:
22
+ import psutil as _psutil
23
+
24
+ psutil = _psutil
25
+ PSUTIL_AVAILABLE = True
26
+ return psutil
27
+ except ImportError:
28
+ raise ImportError(
29
+ "psutil is required for CPU and RAM monitoring. "
30
+ "Install it with: pip install psutil"
31
+ )
32
+
33
+
34
+ def cpu_available() -> bool:
35
+ """
36
+ Check if CPU and RAM monitoring is available.
37
+
38
+ Returns True if psutil is installed.
39
+ """
40
+ try:
41
+ _ensure_psutil()
42
+ return True
43
+ except ImportError:
44
+ return False
45
+ except Exception:
46
+ return False
47
+
48
+
49
+ def collect_cpu_metrics(
50
+ prev_disk_counters: Any = None,
51
+ prev_net_counters: Any = None,
52
+ elapsed: float | None = None,
53
+ include_static: bool = True,
54
+ ) -> dict:
55
+ """
56
+ Collect CPU, RAM, disk, network, and sensor metrics using psutil.
57
+
58
+ Args:
59
+ prev_disk_counters: Previous disk I/O counters for computing read/write rates.
60
+ If None, only cumulative values are reported.
61
+ prev_net_counters: Previous network I/O counters for computing send/recv rates.
62
+ If None, only cumulative values are reported.
63
+ elapsed: Seconds since prev_disk_counters and prev_net_counters were captured.
64
+ If None or non-positive, cumulative values are reported instead of rates.
65
+ include_static: Whether to include mostly static metrics such as CPU
66
+ frequency and core counts.
67
+
68
+ Returns:
69
+ Dictionary of system metrics.
70
+ """
71
+ if not PSUTIL_AVAILABLE:
72
+ try:
73
+ _ensure_psutil()
74
+ except ImportError:
75
+ return {}
76
+
77
+ metrics = {}
78
+
79
+ try:
80
+ per_core = psutil.cpu_percent(interval=0.1, percpu=True)
81
+ for i, pct in enumerate(per_core):
82
+ metrics[f"cpu/{i}/utilization"] = pct
83
+ if per_core:
84
+ metrics["cpu/utilization"] = sum(per_core) / len(per_core)
85
+ except Exception:
86
+ pass
87
+
88
+ if include_static:
89
+ try:
90
+ cpu_freq = psutil.cpu_freq()
91
+ if cpu_freq:
92
+ metrics["cpu/frequency"] = cpu_freq.current
93
+ if cpu_freq.max > 0:
94
+ metrics["cpu/frequency_max"] = cpu_freq.max
95
+ except Exception:
96
+ pass
97
+
98
+ try:
99
+ cpu_count_logical = psutil.cpu_count(logical=True)
100
+ if cpu_count_logical is not None:
101
+ metrics["cpu/count_logical"] = cpu_count_logical
102
+ cpu_count_physical = psutil.cpu_count(logical=False)
103
+ if cpu_count_physical is not None:
104
+ metrics["cpu/count_physical"] = cpu_count_physical
105
+ except Exception:
106
+ pass
107
+
108
+ try:
109
+ mem = psutil.virtual_memory()
110
+ metrics["memory/used"] = mem.used / (1024**3)
111
+ metrics["memory/total"] = mem.total / (1024**3)
112
+ metrics["memory/available"] = mem.available / (1024**3)
113
+ metrics["memory/percent"] = mem.percent
114
+ except Exception:
115
+ pass
116
+
117
+ try:
118
+ swap = psutil.swap_memory()
119
+ metrics["swap/used"] = swap.used / (1024**3)
120
+ metrics["swap/total"] = swap.total / (1024**3)
121
+ metrics["swap/percent"] = swap.percent
122
+ except Exception:
123
+ pass
124
+
125
+ try:
126
+ disk = psutil.disk_io_counters()
127
+ if disk is not None:
128
+ if prev_disk_counters is not None and elapsed and elapsed > 0:
129
+ metrics["disk/read_mb_per_sec"] = (
130
+ (disk.read_bytes - prev_disk_counters.read_bytes)
131
+ / elapsed
132
+ / (1024**2)
133
+ )
134
+ metrics["disk/write_mb_per_sec"] = (
135
+ (disk.write_bytes - prev_disk_counters.write_bytes)
136
+ / elapsed
137
+ / (1024**2)
138
+ )
139
+ metrics["disk/read_iops"] = (
140
+ disk.read_count - prev_disk_counters.read_count
141
+ ) / elapsed
142
+ metrics["disk/write_iops"] = (
143
+ disk.write_count - prev_disk_counters.write_count
144
+ ) / elapsed
145
+ else:
146
+ metrics["disk/read_bytes"] = disk.read_bytes / (1024**3)
147
+ metrics["disk/write_bytes"] = disk.write_bytes / (1024**3)
148
+ except Exception:
149
+ pass
150
+
151
+ try:
152
+ net = psutil.net_io_counters()
153
+ if net is not None:
154
+ if prev_net_counters is not None and elapsed and elapsed > 0:
155
+ metrics["network/sent_mb_per_sec"] = (
156
+ (net.bytes_sent - prev_net_counters.bytes_sent)
157
+ / elapsed
158
+ / (1024**2)
159
+ )
160
+ metrics["network/recv_mb_per_sec"] = (
161
+ (net.bytes_recv - prev_net_counters.bytes_recv)
162
+ / elapsed
163
+ / (1024**2)
164
+ )
165
+ else:
166
+ metrics["network/sent_bytes"] = net.bytes_sent / (1024**3)
167
+ metrics["network/recv_bytes"] = net.bytes_recv / (1024**3)
168
+ except Exception:
169
+ pass
170
+
171
+ try:
172
+ sensors = psutil.sensors_temperatures()
173
+ if sensors:
174
+ for chip_name, entries in sensors.items():
175
+ for i, entry in enumerate(entries):
176
+ label = (
177
+ entry.label.strip()
178
+ if entry.label and entry.label.strip()
179
+ else f"{chip_name}_{i}"
180
+ )
181
+ metrics[f"temp/{label}"] = entry.current
182
+ except Exception:
183
+ pass
184
+
185
+ try:
186
+ battery = psutil.sensors_battery()
187
+ if battery is not None:
188
+ metrics["battery/percent"] = battery.percent
189
+ metrics["battery/power_plugged"] = int(battery.power_plugged)
190
+ except Exception:
191
+ pass
192
+
193
+ return metrics
194
+
195
+
196
+ class CpuMonitor:
197
+ def __init__(self, run: "Run", interval: float = 10.0):
198
+ self._run = run
199
+ self._interval = interval
200
+ self._stop_flag = threading.Event()
201
+ self._thread: "threading.Thread | None" = None
202
+ self._last_disk_counters: Any = None
203
+ self._last_net_counters: Any = None
204
+ self._last_time: float | None = None
205
+
206
+ def start(self):
207
+ if not PSUTIL_AVAILABLE:
208
+ try:
209
+ _ensure_psutil()
210
+ except ImportError:
211
+ warnings.warn(
212
+ "auto_log_cpu=True but psutil is not installed. "
213
+ "CPU and RAM logging disabled. Install with: pip install psutil"
214
+ )
215
+ return
216
+
217
+ self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
218
+ self._thread.start()
219
+
220
+ def stop(self):
221
+ self._stop_flag.set()
222
+ if self._thread is not None:
223
+ self._thread.join(timeout=2.0)
224
+
225
+ def _monitor_loop(self):
226
+ try:
227
+ self._last_disk_counters = psutil.disk_io_counters()
228
+ except Exception:
229
+ pass
230
+ try:
231
+ self._last_net_counters = psutil.net_io_counters()
232
+ except Exception:
233
+ pass
234
+ self._last_time = time.monotonic()
235
+
236
+ while not self._stop_flag.is_set():
237
+ self._stop_flag.wait(timeout=self._interval)
238
+ if self._stop_flag.is_set():
239
+ break
240
+ try:
241
+ now = time.monotonic()
242
+ elapsed = now - self._last_time if self._last_time is not None else None
243
+ metrics = collect_cpu_metrics(
244
+ prev_disk_counters=self._last_disk_counters,
245
+ prev_net_counters=self._last_net_counters,
246
+ elapsed=elapsed,
247
+ include_static=False,
248
+ )
249
+ try:
250
+ self._last_disk_counters = psutil.disk_io_counters()
251
+ except Exception:
252
+ self._last_disk_counters = None
253
+ try:
254
+ self._last_net_counters = psutil.net_io_counters()
255
+ except Exception:
256
+ self._last_net_counters = None
257
+ self._last_time = now
258
+ if metrics:
259
+ self._run.log_system(metrics)
260
+ except Exception:
261
+ pass
262
+
263
+
264
+ def log_cpu(run: "Run | None" = None) -> dict:
265
+ """
266
+ Log CPU, RAM, disk, network, and sensor metrics to the current or specified run
267
+ as system metrics.
268
+
269
+ Args:
270
+ run: Optional Run instance. If None, uses current run from context.
271
+
272
+ Returns:
273
+ dict: The system metrics that were logged.
274
+
275
+ Example:
276
+ ```python
277
+ import trackio
278
+
279
+ run = trackio.init(project="my-project")
280
+ trackio.log({"loss": 0.5})
281
+ trackio.log_cpu()
282
+ ```
283
+ """
284
+ from trackio import context_vars
285
+
286
+ if run is None:
287
+ run = context_vars.current_run.get()
288
+ if run is None:
289
+ raise RuntimeError("Call trackio.init() before trackio.log_cpu().")
290
+
291
+ metrics = collect_cpu_metrics()
292
+ if metrics:
293
+ run.log_system(metrics)
294
+ return metrics
trackio/deploy.py ADDED
@@ -0,0 +1,1252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.metadata
2
+ import io
3
+ import json as json_mod
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ import warnings
11
+ from collections import Counter
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+
15
+ if sys.version_info >= (3, 11):
16
+ import tomllib
17
+ else:
18
+ import tomli as tomllib
19
+
20
+ import httpx
21
+ import huggingface_hub
22
+ from gradio_client import handle_file
23
+ from httpx import ReadTimeout
24
+ from huggingface_hub import Volume
25
+ from huggingface_hub.errors import (
26
+ BucketNotFoundError,
27
+ HfHubHTTPError,
28
+ RepositoryNotFoundError,
29
+ )
30
+
31
+ import trackio
32
+ from trackio.bucket_storage import (
33
+ create_bucket_if_not_exists,
34
+ export_from_bucket_for_static,
35
+ upload_project_to_bucket,
36
+ upload_project_to_bucket_for_static,
37
+ )
38
+ from trackio.frontend_config import resolve_frontend_dir
39
+ from trackio.remote_client import RemoteClient
40
+ from trackio.sqlite_storage import SQLiteStorage
41
+ from trackio.utils import (
42
+ MEDIA_DIR,
43
+ get_or_create_project_hash,
44
+ on_spaces,
45
+ preprocess_space_and_dataset_ids,
46
+ )
47
+
48
+ SPACE_HOST_URL = "https://{user_name}-{space_name}.hf.space/"
49
+ SPACE_URL = "https://huggingface.co/spaces/{space_id}"
50
+ _BOLD_ORANGE = "\033[1m\033[38;5;208m"
51
+ _RESET = "\033[0m"
52
+
53
+
54
+ def raise_if_space_is_frozen_for_logging(space_id: str) -> None:
55
+ try:
56
+ info = huggingface_hub.HfApi().space_info(space_id)
57
+ except RepositoryNotFoundError:
58
+ return
59
+ if getattr(info, "sdk", None) == "static":
60
+ raise RuntimeError(
61
+ f"Cannot log to Hugging Face Space '{space_id}' because it has been frozen "
62
+ f"(it uses the static SDK: a read-only dashboard with no live Trackio server).\n\n"
63
+ f"Use a different space_id for training, or create a new Gradio Trackio Space. "
64
+ f"Freezing converts a live Gradio Space to static after a run; a frozen Space "
65
+ f'cannot accept new logs. See trackio.sync(..., sdk="static") in the Trackio docs.'
66
+ )
67
+
68
+
69
+ def _readme_linked_hub_yaml(dataset_id: str | None) -> str:
70
+ if dataset_id is not None:
71
+ return f"datasets:\n - {dataset_id}\n"
72
+ return ""
73
+
74
+
75
+ _CUSTOM_SPACE_FRONTEND_DIR = "trackio_custom_frontend"
76
+
77
+
78
+ def _space_app_py(frontend_dir: str | None = None) -> str:
79
+ if frontend_dir is None:
80
+ return "import trackio\ntrackio.show()\n"
81
+ return f'import trackio\ntrackio.show(frontend_dir="{frontend_dir}")\n'
82
+
83
+
84
+ def _upload_frontend_folder(
85
+ hf_api: huggingface_hub.HfApi,
86
+ *,
87
+ repo_id: str,
88
+ repo_type: str,
89
+ folder_path: str | Path,
90
+ path_in_repo: str | None = None,
91
+ ) -> None:
92
+ kwargs = {
93
+ "repo_id": repo_id,
94
+ "repo_type": repo_type,
95
+ "folder_path": str(folder_path),
96
+ }
97
+ if path_in_repo is not None:
98
+ kwargs["path_in_repo"] = path_in_repo
99
+ hf_api.upload_folder(**kwargs)
100
+
101
+
102
+ def _retry_hf_write(op_name: str, fn, retries: int = 4, initial_delay: float = 1.5):
103
+ delay = initial_delay
104
+ for attempt in range(1, retries + 1):
105
+ try:
106
+ return fn()
107
+ except ReadTimeout:
108
+ if attempt == retries:
109
+ raise
110
+ print(
111
+ f"* {op_name} timed out (attempt {attempt}/{retries}). Retrying in {delay:.1f}s..."
112
+ )
113
+ time.sleep(delay)
114
+ delay = min(delay * 2, 12)
115
+ except HfHubHTTPError as e:
116
+ status = e.response.status_code if e.response is not None else None
117
+ if status is None or status < 500 or attempt == retries:
118
+ raise
119
+ print(
120
+ f"* {op_name} failed with HTTP {status} (attempt {attempt}/{retries}). Retrying in {delay:.1f}s..."
121
+ )
122
+ time.sleep(delay)
123
+ delay = min(delay * 2, 12)
124
+
125
+
126
+ def _get_space_volumes(
127
+ space_id: str, hf_api: huggingface_hub.HfApi | None = None
128
+ ) -> list[Volume]:
129
+ """
130
+ Return mounted volumes for a Space.
131
+
132
+ `HfApi.get_space_runtime()` does not always populate `volumes`, even when the
133
+ mount exists. Fall back to `space_info().runtime.volumes`, which currently
134
+ carries the volume metadata for running Spaces.
135
+ """
136
+ hf_api = hf_api or huggingface_hub.HfApi()
137
+ runtime = hf_api.get_space_runtime(space_id)
138
+ if runtime.volumes:
139
+ return list(runtime.volumes)
140
+
141
+ info = hf_api.space_info(space_id)
142
+ if info.runtime and info.runtime.volumes:
143
+ return list(info.runtime.volumes)
144
+
145
+ return []
146
+
147
+
148
+ def _get_space_bucket_at_data_mount(
149
+ space_id: str, hf_api: huggingface_hub.HfApi | None = None
150
+ ) -> str | None:
151
+ for volume in _get_space_volumes(space_id, hf_api=hf_api):
152
+ if volume.type == "bucket" and volume.mount_path == "/data":
153
+ return volume.source
154
+ return None
155
+
156
+
157
+ def _get_existing_space_bucket(
158
+ space_id: str, hf_api: huggingface_hub.HfApi | None = None
159
+ ) -> str | None:
160
+ """Return the Trackio bucket for a Space, preferring the canonical /data mount."""
161
+ bucket_at_data = _get_space_bucket_at_data_mount(space_id, hf_api=hf_api)
162
+ if bucket_at_data is not None:
163
+ return bucket_at_data
164
+
165
+ for volume in _get_space_volumes(space_id, hf_api=hf_api):
166
+ if volume.type == "bucket":
167
+ return volume.source
168
+ return None
169
+
170
+
171
+ def _get_existing_static_space_bucket(
172
+ space_id: str, hf_api: huggingface_hub.HfApi | None = None
173
+ ) -> str | None:
174
+ hf_api = hf_api or huggingface_hub.HfApi()
175
+ try:
176
+ config_path = hf_api.hf_hub_download(
177
+ repo_id=space_id,
178
+ repo_type="space",
179
+ filename="config.json",
180
+ )
181
+ except (FileNotFoundError, HfHubHTTPError, OSError, ValueError):
182
+ return None
183
+
184
+ try:
185
+ with open(config_path, encoding="utf-8") as config_file:
186
+ config = json_mod.load(config_file)
187
+ except (OSError, ValueError, TypeError):
188
+ return None
189
+
190
+ bucket_id = config.get("bucket_id")
191
+ if isinstance(bucket_id, str) and bucket_id:
192
+ return bucket_id
193
+ return None
194
+
195
+
196
+ def _ensure_bucket_mounted_at_data(
197
+ space_id: str,
198
+ bucket_id: str,
199
+ hf_api: huggingface_hub.HfApi | None = None,
200
+ ) -> None:
201
+ hf_api = hf_api or huggingface_hub.HfApi()
202
+ existing = _get_space_volumes(space_id, hf_api=hf_api)
203
+ already_mounted = any(
204
+ v.type == "bucket" and v.source == bucket_id and v.mount_path == "/data"
205
+ for v in existing
206
+ )
207
+ if not already_mounted:
208
+ preserved = [
209
+ v
210
+ for v in existing
211
+ if not (
212
+ v.type == "bucket"
213
+ and (v.source == bucket_id or v.mount_path == "/data")
214
+ )
215
+ ]
216
+ hf_api.set_space_volumes(
217
+ space_id,
218
+ preserved + [Volume(type="bucket", source=bucket_id, mount_path="/data")],
219
+ )
220
+ print(f"* Attached bucket {bucket_id} at '/data'")
221
+
222
+ existing_variables = hf_api.get_space_variables(space_id)
223
+ current_trackio_dir = getattr(existing_variables.get("TRACKIO_DIR"), "value", None)
224
+ if current_trackio_dir != "/data/trackio":
225
+ huggingface_hub.add_space_variable(space_id, "TRACKIO_DIR", "/data/trackio")
226
+ current_bucket_id = getattr(
227
+ existing_variables.get("TRACKIO_BUCKET_ID"), "value", None
228
+ )
229
+ if current_bucket_id != bucket_id:
230
+ huggingface_hub.add_space_variable(space_id, "TRACKIO_BUCKET_ID", bucket_id)
231
+
232
+
233
+ def _bucket_exists(bucket_id: str, hf_api: huggingface_hub.HfApi | None = None) -> bool:
234
+ hf_api = hf_api or huggingface_hub.HfApi()
235
+ try:
236
+ hf_api.bucket_info(bucket_id)
237
+ return True
238
+ except BucketNotFoundError:
239
+ return False
240
+
241
+
242
+ def _find_available_bucket_id(
243
+ preferred_bucket_id: str, hf_api: huggingface_hub.HfApi | None = None
244
+ ) -> str:
245
+ hf_api = hf_api or huggingface_hub.HfApi()
246
+ if not _bucket_exists(preferred_bucket_id, hf_api):
247
+ return preferred_bucket_id
248
+
249
+ suffix = 2
250
+ while True:
251
+ candidate = f"{preferred_bucket_id}-{suffix}"
252
+ if not _bucket_exists(candidate, hf_api):
253
+ return candidate
254
+ suffix += 1
255
+
256
+
257
+ def resolve_auto_bucket_id(
258
+ space_id: str,
259
+ preferred_bucket_id: str,
260
+ hf_api: huggingface_hub.HfApi | None = None,
261
+ ) -> str:
262
+ """
263
+ Resolve the bucket to use for an auto-generated bucket ID.
264
+
265
+ Rules:
266
+ - Existing Space with a bucket mounted at /data -> reuse that bucket.
267
+ - Existing static Space with a bucket_id in config.json -> reuse that bucket.
268
+ - Otherwise -> use the preferred auto bucket ID if free, or a suffixed variant.
269
+ """
270
+ hf_api = hf_api or huggingface_hub.HfApi()
271
+ try:
272
+ info = hf_api.space_info(space_id)
273
+ except RepositoryNotFoundError:
274
+ pass
275
+ else:
276
+ existing_bucket_id = _get_existing_space_bucket(space_id, hf_api=hf_api)
277
+ if existing_bucket_id is None and getattr(info, "sdk", None) == "static":
278
+ existing_bucket_id = _get_existing_static_space_bucket(
279
+ space_id, hf_api=hf_api
280
+ )
281
+ if existing_bucket_id is not None:
282
+ return existing_bucket_id
283
+
284
+ bucket_id = _find_available_bucket_id(preferred_bucket_id, hf_api)
285
+ if bucket_id != preferred_bucket_id:
286
+ print(
287
+ f"* Auto-generated bucket {preferred_bucket_id} already exists; "
288
+ f"using {bucket_id} instead"
289
+ )
290
+ return bucket_id
291
+
292
+
293
+ def _get_source_install_dependencies() -> str:
294
+ """Get trackio dependencies from pyproject.toml for source installs."""
295
+ trackio_path = files("trackio")
296
+ pyproject_path = Path(trackio_path).parent / "pyproject.toml"
297
+ with open(pyproject_path, "rb") as f:
298
+ pyproject = tomllib.load(f)
299
+ deps = pyproject["project"]["dependencies"]
300
+ spaces_deps = (
301
+ pyproject["project"].get("optional-dependencies", {}).get("spaces", [])
302
+ )
303
+ mcp_deps = pyproject["project"].get("optional-dependencies", {}).get("mcp", [])
304
+ return "\n".join(deps + spaces_deps + mcp_deps)
305
+
306
+
307
+ def _get_space_install_requirement() -> str:
308
+ return f"trackio[spaces,mcp]=={trackio.__version__}"
309
+
310
+
311
+ def _is_trackio_installed_from_source() -> bool:
312
+ """Check if trackio is installed from source/editable install vs PyPI."""
313
+ try:
314
+ trackio_file = trackio.__file__
315
+ if "site-packages" not in trackio_file and "dist-packages" not in trackio_file:
316
+ return True
317
+
318
+ dist = importlib.metadata.distribution("trackio")
319
+ if dist.files:
320
+ files = list(dist.files)
321
+ has_pth = any(".pth" in str(f) for f in files)
322
+ if has_pth:
323
+ return True
324
+
325
+ return False
326
+ except (
327
+ AttributeError,
328
+ importlib.metadata.PackageNotFoundError,
329
+ importlib.metadata.MetadataError,
330
+ ValueError,
331
+ TypeError,
332
+ ):
333
+ return True
334
+
335
+
336
+ def deploy_as_space(
337
+ space_id: str,
338
+ space_storage: huggingface_hub.SpaceStorage | None = None,
339
+ dataset_id: str | None = None,
340
+ bucket_id: str | None = None,
341
+ private: bool | None = None,
342
+ frontend_dir: str | Path | None = None,
343
+ ):
344
+ if on_spaces(): # in case a repo with this function is uploaded to spaces
345
+ return
346
+
347
+ if dataset_id is not None and bucket_id is not None:
348
+ raise ValueError(
349
+ "Cannot use bucket volume options together with dataset_id; use one persistence mode."
350
+ )
351
+
352
+ trackio_path = files("trackio")
353
+
354
+ hf_api = huggingface_hub.HfApi()
355
+
356
+ try:
357
+ huggingface_hub.create_repo(
358
+ space_id,
359
+ private=private,
360
+ space_sdk="gradio",
361
+ space_storage=space_storage,
362
+ repo_type="space",
363
+ exist_ok=True,
364
+ )
365
+ except HfHubHTTPError as e:
366
+ if e.response.status_code in [401, 403]: # unauthorized or forbidden
367
+ print("Need 'write' access token to create a Spaces repo.")
368
+ huggingface_hub.login(add_to_git_credential=False)
369
+ huggingface_hub.create_repo(
370
+ space_id,
371
+ private=private,
372
+ space_sdk="gradio",
373
+ space_storage=space_storage,
374
+ repo_type="space",
375
+ exist_ok=True,
376
+ )
377
+ else:
378
+ raise ValueError(f"Failed to create Space: {e}")
379
+
380
+ # We can assume huggingface-hub is available; requirements.txt pins trackio.
381
+ # Make sure necessary dependencies are installed by creating a requirements.txt.
382
+ is_source_install = _is_trackio_installed_from_source()
383
+ resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
384
+
385
+ if bucket_id is not None:
386
+ create_bucket_if_not_exists(bucket_id, private=private)
387
+
388
+ with open(Path(trackio_path, "README.md"), "r", encoding="utf-8") as f:
389
+ readme_content = f.read()
390
+ readme_content = readme_content.replace("sdk_version: {GRADIO_VERSION}\n", "")
391
+ readme_content = readme_content.replace("{APP_FILE}", "app.py")
392
+ readme_content = readme_content.replace(
393
+ "{LINKED_HUB_METADATA}", _readme_linked_hub_yaml(dataset_id)
394
+ )
395
+ readme_buffer = io.BytesIO(readme_content.encode("utf-8"))
396
+ hf_api.upload_file(
397
+ path_or_fileobj=readme_buffer,
398
+ path_in_repo="README.md",
399
+ repo_id=space_id,
400
+ repo_type="space",
401
+ )
402
+
403
+ if is_source_install:
404
+ requirements_content = _get_source_install_dependencies()
405
+ else:
406
+ requirements_content = _get_space_install_requirement()
407
+
408
+ requirements_buffer = io.BytesIO(requirements_content.encode("utf-8"))
409
+ hf_api.upload_file(
410
+ path_or_fileobj=requirements_buffer,
411
+ path_in_repo="requirements.txt",
412
+ repo_id=space_id,
413
+ repo_type="space",
414
+ )
415
+
416
+ huggingface_hub.utils.disable_progress_bars()
417
+
418
+ if is_source_install:
419
+ dist_index = (
420
+ Path(trackio.__file__).resolve().parent / "frontend" / "dist" / "index.html"
421
+ )
422
+ if not dist_index.is_file() and not resolved_frontend.is_custom:
423
+ raise ValueError(
424
+ "The Trackio frontend build is missing. From the repository root run "
425
+ "`cd trackio/frontend && npm ci && npm run build`, then deploy again."
426
+ )
427
+ hf_api.upload_folder(
428
+ repo_id=space_id,
429
+ repo_type="space",
430
+ folder_path=trackio_path,
431
+ path_in_repo="trackio",
432
+ ignore_patterns=[
433
+ "README.md",
434
+ "frontend/node_modules/**",
435
+ "frontend/src/**",
436
+ "frontend/.gitignore",
437
+ "frontend/package.json",
438
+ "frontend/package-lock.json",
439
+ "frontend/vite.config.js",
440
+ "frontend/svelte.config.js",
441
+ "**/__pycache__/**",
442
+ "*.pyc",
443
+ ],
444
+ )
445
+
446
+ if resolved_frontend.is_custom:
447
+ _upload_frontend_folder(
448
+ hf_api,
449
+ repo_id=space_id,
450
+ repo_type="space",
451
+ folder_path=resolved_frontend.path,
452
+ path_in_repo=_CUSTOM_SPACE_FRONTEND_DIR,
453
+ )
454
+
455
+ app_file_content = _space_app_py(
456
+ _CUSTOM_SPACE_FRONTEND_DIR if resolved_frontend.is_custom else None
457
+ )
458
+ app_file_buffer = io.BytesIO(app_file_content.encode("utf-8"))
459
+ hf_api.upload_file(
460
+ path_or_fileobj=app_file_buffer,
461
+ path_in_repo="app.py",
462
+ repo_id=space_id,
463
+ repo_type="space",
464
+ )
465
+
466
+ if hf_token := huggingface_hub.utils.get_token():
467
+ huggingface_hub.add_space_secret(space_id, "HF_TOKEN", hf_token)
468
+ if bucket_id is not None:
469
+ _ensure_bucket_mounted_at_data(space_id, bucket_id, hf_api)
470
+ elif dataset_id is not None:
471
+ huggingface_hub.add_space_variable(space_id, "TRACKIO_DATASET_ID", dataset_id)
472
+ if logo_light_url := os.environ.get("TRACKIO_LOGO_LIGHT_URL"):
473
+ huggingface_hub.add_space_variable(
474
+ space_id, "TRACKIO_LOGO_LIGHT_URL", logo_light_url
475
+ )
476
+ if logo_dark_url := os.environ.get("TRACKIO_LOGO_DARK_URL"):
477
+ huggingface_hub.add_space_variable(
478
+ space_id, "TRACKIO_LOGO_DARK_URL", logo_dark_url
479
+ )
480
+ if plot_order := os.environ.get("TRACKIO_PLOT_ORDER"):
481
+ huggingface_hub.add_space_variable(space_id, "TRACKIO_PLOT_ORDER", plot_order)
482
+ if theme := os.environ.get("TRACKIO_THEME"):
483
+ huggingface_hub.add_space_variable(space_id, "TRACKIO_THEME", theme)
484
+ huggingface_hub.add_space_variable(space_id, "GRADIO_MCP_SERVER", "True")
485
+
486
+
487
+ def create_space_if_not_exists(
488
+ space_id: str,
489
+ space_storage: huggingface_hub.SpaceStorage | None = None,
490
+ dataset_id: str | None = None,
491
+ bucket_id: str | None = None,
492
+ private: bool | None = None,
493
+ frontend_dir: str | Path | None = None,
494
+ ) -> None:
495
+ """
496
+ Creates a new Hugging Face Space if it does not exist.
497
+
498
+ Args:
499
+ space_id (`str`):
500
+ The ID of the Space to create.
501
+ space_storage ([`~huggingface_hub.SpaceStorage`], *optional*):
502
+ Choice of persistent storage tier for the Space.
503
+ dataset_id (`str`, *optional*):
504
+ Deprecated. Use `bucket_id` instead.
505
+ bucket_id (`str`, *optional*):
506
+ Full Hub bucket id (`namespace/name`) to attach via the Hub volumes API (platform mount).
507
+ Sets `TRACKIO_DIR` to the mount path.
508
+ private (`bool`, *optional*):
509
+ Whether to make the Space private. If `None` (default), the repo will be
510
+ public unless the organization's default is private. This value is ignored
511
+ if the repo already exists.
512
+ """
513
+ if "/" not in space_id:
514
+ raise ValueError(
515
+ f"Invalid space ID: {space_id}. Must be in the format: username/reponame or orgname/reponame."
516
+ )
517
+ if dataset_id is not None and "/" not in dataset_id:
518
+ raise ValueError(
519
+ f"Invalid dataset ID: {dataset_id}. Must be in the format: username/datasetname or orgname/datasetname."
520
+ )
521
+ if bucket_id is not None and "/" not in bucket_id:
522
+ raise ValueError(
523
+ f"Invalid bucket ID: {bucket_id}. Must be in the format: username/bucketname or orgname/bucketname."
524
+ )
525
+ try:
526
+ huggingface_hub.repo_info(space_id, repo_type="space")
527
+ print(
528
+ f"* Found existing space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
529
+ )
530
+ if bucket_id is not None:
531
+ create_bucket_if_not_exists(bucket_id, private=private)
532
+ _ensure_bucket_mounted_at_data(space_id, bucket_id)
533
+ elif dataset_id is not None:
534
+ huggingface_hub.add_space_variable(
535
+ space_id, "TRACKIO_DATASET_ID", dataset_id
536
+ )
537
+ resolved_frontend = resolve_frontend_dir(frontend_dir, announce=False)
538
+ if resolved_frontend.is_custom:
539
+ deploy_as_space(
540
+ space_id,
541
+ space_storage,
542
+ dataset_id,
543
+ bucket_id,
544
+ private,
545
+ frontend_dir=frontend_dir,
546
+ )
547
+ return
548
+ except RepositoryNotFoundError:
549
+ pass
550
+ except HfHubHTTPError as e:
551
+ if e.response.status_code in [401, 403]: # unauthorized or forbidden
552
+ print("Need 'write' access token to create a Spaces repo.")
553
+ huggingface_hub.login(add_to_git_credential=False)
554
+ else:
555
+ raise ValueError(f"Failed to create Space: {e}")
556
+
557
+ print(
558
+ f"* Creating new space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
559
+ )
560
+ deploy_as_space(
561
+ space_id,
562
+ space_storage,
563
+ dataset_id,
564
+ bucket_id,
565
+ private,
566
+ frontend_dir=frontend_dir,
567
+ )
568
+ print("* Waiting for Space to be ready...")
569
+ _wait_until_space_running(space_id)
570
+
571
+
572
+ def _wait_until_space_running(space_id: str, timeout: int = 300) -> None:
573
+ hf_api = huggingface_hub.HfApi()
574
+ start = time.time()
575
+ delay = 2
576
+ request_timeout = 45.0
577
+ failure_stages = frozenset(
578
+ ("NO_APP_FILE", "CONFIG_ERROR", "BUILD_ERROR", "RUNTIME_ERROR")
579
+ )
580
+ while time.time() - start < timeout:
581
+ try:
582
+ info = hf_api.space_info(space_id, timeout=request_timeout)
583
+ if info.runtime:
584
+ stage = str(info.runtime.stage)
585
+ if stage in failure_stages:
586
+ raise RuntimeError(
587
+ f"Space {space_id} entered terminal stage {stage}. "
588
+ "Fix README.md or app files; see build logs on the Hub."
589
+ )
590
+ if stage == "RUNNING":
591
+ return
592
+ except RuntimeError:
593
+ raise
594
+ except (huggingface_hub.utils.HfHubHTTPError, httpx.RequestError):
595
+ pass
596
+ time.sleep(delay)
597
+ delay = min(delay * 1.5, 15)
598
+ raise TimeoutError(
599
+ f"Space {space_id} did not reach RUNNING within {timeout}s. "
600
+ "Check status and build logs on the Hub."
601
+ )
602
+
603
+
604
+ def wait_until_space_exists(
605
+ space_id: str,
606
+ ) -> None:
607
+ """
608
+ Blocks the current thread until the Space exists.
609
+
610
+ Args:
611
+ space_id (`str`):
612
+ The ID of the Space to wait for.
613
+
614
+ Raises:
615
+ `TimeoutError`: If waiting for the Space takes longer than expected.
616
+ """
617
+ hf_api = huggingface_hub.HfApi()
618
+ delay = 1
619
+ for _ in range(30):
620
+ try:
621
+ hf_api.space_info(space_id)
622
+ return
623
+ except (huggingface_hub.utils.HfHubHTTPError, httpx.RequestError):
624
+ time.sleep(delay)
625
+ delay = min(delay * 2, 60)
626
+ raise TimeoutError("Waiting for space to exist took longer than expected")
627
+
628
+
629
+ def upload_db_to_space(project: str, space_id: str, force: bool = False) -> None:
630
+ """
631
+ Uploads the database of a local Trackio project to a Hugging Face Space.
632
+
633
+ This uses the Trackio remote client so newer Trackio Spaces can speak the direct
634
+ HTTP API while older Gradio-based Spaces still work through `gradio_client`.
635
+
636
+ Args:
637
+ project (`str`):
638
+ The name of the project to upload.
639
+ space_id (`str`):
640
+ The ID of the Space to upload to.
641
+ force (`bool`, *optional*, defaults to `False`):
642
+ If `True`, overwrites the existing database without prompting. If `False`,
643
+ prompts for confirmation.
644
+ """
645
+ db_path = SQLiteStorage.get_project_db_path(project)
646
+ client = RemoteClient(
647
+ space_id,
648
+ hf_token=huggingface_hub.utils.get_token(),
649
+ httpx_kwargs={"timeout": 90},
650
+ )
651
+
652
+ if not force:
653
+ try:
654
+ existing_projects = client.predict(api_name="/get_all_projects")
655
+ if project in existing_projects:
656
+ response = input(
657
+ f"Database for project '{project}' already exists on Space '{space_id}'. "
658
+ f"Overwrite it? (y/N): "
659
+ )
660
+ if response.lower() not in ["y", "yes"]:
661
+ print("* Upload cancelled.")
662
+ return
663
+ except Exception as e:
664
+ print(f"* Warning: Could not check if project exists on Space: {e}")
665
+ print("* Proceeding with upload...")
666
+
667
+ client.predict(
668
+ api_name="/upload_db_to_space",
669
+ project=project,
670
+ uploaded_db=handle_file(db_path),
671
+ hf_token=huggingface_hub.utils.get_token(),
672
+ )
673
+
674
+
675
+ SYNC_BATCH_SIZE = 500
676
+
677
+
678
+ def sync_incremental(
679
+ project: str,
680
+ space_id: str,
681
+ private: bool | None = None,
682
+ pending_only: bool = False,
683
+ frontend_dir: str | Path | None = None,
684
+ ) -> None:
685
+ """
686
+ Syncs a local Trackio project to a Space via the bulk_log API endpoints
687
+ instead of uploading the entire DB file. Supports incremental sync.
688
+
689
+ Args:
690
+ project: The name of the project to sync.
691
+ space_id: The HF Space ID to sync to.
692
+ private: Whether to make the Space private if creating.
693
+ pending_only: If True, only sync rows tagged with space_id (pending data).
694
+ """
695
+ print(
696
+ f"* Syncing project '{project}' to: {SPACE_URL.format(space_id=space_id)} (please wait...)"
697
+ )
698
+ create_space_if_not_exists(space_id, private=private, frontend_dir=frontend_dir)
699
+ wait_until_space_exists(space_id)
700
+ hf_token = huggingface_hub.utils.get_token()
701
+ expected_run_counts: Counter[str] = Counter()
702
+
703
+ client = RemoteClient(
704
+ space_id,
705
+ hf_token=hf_token,
706
+ httpx_kwargs={"timeout": 90},
707
+ )
708
+
709
+ if pending_only:
710
+ pending_logs = SQLiteStorage.get_pending_logs(project)
711
+ if pending_logs:
712
+ logs = pending_logs["logs"]
713
+ expected_run_counts.update(log["run"] for log in logs)
714
+ for i in range(0, len(logs), SYNC_BATCH_SIZE):
715
+ batch = logs[i : i + SYNC_BATCH_SIZE]
716
+ print(
717
+ f" Syncing metrics: {min(i + SYNC_BATCH_SIZE, len(logs))}/{len(logs)}..."
718
+ )
719
+ client.predict(api_name="/bulk_log", logs=batch, hf_token=hf_token)
720
+ SQLiteStorage.clear_pending_logs(project, pending_logs["ids"])
721
+
722
+ pending_sys = SQLiteStorage.get_pending_system_logs(project)
723
+ if pending_sys:
724
+ logs = pending_sys["logs"]
725
+ for i in range(0, len(logs), SYNC_BATCH_SIZE):
726
+ batch = logs[i : i + SYNC_BATCH_SIZE]
727
+ print(
728
+ f" Syncing system metrics: {min(i + SYNC_BATCH_SIZE, len(logs))}/{len(logs)}..."
729
+ )
730
+ client.predict(
731
+ api_name="/bulk_log_system", logs=batch, hf_token=hf_token
732
+ )
733
+ SQLiteStorage.clear_pending_system_logs(project, pending_sys["ids"])
734
+
735
+ pending_uploads = SQLiteStorage.get_pending_uploads(project)
736
+ if pending_uploads:
737
+ upload_entries = []
738
+ for u in pending_uploads["uploads"]:
739
+ fp = u["file_path"]
740
+ if os.path.exists(fp):
741
+ upload_entries.append(
742
+ {
743
+ "project": u["project"],
744
+ "run": u["run"],
745
+ "step": u["step"],
746
+ "relative_path": u["relative_path"],
747
+ "uploaded_file": handle_file(fp),
748
+ }
749
+ )
750
+ if upload_entries:
751
+ print(f" Syncing {len(upload_entries)} media files...")
752
+ client.predict(
753
+ api_name="/bulk_upload_media",
754
+ uploads=upload_entries,
755
+ hf_token=hf_token,
756
+ )
757
+ SQLiteStorage.clear_pending_uploads(project, pending_uploads["ids"])
758
+ else:
759
+ all_logs = SQLiteStorage.get_all_logs_for_sync(project)
760
+ if all_logs:
761
+ expected_run_counts.update(log["run"] for log in all_logs)
762
+ for i in range(0, len(all_logs), SYNC_BATCH_SIZE):
763
+ batch = all_logs[i : i + SYNC_BATCH_SIZE]
764
+ print(
765
+ f" Syncing metrics: {min(i + SYNC_BATCH_SIZE, len(all_logs))}/{len(all_logs)}..."
766
+ )
767
+ client.predict(api_name="/bulk_log", logs=batch, hf_token=hf_token)
768
+
769
+ all_sys_logs = SQLiteStorage.get_all_system_logs_for_sync(project)
770
+ if all_sys_logs:
771
+ for i in range(0, len(all_sys_logs), SYNC_BATCH_SIZE):
772
+ batch = all_sys_logs[i : i + SYNC_BATCH_SIZE]
773
+ print(
774
+ f" Syncing system metrics: {min(i + SYNC_BATCH_SIZE, len(all_sys_logs))}/{len(all_sys_logs)}..."
775
+ )
776
+ client.predict(
777
+ api_name="/bulk_log_system", logs=batch, hf_token=hf_token
778
+ )
779
+
780
+ _wait_for_remote_sync(client, project, expected_run_counts)
781
+ SQLiteStorage.set_project_metadata(project, "space_id", space_id)
782
+ print(
783
+ f"* Synced successfully to space: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
784
+ )
785
+
786
+
787
+ def _build_remote_client_with_retry(
788
+ space_id: str,
789
+ timeout: int = 360,
790
+ verbose: bool = False,
791
+ ) -> RemoteClient:
792
+ deadline = time.time() + timeout
793
+ delay = 2
794
+ last_error: Exception | None = None
795
+ while time.time() < deadline:
796
+ try:
797
+ return RemoteClient(space_id, verbose=verbose, httpx_kwargs={"timeout": 90})
798
+ except (ValueError, ConnectionError) as e:
799
+ last_error = e
800
+ time.sleep(delay)
801
+ delay = min(delay * 1.5, 15)
802
+ raise ConnectionError(
803
+ f"Could not connect to Space '{space_id}' within {timeout}s: {last_error}"
804
+ )
805
+
806
+
807
+ def _wait_for_remote_sync(
808
+ client: RemoteClient,
809
+ project: str,
810
+ expected_run_counts: Counter[str],
811
+ timeout: int = 180,
812
+ ) -> None:
813
+ if not expected_run_counts:
814
+ return
815
+
816
+ deadline = time.time() + timeout
817
+ delay = 2
818
+ last_error: Exception | None = None
819
+ pending = dict(expected_run_counts)
820
+
821
+ while time.time() < deadline and pending:
822
+ completed = []
823
+ for run_name, expected_num_logs in pending.items():
824
+ try:
825
+ summary = client.predict(
826
+ project=project, run=run_name, api_name="/get_run_summary"
827
+ )
828
+ if summary.get("num_logs") == expected_num_logs:
829
+ completed.append(run_name)
830
+ except Exception as e:
831
+ last_error = e
832
+ for run_name in completed:
833
+ pending.pop(run_name, None)
834
+ if pending:
835
+ time.sleep(delay)
836
+ delay = min(delay * 1.5, 15)
837
+
838
+ if pending:
839
+ raise TimeoutError(
840
+ f"Remote sync for project '{project}' did not become visible for runs "
841
+ f"{sorted(pending.items())} within {timeout}s. "
842
+ f"Last error: {last_error!r}"
843
+ )
844
+
845
+
846
+ def upload_dataset_for_static(
847
+ project: str,
848
+ dataset_id: str,
849
+ private: bool | None = None,
850
+ ) -> None:
851
+ hf_api = huggingface_hub.HfApi()
852
+
853
+ try:
854
+ huggingface_hub.create_repo(
855
+ dataset_id,
856
+ private=private,
857
+ repo_type="dataset",
858
+ exist_ok=True,
859
+ )
860
+ except HfHubHTTPError as e:
861
+ if e.response.status_code in [401, 403]:
862
+ print("Need 'write' access token to create a Dataset repo.")
863
+ huggingface_hub.login(add_to_git_credential=False)
864
+ huggingface_hub.create_repo(
865
+ dataset_id,
866
+ private=private,
867
+ repo_type="dataset",
868
+ exist_ok=True,
869
+ )
870
+ else:
871
+ raise ValueError(f"Failed to create Dataset: {e}")
872
+
873
+ with tempfile.TemporaryDirectory() as tmp_dir:
874
+ output_dir = Path(tmp_dir)
875
+ SQLiteStorage.export_for_static_space(project, output_dir)
876
+
877
+ media_dir = MEDIA_DIR / project
878
+ if media_dir.exists():
879
+ dest = output_dir / "media"
880
+ shutil.copytree(media_dir, dest)
881
+
882
+ _retry_hf_write(
883
+ "Dataset upload",
884
+ lambda: hf_api.upload_folder(
885
+ repo_id=dataset_id,
886
+ repo_type="dataset",
887
+ folder_path=str(output_dir),
888
+ ),
889
+ )
890
+
891
+ print(f"* Dataset uploaded: https://huggingface.co/datasets/{dataset_id}")
892
+
893
+
894
+ def deploy_as_static_space(
895
+ space_id: str,
896
+ dataset_id: str | None,
897
+ project: str,
898
+ bucket_id: str | None = None,
899
+ private: bool | None = None,
900
+ hf_token: str | None = None,
901
+ frontend_dir: str | Path | None = None,
902
+ ) -> None:
903
+ if on_spaces():
904
+ return
905
+
906
+ if private is True:
907
+ raise ValueError(
908
+ "private=True is not supported for static Trackio Spaces. Static Spaces "
909
+ "run entirely in the browser, so their snapshot data must be public. "
910
+ "Use sdk='gradio' for a private dashboard."
911
+ )
912
+ hf_api = huggingface_hub.HfApi()
913
+
914
+ try:
915
+ huggingface_hub.create_repo(
916
+ space_id,
917
+ private=False,
918
+ space_sdk="static",
919
+ repo_type="space",
920
+ exist_ok=True,
921
+ )
922
+ except HfHubHTTPError as e:
923
+ if e.response.status_code in [401, 403]:
924
+ print("Need 'write' access token to create a Spaces repo.")
925
+ huggingface_hub.login(add_to_git_credential=False)
926
+ huggingface_hub.create_repo(
927
+ space_id,
928
+ private=False,
929
+ space_sdk="static",
930
+ repo_type="space",
931
+ exist_ok=True,
932
+ )
933
+ else:
934
+ raise ValueError(f"Failed to create Space: {e}")
935
+
936
+ linked = _readme_linked_hub_yaml(dataset_id)
937
+ readme_content = (
938
+ f"---\nemoji: 🎯\nsdk: static\npinned: false\ntags:\n - trackio\n{linked}---\n"
939
+ )
940
+ _retry_hf_write(
941
+ "Static Space README upload",
942
+ lambda: hf_api.upload_file(
943
+ path_or_fileobj=io.BytesIO(readme_content.encode("utf-8")),
944
+ path_in_repo="README.md",
945
+ repo_id=space_id,
946
+ repo_type="space",
947
+ ),
948
+ )
949
+
950
+ resolved_frontend = resolve_frontend_dir(frontend_dir, announce=True)
951
+
952
+ _retry_hf_write(
953
+ "Static Space frontend upload",
954
+ lambda: hf_api.upload_folder(
955
+ repo_id=space_id,
956
+ repo_type="space",
957
+ folder_path=str(resolved_frontend.path),
958
+ ),
959
+ )
960
+
961
+ config = {
962
+ "mode": "static",
963
+ "project": project,
964
+ "private": bool(private),
965
+ }
966
+ if bucket_id is not None:
967
+ config["bucket_id"] = bucket_id
968
+ if dataset_id is not None:
969
+ config["dataset_id"] = dataset_id
970
+ if hf_token is not None:
971
+ warnings.warn(
972
+ "`hf_token` is ignored by deploy_as_static_space() for static Space "
973
+ "deployment and will be removed in a future release.",
974
+ DeprecationWarning,
975
+ stacklevel=2,
976
+ )
977
+
978
+ _retry_hf_write(
979
+ "Static Space config upload",
980
+ lambda: hf_api.upload_file(
981
+ path_or_fileobj=io.BytesIO(json_mod.dumps(config).encode("utf-8")),
982
+ path_in_repo="config.json",
983
+ repo_id=space_id,
984
+ repo_type="space",
985
+ ),
986
+ )
987
+
988
+ assets_dir = Path(trackio.__file__).resolve().parent / "assets"
989
+ if assets_dir.is_dir():
990
+ _retry_hf_write(
991
+ "Static Space assets upload",
992
+ lambda: hf_api.upload_folder(
993
+ repo_id=space_id,
994
+ repo_type="space",
995
+ folder_path=str(assets_dir),
996
+ path_in_repo="static/trackio",
997
+ ),
998
+ )
999
+
1000
+ print(
1001
+ f"* Static Space deployed: {_BOLD_ORANGE}{SPACE_URL.format(space_id=space_id)}{_RESET}"
1002
+ )
1003
+
1004
+
1005
+ def sync(
1006
+ project: str,
1007
+ space_id: str | None = None,
1008
+ private: bool | None = None,
1009
+ force: bool = False,
1010
+ run_in_background: bool = False,
1011
+ sdk: str = "gradio",
1012
+ dataset_id: str | None = None,
1013
+ bucket_id: str | None = None,
1014
+ frontend_dir: str | Path | None = None,
1015
+ ) -> str:
1016
+ """
1017
+ Syncs a local Trackio project's database to a Hugging Face Space.
1018
+ If the Space does not exist, it will be created. Local data is never deleted.
1019
+
1020
+ **Freezing:** Passing ``sdk="static"`` deploys a static Space backed by an HF Bucket
1021
+ (read-only dashboard, no Gradio server). You can sync the same project again later to
1022
+ refresh that static Space. If you want a one-time snapshot of an existing Gradio Space,
1023
+ use ``freeze()`` instead.
1024
+
1025
+ Args:
1026
+ project (`str`): The name of the project to upload.
1027
+ space_id (`str`, *optional*): The ID of the Space to upload to (e.g., `"username/space_id"`).
1028
+ If not provided, checks project metadata first, then generates a random space_id.
1029
+ private (`bool`, *optional*):
1030
+ Whether to make the Space private. If None (default), the repo will be
1031
+ public unless the organization's default is private. This value is ignored
1032
+ if the repo already exists. Not supported with ``sdk="static"`` because
1033
+ static Trackio dashboards read snapshot data directly from the browser.
1034
+ force (`bool`, *optional*, defaults to `False`):
1035
+ If `True`, overwrite the existing database without prompting for confirmation.
1036
+ If `False`, prompt the user before overwriting an existing database.
1037
+ run_in_background (`bool`, *optional*, defaults to `False`):
1038
+ If `True`, the Space creation and database upload will be run in a background thread.
1039
+ If `False`, all the steps will be run synchronously.
1040
+ sdk (`str`, *optional*, defaults to `"gradio"`):
1041
+ The type of Space to deploy. `"gradio"` deploys a Gradio Space with a live
1042
+ server. `"static"` freezes the Space: deploys a static Space that reads from an HF Bucket
1043
+ (no server needed).
1044
+ dataset_id (`str`, *optional*):
1045
+ Deprecated. Use `bucket_id` instead.
1046
+ bucket_id (`str`, *optional*):
1047
+ The ID of the HF Bucket to sync to. By default, a bucket is auto-generated
1048
+ from the space_id.
1049
+ Returns:
1050
+ `str`: The Space ID of the synced project.
1051
+ """
1052
+ if sdk not in ("gradio", "static"):
1053
+ raise ValueError(f"sdk must be 'gradio' or 'static', got '{sdk}'")
1054
+ if sdk == "static" and private is True:
1055
+ raise ValueError(
1056
+ "private=True is not supported for static Trackio Spaces. Static Spaces "
1057
+ "run entirely in the browser, so their snapshot data must be public. "
1058
+ "Use sdk='gradio' for a private dashboard."
1059
+ )
1060
+ bucket_id_was_explicit = bucket_id is not None
1061
+
1062
+ if space_id is None:
1063
+ space_id = SQLiteStorage.get_space_id(project)
1064
+ if space_id is None:
1065
+ space_id = f"{project}-{get_or_create_project_hash(project)}"
1066
+ space_id, dataset_id, bucket_id = preprocess_space_and_dataset_ids(
1067
+ space_id, dataset_id, bucket_id
1068
+ )
1069
+ if dataset_id is None and bucket_id is not None and not bucket_id_was_explicit:
1070
+ bucket_id = resolve_auto_bucket_id(space_id, bucket_id)
1071
+
1072
+ def _do_sync():
1073
+ try:
1074
+ info = huggingface_hub.HfApi().space_info(space_id)
1075
+ existing_sdk = info.sdk
1076
+ if existing_sdk and existing_sdk != sdk:
1077
+ raise ValueError(
1078
+ f"Space '{space_id}' is a '{existing_sdk}' Space but sdk='{sdk}' was requested. "
1079
+ f"The sdk must match the existing Space type."
1080
+ )
1081
+ except RepositoryNotFoundError:
1082
+ pass
1083
+
1084
+ if sdk == "static":
1085
+ if dataset_id is not None:
1086
+ upload_dataset_for_static(project, dataset_id, private=False)
1087
+ deploy_as_static_space(
1088
+ space_id,
1089
+ dataset_id,
1090
+ project,
1091
+ private=False,
1092
+ frontend_dir=frontend_dir,
1093
+ )
1094
+ elif bucket_id is not None:
1095
+ create_bucket_if_not_exists(bucket_id, private=False)
1096
+ upload_project_to_bucket_for_static(project, bucket_id)
1097
+ print(
1098
+ f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
1099
+ )
1100
+ deploy_as_static_space(
1101
+ space_id,
1102
+ None,
1103
+ project,
1104
+ bucket_id=bucket_id,
1105
+ private=False,
1106
+ frontend_dir=frontend_dir,
1107
+ )
1108
+ else:
1109
+ if bucket_id is not None:
1110
+ create_bucket_if_not_exists(bucket_id, private=private)
1111
+ upload_project_to_bucket(project, bucket_id)
1112
+ print(
1113
+ f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
1114
+ )
1115
+ create_space_if_not_exists(
1116
+ space_id,
1117
+ bucket_id=bucket_id,
1118
+ private=private,
1119
+ frontend_dir=frontend_dir,
1120
+ )
1121
+ _wait_until_space_running(space_id)
1122
+ _wait_for_remote_sync(
1123
+ _build_remote_client_with_retry(space_id),
1124
+ project,
1125
+ Counter(
1126
+ log["run"]
1127
+ for log in SQLiteStorage.get_all_logs_for_sync(project)
1128
+ ),
1129
+ )
1130
+ else:
1131
+ sync_incremental(
1132
+ project,
1133
+ space_id,
1134
+ private=private,
1135
+ pending_only=False,
1136
+ frontend_dir=frontend_dir,
1137
+ )
1138
+ SQLiteStorage.set_project_metadata(project, "space_id", space_id)
1139
+
1140
+ if run_in_background:
1141
+ threading.Thread(target=_do_sync).start()
1142
+ else:
1143
+ _do_sync()
1144
+ return space_id
1145
+
1146
+
1147
+ def _get_source_bucket(space_id: str) -> str:
1148
+ bucket_id = _get_existing_space_bucket(space_id)
1149
+ if bucket_id is not None:
1150
+ _ensure_bucket_mounted_at_data(space_id, bucket_id)
1151
+ return bucket_id
1152
+ raise ValueError(
1153
+ f"Space '{space_id}' has no bucket mounted at '/data'. "
1154
+ f"freeze() requires the source Space to use bucket storage."
1155
+ )
1156
+
1157
+
1158
+ def freeze(
1159
+ space_id: str,
1160
+ project: str,
1161
+ new_space_id: str | None = None,
1162
+ private: bool | None = None,
1163
+ bucket_id: str | None = None,
1164
+ frontend_dir: str | Path | None = None,
1165
+ ) -> str:
1166
+ """
1167
+ Creates a new static Hugging Face Space containing a read-only snapshot of
1168
+ the data for the specified project from the source Gradio Space. The data is
1169
+ read from the bucket attached to the source Space at freeze time. The original
1170
+ Space is not modified, and the new static Space does not automatically reflect
1171
+ metrics uploaded to the original Gradio Space after the freeze completes.
1172
+
1173
+ Args:
1174
+ space_id (`str`):
1175
+ The ID of the source Gradio Space (e.g., `"username/my-space"` or a
1176
+ short repo name with the logged-in namespace inferred, like `init()`).
1177
+ Must be a Gradio Space with a bucket mounted at `/data`.
1178
+ project (`str`):
1179
+ The name of the project whose data to include in the frozen Space.
1180
+ new_space_id (`str`, *optional*):
1181
+ The ID for the new static Space. If not provided, defaults to
1182
+ `"{space_id}_static"`.
1183
+ private (`bool`, *optional*):
1184
+ Not supported. Frozen static dashboards read snapshot data directly
1185
+ from the browser, so the destination snapshot must be public.
1186
+ bucket_id (`str`, *optional*):
1187
+ The ID of the HF Bucket for the new static Space's data storage.
1188
+ If not provided, one is auto-generated from the new Space ID.
1189
+
1190
+ Returns:
1191
+ `str`: The Space ID of the newly created static Space.
1192
+ """
1193
+ if private is True:
1194
+ raise ValueError(
1195
+ "private=True is not supported for frozen static Trackio Spaces. Static "
1196
+ "Spaces run entirely in the browser, so their snapshot data must be "
1197
+ "public. Use a Gradio Space if the frozen dashboard must stay private."
1198
+ )
1199
+ space_id, _, _ = preprocess_space_and_dataset_ids(space_id, None, None)
1200
+
1201
+ try:
1202
+ info = huggingface_hub.HfApi().space_info(space_id)
1203
+ if info.sdk != "gradio":
1204
+ raise ValueError(
1205
+ f"Space '{space_id}' is not a Gradio Space (sdk='{info.sdk}'). "
1206
+ f"freeze() requires a Gradio Space as the source."
1207
+ )
1208
+ except RepositoryNotFoundError:
1209
+ raise ValueError(
1210
+ f"Space '{space_id}' not found. Provide an existing Gradio Space ID."
1211
+ )
1212
+
1213
+ source_bucket_id = _get_source_bucket(space_id)
1214
+ print(f"* Reading project data from bucket: {source_bucket_id}")
1215
+
1216
+ bucket_id_was_explicit = bucket_id is not None
1217
+
1218
+ if new_space_id is None:
1219
+ new_space_id = f"{space_id}_static"
1220
+ new_space_id, _dataset_id, bucket_id = preprocess_space_and_dataset_ids(
1221
+ new_space_id, None, bucket_id
1222
+ )
1223
+ if bucket_id is not None and not bucket_id_was_explicit:
1224
+ bucket_id = resolve_auto_bucket_id(new_space_id, bucket_id)
1225
+
1226
+ hf_api = huggingface_hub.HfApi()
1227
+ try:
1228
+ dest_info = hf_api.space_info(new_space_id)
1229
+ tags = dest_info.tags or []
1230
+ if dest_info.sdk != "static" or "trackio" not in tags:
1231
+ raise ValueError(
1232
+ f"Space '{new_space_id}' already exists and is not a Trackio static Space "
1233
+ f"(sdk='{dest_info.sdk}', tags={tags}). Choose a different new_space_id "
1234
+ f"or delete the existing Space first."
1235
+ )
1236
+ except RepositoryNotFoundError:
1237
+ pass
1238
+
1239
+ create_bucket_if_not_exists(bucket_id, private=False)
1240
+ export_from_bucket_for_static(source_bucket_id, bucket_id, project)
1241
+ print(
1242
+ f"* Project data uploaded to bucket: https://huggingface.co/buckets/{bucket_id}"
1243
+ )
1244
+ deploy_as_static_space(
1245
+ new_space_id,
1246
+ None,
1247
+ project,
1248
+ bucket_id=bucket_id,
1249
+ private=False,
1250
+ frontend_dir=frontend_dir,
1251
+ )
1252
+ 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/frontend/dist/assets/index-DsqR87ZT.js ADDED
The diff for this file is too large to render. See raw diff
 
trackio/frontend/dist/assets/index-PpAq14vL.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}.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)}.logo-section.svelte-181dlmc{margin-bottom:20px}.logo.svelte-181dlmc{width:80%;max-width:200px}.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)}.locked-project.svelte-181dlmc{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)}.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}.media-toolbar.svelte-outb32{display:flex;justify-content:flex-end;align-items:center;gap:12px;margin-bottom:12px}.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-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}.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}.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}.run-detail-page.svelte-1bpgsx2{padding:20px 24px;overflow-y:auto;flex:1}.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}.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}.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/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-DsqR87ZT.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-PpAq14vL.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,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serves a static frontend alongside the Trackio HTTP API."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.responses import FileResponse, HTMLResponse, JSONResponse
9
+ from starlette.routing import Mount, Route
10
+ from starlette.staticfiles import StaticFiles
11
+
12
+ ASSETS_DIR = Path(__file__).parent / "assets"
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+ _LIVE_RELOAD_SCRIPT = """
17
+ <script>
18
+ (() => {
19
+ const endpoint = "/__trackio/frontend_version";
20
+ let currentVersion = null;
21
+
22
+ async function poll() {
23
+ try {
24
+ const response = await fetch(endpoint, { cache: "no-store" });
25
+ const payload = await response.json();
26
+ if (currentVersion === null) {
27
+ currentVersion = payload.version;
28
+ return;
29
+ }
30
+ if (payload.version !== currentVersion) {
31
+ window.location.reload();
32
+ }
33
+ } catch (error) {
34
+ console.warn("Trackio live reload poll failed", error);
35
+ }
36
+ }
37
+
38
+ poll();
39
+ setInterval(poll, 1000);
40
+ })();
41
+ </script>
42
+ """.strip()
43
+
44
+
45
+ def _frontend_version(frontend_root: Path) -> str:
46
+ digest = hashlib.sha256()
47
+ for path in sorted(frontend_root.rglob("*")):
48
+ if not path.is_file():
49
+ continue
50
+ relative = path.relative_to(frontend_root)
51
+ digest.update(str(relative).encode("utf-8"))
52
+ stat = path.stat()
53
+ digest.update(str(stat.st_mtime_ns).encode("utf-8"))
54
+ digest.update(str(stat.st_size).encode("utf-8"))
55
+ return digest.hexdigest()
56
+
57
+
58
+ def _inject_live_reload(html: str) -> str:
59
+ if "/__trackio/frontend_version" in html:
60
+ return html
61
+ if "</body>" in html:
62
+ return html.replace("</body>", f"{_LIVE_RELOAD_SCRIPT}\n </body>")
63
+ return html + _LIVE_RELOAD_SCRIPT
64
+
65
+
66
+ def _html_response(path: Path) -> HTMLResponse:
67
+ html = path.read_text(encoding="utf-8")
68
+ return HTMLResponse(_inject_live_reload(html))
69
+
70
+
71
+ class FrontendMiddleware(BaseHTTPMiddleware):
72
+ def __init__(self, app, frontend_root: Path, index_html_path: Path):
73
+ super().__init__(app)
74
+ self.frontend_root = frontend_root
75
+ self.index_html_path = index_html_path
76
+ self.reserved_prefixes = (
77
+ "/api/",
78
+ "/file",
79
+ "/version",
80
+ "/static/trackio",
81
+ "/__trackio/frontend_version",
82
+ "/oauth/",
83
+ "/login/",
84
+ "/mcp",
85
+ )
86
+ self.reserved_exact = {
87
+ "/api",
88
+ "/oauth",
89
+ "/login",
90
+ }
91
+
92
+ async def dispatch(self, request, call_next):
93
+ if request.method not in {"GET", "HEAD"}:
94
+ return await call_next(request)
95
+
96
+ path = request.url.path
97
+ if path in self.reserved_exact or path.startswith(self.reserved_prefixes):
98
+ return await call_next(request)
99
+
100
+ relative_path = path.lstrip("/")
101
+ requested_path = (self.frontend_root / relative_path).resolve()
102
+ if (
103
+ relative_path
104
+ and requested_path.is_file()
105
+ and requested_path.is_relative_to(self.frontend_root)
106
+ ):
107
+ if requested_path.suffix.lower() == ".html":
108
+ return _html_response(requested_path)
109
+ return FileResponse(requested_path)
110
+
111
+ return _html_response(self.index_html_path)
112
+
113
+
114
+ def mount_frontend(app, frontend_dir: str | Path):
115
+ frontend_root = Path(frontend_dir).resolve()
116
+ if not frontend_root.exists():
117
+ _logger.warning(
118
+ "Trackio dashboard UI was not mounted: %s is missing. "
119
+ "Build the frontend or provide a custom frontend directory.",
120
+ frontend_root,
121
+ )
122
+ return
123
+
124
+ index_html_path = frontend_root / "index.html"
125
+ if not index_html_path.exists():
126
+ _logger.warning(
127
+ "Trackio dashboard UI was not mounted: %s is missing.",
128
+ index_html_path,
129
+ )
130
+ return
131
+
132
+ async def frontend_version(_request):
133
+ return JSONResponse({"version": _frontend_version(frontend_root)})
134
+
135
+ static_assets = StaticFiles(directory=str(ASSETS_DIR))
136
+
137
+ app.add_middleware(
138
+ FrontendMiddleware,
139
+ frontend_root=frontend_root,
140
+ index_html_path=index_html_path,
141
+ )
142
+ app.routes.append(Mount("/static/trackio", app=static_assets))
143
+ app.routes.append(
144
+ Route("/__trackio/frontend_version", frontend_version, methods=["GET"])
145
+ )
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
trackio/histogram.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Sequence
2
+
3
+ import numpy as np
4
+
5
+
6
+ class Histogram:
7
+ """
8
+ Histogram data type for Trackio, compatible with wandb.Histogram.
9
+
10
+ Args:
11
+ sequence (`np.ndarray` or `Sequence[float]` or `Sequence[int]`, *optional*):
12
+ Sequence of values to create the histogram from.
13
+ np_histogram (`tuple`, *optional*):
14
+ Pre-computed NumPy histogram as a `(hist, bins)` tuple.
15
+ num_bins (`int`, *optional*, defaults to `64`):
16
+ Number of bins for the histogram (maximum `512`).
17
+
18
+ Example:
19
+ ```python
20
+ import trackio
21
+ import numpy as np
22
+
23
+ # Create histogram from sequence
24
+ data = np.random.randn(1000)
25
+ trackio.log({"distribution": trackio.Histogram(data)})
26
+
27
+ # Create histogram from numpy histogram
28
+ hist, bins = np.histogram(data, bins=30)
29
+ trackio.log({"distribution": trackio.Histogram(np_histogram=(hist, bins))})
30
+
31
+ # Specify custom number of bins
32
+ trackio.log({"distribution": trackio.Histogram(data, num_bins=50)})
33
+ ```
34
+ """
35
+
36
+ TYPE = "trackio.histogram"
37
+
38
+ def __init__(
39
+ self,
40
+ sequence: np.ndarray | Sequence[float] | Sequence[int] | None = None,
41
+ np_histogram: tuple | None = None,
42
+ num_bins: int = 64,
43
+ ):
44
+ if sequence is None and np_histogram is None:
45
+ raise ValueError("Must provide either sequence or np_histogram")
46
+
47
+ if sequence is not None and np_histogram is not None:
48
+ raise ValueError("Cannot provide both sequence and np_histogram")
49
+
50
+ num_bins = min(num_bins, 512)
51
+
52
+ if np_histogram is not None:
53
+ self.histogram, self.bins = np_histogram
54
+ self.histogram = np.asarray(self.histogram)
55
+ self.bins = np.asarray(self.bins)
56
+ else:
57
+ data = np.asarray(sequence).flatten()
58
+ data = data[np.isfinite(data)]
59
+ if len(data) == 0:
60
+ self.histogram = np.array([])
61
+ self.bins = np.array([])
62
+ else:
63
+ self.histogram, self.bins = np.histogram(data, bins=num_bins)
64
+
65
+ def _to_dict(self) -> dict:
66
+ """Convert histogram to dictionary for storage."""
67
+ return {
68
+ "_type": self.TYPE,
69
+ "bins": self.bins.tolist(),
70
+ "values": self.histogram.tolist(),
71
+ }
trackio/imports.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from trackio import deploy, utils
6
+ from trackio.sqlite_storage import SQLiteStorage
7
+
8
+
9
+ def import_csv(
10
+ csv_path: str | Path,
11
+ project: str,
12
+ name: str | None = None,
13
+ space_id: str | None = None,
14
+ dataset_id: str | None = None,
15
+ private: bool | None = None,
16
+ force: bool = False,
17
+ ) -> None:
18
+ """
19
+ Imports a CSV file into a Trackio project. The CSV file must contain a `"step"`
20
+ column, may optionally contain a `"timestamp"` column, and any other columns will be
21
+ treated as metrics. It should also include a header row with the column names.
22
+
23
+ TODO: call init() and return a Run object so that the user can continue to log metrics to it.
24
+
25
+ Args:
26
+ csv_path (`str` or `Path`):
27
+ The str or Path to the CSV file to import.
28
+ project (`str`):
29
+ The name of the project to import the CSV file into. Must not be an existing
30
+ project.
31
+ name (`str`, *optional*):
32
+ The name of the Run to import the CSV file into. If not provided, a default
33
+ name will be generated.
34
+ name (`str`, *optional*):
35
+ The name of the run (if not provided, a default name will be generated).
36
+ space_id (`str`, *optional*):
37
+ If provided, the project will be logged to a Hugging Face Space instead of a
38
+ local directory. Should be a complete Space name like `"username/reponame"`
39
+ or `"orgname/reponame"`, or just `"reponame"` in which case the Space will
40
+ be created in the currently-logged-in Hugging Face user's namespace. If the
41
+ Space does not exist, it will be created. If the Space already exists, the
42
+ project will be logged to it.
43
+ dataset_id (`str`, *optional*):
44
+ Deprecated. Use `bucket_id` instead.
45
+ private (`bool`, *optional*):
46
+ Whether to make the Space private. If None (default), the repo will be
47
+ public unless the organization's default is private. This value is ignored
48
+ if the repo already exists.
49
+ """
50
+ if SQLiteStorage.get_runs(project):
51
+ raise ValueError(
52
+ f"Project '{project}' already exists. Cannot import CSV into existing project."
53
+ )
54
+
55
+ csv_path = Path(csv_path)
56
+ if not csv_path.exists():
57
+ raise FileNotFoundError(f"CSV file not found: {csv_path}")
58
+
59
+ with csv_path.open(newline="", encoding="utf-8") as csv_file:
60
+ reader = csv.DictReader(csv_file)
61
+ source_columns = reader.fieldnames or []
62
+ rows = list(reader)
63
+
64
+ if not rows:
65
+ raise ValueError("CSV file is empty")
66
+
67
+ column_mapping = utils.simplify_column_names(source_columns)
68
+ normalized_rows = [
69
+ {column_mapping[key]: value for key, value in row.items()} for row in rows
70
+ ]
71
+ columns = list(normalized_rows[0].keys())
72
+
73
+ step_column = None
74
+ for col in columns:
75
+ if col.lower() == "step":
76
+ step_column = col
77
+ break
78
+
79
+ if step_column is None:
80
+ raise ValueError("CSV file must contain a 'step' or 'Step' column")
81
+
82
+ if name is None:
83
+ name = csv_path.stem
84
+
85
+ metrics_list = []
86
+ steps = []
87
+ timestamps = []
88
+
89
+ numeric_columns = []
90
+ for column in columns:
91
+ if column == step_column:
92
+ continue
93
+ if column == "timestamp":
94
+ continue
95
+
96
+ try:
97
+ for row in normalized_rows:
98
+ value = row[column]
99
+ if value in ("", None):
100
+ continue
101
+ float(value)
102
+ except (ValueError, TypeError):
103
+ continue
104
+ numeric_columns.append(column)
105
+
106
+ for row in normalized_rows:
107
+ metrics = {}
108
+ for column in numeric_columns:
109
+ value = row[column]
110
+ if value not in ("", None):
111
+ metrics[column] = float(value)
112
+
113
+ if metrics:
114
+ metrics_list.append(metrics)
115
+ steps.append(int(float(row[step_column])))
116
+
117
+ if "timestamp" in row and row["timestamp"] not in ("", None):
118
+ timestamps.append(str(row["timestamp"]))
119
+ else:
120
+ timestamps.append("")
121
+
122
+ if not metrics_list:
123
+ raise ValueError(
124
+ f"No numeric metric data found in CSV file: {csv_path}. Columns other "
125
+ "than 'step' and 'timestamp' must contain numeric values."
126
+ )
127
+
128
+ SQLiteStorage.bulk_log(
129
+ project=project,
130
+ run=name,
131
+ metrics_list=metrics_list,
132
+ steps=steps,
133
+ timestamps=timestamps,
134
+ )
135
+
136
+ print(
137
+ f"* Imported {len(metrics_list)} rows from {csv_path} into project '{project}' as run '{name}'"
138
+ )
139
+ print(f"* Metrics found: {', '.join(metrics_list[0].keys())}")
140
+
141
+ space_id, dataset_id, _ = utils.preprocess_space_and_dataset_ids(
142
+ space_id, dataset_id
143
+ )
144
+ if dataset_id is not None:
145
+ os.environ["TRACKIO_DATASET_ID"] = dataset_id
146
+ print(f"* Trackio metrics will be synced to Hugging Face Dataset: {dataset_id}")
147
+
148
+ if space_id is None:
149
+ utils.print_dashboard_instructions(project)
150
+ else:
151
+ deploy.create_space_if_not_exists(
152
+ space_id=space_id, dataset_id=dataset_id, private=private
153
+ )
154
+ deploy.wait_until_space_exists(space_id=space_id)
155
+ deploy.upload_db_to_space(project=project, space_id=space_id, force=force)
156
+ print(
157
+ f"* View dashboard by going to: {deploy.SPACE_URL.format(space_id=space_id)}"
158
+ )
159
+
160
+
161
+ def import_tf_events(
162
+ log_dir: str | Path,
163
+ project: str,
164
+ name: str | None = None,
165
+ space_id: str | None = None,
166
+ dataset_id: str | None = None,
167
+ private: bool | None = None,
168
+ force: bool = False,
169
+ ) -> None:
170
+ """
171
+ Imports TensorFlow Events files from a directory into a Trackio project. Each
172
+ subdirectory in the log directory will be imported as a separate run.
173
+
174
+ Args:
175
+ log_dir (`str` or `Path`):
176
+ The str or Path to the directory containing TensorFlow Events files.
177
+ project (`str`):
178
+ The name of the project to import the TensorFlow Events files into. Must not
179
+ be an existing project.
180
+ name (`str`, *optional*):
181
+ The name prefix for runs (if not provided, will use directory names). Each
182
+ subdirectory will create a separate run.
183
+ space_id (`str`, *optional*):
184
+ If provided, the project will be logged to a Hugging Face Space instead of a
185
+ local directory. Should be a complete Space name like `"username/reponame"`
186
+ or `"orgname/reponame"`, or just `"reponame"` in which case the Space will
187
+ be created in the currently-logged-in Hugging Face user's namespace. If the
188
+ Space does not exist, it will be created. If the Space already exists, the
189
+ project will be logged to it.
190
+ dataset_id (`str`, *optional*):
191
+ Deprecated. Use `bucket_id` instead.
192
+ private (`bool`, *optional*):
193
+ Whether to make the Space private. If None (default), the repo will be
194
+ public unless the organization's default is private. This value is ignored
195
+ if the repo already exists.
196
+ """
197
+ try:
198
+ from tbparse import SummaryReader
199
+ except ImportError:
200
+ raise ImportError(
201
+ "The `tbparse` package is not installed but is required for `import_tf_events`. Please install trackio with the `tensorboard` extra: `pip install trackio[tensorboard]`."
202
+ )
203
+
204
+ if SQLiteStorage.get_runs(project):
205
+ raise ValueError(
206
+ f"Project '{project}' already exists. Cannot import TF events into existing project."
207
+ )
208
+
209
+ path = Path(log_dir)
210
+ if not path.exists():
211
+ raise FileNotFoundError(f"TF events directory not found: {path}")
212
+
213
+ # Use tbparse to read all tfevents files in the directory structure
214
+ reader = SummaryReader(str(path), extra_columns={"dir_name"})
215
+ df = reader.scalars
216
+
217
+ if df.empty:
218
+ raise ValueError(f"No TensorFlow events data found in {path}")
219
+
220
+ total_imported = 0
221
+ imported_runs = []
222
+
223
+ # Group by dir_name to create separate runs
224
+ for dir_name, group_df in df.groupby("dir_name"):
225
+ try:
226
+ # Determine run name based on directory name
227
+ if dir_name == "":
228
+ run_name = "main" # For files in the root directory
229
+ else:
230
+ run_name = dir_name # Use directory name
231
+
232
+ if name:
233
+ run_name = f"{name}_{run_name}"
234
+
235
+ if group_df.empty:
236
+ print(f"* Skipping directory {dir_name}: no scalar data found")
237
+ continue
238
+
239
+ metrics_list = []
240
+ steps = []
241
+ timestamps = []
242
+
243
+ for _, row in group_df.iterrows():
244
+ # Convert row values to appropriate types
245
+ tag = str(row["tag"])
246
+ value = float(row["value"])
247
+ step = int(row["step"])
248
+
249
+ metrics = {tag: value}
250
+ metrics_list.append(metrics)
251
+ steps.append(step)
252
+
253
+ # Use wall_time if present, else fallback
254
+ if "wall_time" in group_df.columns and not utils.is_missing_value(
255
+ row["wall_time"]
256
+ ):
257
+ timestamps.append(str(row["wall_time"]))
258
+ else:
259
+ timestamps.append("")
260
+
261
+ if metrics_list:
262
+ SQLiteStorage.bulk_log(
263
+ project=project,
264
+ run=str(run_name),
265
+ metrics_list=metrics_list,
266
+ steps=steps,
267
+ timestamps=timestamps,
268
+ )
269
+
270
+ total_imported += len(metrics_list)
271
+ imported_runs.append(run_name)
272
+
273
+ print(
274
+ f"* Imported {len(metrics_list)} scalar events from directory '{dir_name}' as run '{run_name}'"
275
+ )
276
+ print(f"* Metrics in this run: {', '.join(set(group_df['tag']))}")
277
+
278
+ except Exception as e:
279
+ print(f"* Error processing directory {dir_name}: {e}")
280
+ continue
281
+
282
+ if not imported_runs:
283
+ raise ValueError("No valid TensorFlow events data could be imported")
284
+
285
+ print(f"* Total imported events: {total_imported}")
286
+ print(f"* Created runs: {', '.join(imported_runs)}")
287
+
288
+ space_id, dataset_id, _ = utils.preprocess_space_and_dataset_ids(
289
+ space_id, dataset_id
290
+ )
291
+ if dataset_id is not None:
292
+ os.environ["TRACKIO_DATASET_ID"] = dataset_id
293
+ print(f"* Trackio metrics will be synced to Hugging Face Dataset: {dataset_id}")
294
+
295
+ if space_id is None:
296
+ utils.print_dashboard_instructions(project)
297
+ else:
298
+ deploy.create_space_if_not_exists(
299
+ space_id, dataset_id=dataset_id, private=private
300
+ )
301
+ deploy.wait_until_space_exists(space_id)
302
+ deploy.upload_db_to_space(project, space_id, force=force)
303
+ print(
304
+ f"* View dashboard by going to: {deploy.SPACE_URL.format(space_id=space_id)}"
305
+ )
trackio/launch.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import threading
7
+ import time
8
+ import warnings
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ import uvicorn
14
+ from uvicorn.config import Config
15
+
16
+ from trackio._vendor.gradio_exceptions import ChecksumMismatchError
17
+ from trackio._vendor.networking import normalize_share_url, setup_tunnel, url_ok
18
+ from trackio._vendor.tunneling import BINARY_PATH
19
+ from trackio.launch_utils import colab_check, is_hosted_notebook
20
+
21
+ INITIAL_PORT_VALUE = int(os.getenv("GRADIO_SERVER_PORT", "7860"))
22
+ TRY_NUM_PORTS = int(os.getenv("GRADIO_NUM_PORTS", "100"))
23
+ LOCALHOST_NAME = os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")
24
+
25
+
26
+ class _UvicornServer(uvicorn.Server):
27
+ def install_signal_handlers(self) -> None:
28
+ pass
29
+
30
+ def run_in_thread(self) -> None:
31
+ self.thread = threading.Thread(target=self.run, daemon=True)
32
+ self.thread.start()
33
+ start = time.time()
34
+ while not self.started:
35
+ time.sleep(1e-3)
36
+ if time.time() - start > 60:
37
+ raise RuntimeError(
38
+ "Server failed to start. Please check that the port is available."
39
+ )
40
+
41
+
42
+ def _bind_host(server_name: str) -> str:
43
+ if server_name.startswith("[") and server_name.endswith("]"):
44
+ return server_name[1:-1]
45
+ return server_name
46
+
47
+
48
+ def start_server(
49
+ app: Any,
50
+ server_name: str | None = None,
51
+ server_port: int | None = None,
52
+ ssl_keyfile: str | None = None,
53
+ ssl_certfile: str | None = None,
54
+ ssl_keyfile_password: str | None = None,
55
+ ) -> tuple[str, int, str, _UvicornServer]:
56
+ server_name = server_name or LOCALHOST_NAME
57
+ url_host_name = "localhost" if server_name == "0.0.0.0" else server_name
58
+
59
+ host = _bind_host(server_name)
60
+
61
+ server_ports = (
62
+ [server_port]
63
+ if server_port is not None
64
+ else range(INITIAL_PORT_VALUE, INITIAL_PORT_VALUE + TRY_NUM_PORTS)
65
+ )
66
+
67
+ port_used = None
68
+ server = None
69
+ for port in server_ports:
70
+ try:
71
+ socket_family = socket.AF_INET6 if ":" in host else socket.AF_INET
72
+ with socket.socket(socket_family, socket.SOCK_STREAM) as s:
73
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
74
+ s.bind((host, port))
75
+ config = Config(
76
+ app=app,
77
+ port=port,
78
+ host=host,
79
+ log_level="warning",
80
+ ssl_keyfile=ssl_keyfile,
81
+ ssl_certfile=ssl_certfile,
82
+ ssl_keyfile_password=ssl_keyfile_password,
83
+ )
84
+ server = _UvicornServer(config=config)
85
+ server.run_in_thread()
86
+ port_used = port
87
+ break
88
+ except (OSError, RuntimeError):
89
+ continue
90
+ else:
91
+ raise OSError(
92
+ f"Cannot find empty port in range: {min(server_ports)}-{max(server_ports)}. "
93
+ "Set GRADIO_SERVER_PORT or pass server_port to trackio.show()."
94
+ )
95
+
96
+ assert port_used is not None and server is not None
97
+
98
+ if ssl_keyfile is not None:
99
+ path_to_local_server = f"https://{url_host_name}:{port_used}/"
100
+ else:
101
+ path_to_local_server = f"http://{url_host_name}:{port_used}/"
102
+
103
+ return server_name, port_used, path_to_local_server, server
104
+
105
+
106
+ def launch_trackio_dashboard(
107
+ starlette_app: Any,
108
+ *,
109
+ server_name: str | None = None,
110
+ server_port: int | None = None,
111
+ share: bool | None = None,
112
+ share_server_address: str | None = None,
113
+ share_server_protocol: str | None = None,
114
+ share_server_tls_certificate: str | None = None,
115
+ mcp_server: bool = False,
116
+ ssl_verify: bool = True,
117
+ quiet: bool = False,
118
+ ) -> tuple[str | None, str | None, str | None, Any]:
119
+ is_colab = colab_check()
120
+ is_hosted_nb = is_hosted_notebook()
121
+ space_id = os.getenv("SPACE_ID")
122
+
123
+ if share is None:
124
+ if is_colab or is_hosted_nb:
125
+ if not quiet:
126
+ print(
127
+ "It looks like you are running Trackio on a hosted Jupyter notebook, which requires "
128
+ "`share=True`. Automatically setting `share=True` "
129
+ "(set `share=False` in `show()` to disable).\n"
130
+ )
131
+ share = True
132
+ else:
133
+ share = os.getenv("GRADIO_SHARE", "").lower() == "true"
134
+
135
+ sn = server_name
136
+ if sn is None and os.getenv("SYSTEM") == "spaces":
137
+ sn = "0.0.0.0"
138
+ elif sn is None:
139
+ sn = LOCALHOST_NAME
140
+
141
+ server_name_r, server_port_r, local_url, uv_server = start_server(
142
+ starlette_app,
143
+ server_name=sn,
144
+ server_port=server_port,
145
+ )
146
+
147
+ local_api_url = f"{local_url.rstrip('/')}/api/"
148
+ try:
149
+ httpx.get(f"{local_url.rstrip('/')}/version", verify=ssl_verify, timeout=10)
150
+ except Exception as e:
151
+ raise RuntimeError(
152
+ f"Could not reach Trackio server at {local_url.rstrip('/')}/version: {e}"
153
+ ) from e
154
+
155
+ if share and space_id:
156
+ warnings.warn("Setting share=True is not supported on Hugging Face Spaces")
157
+ share = False
158
+
159
+ share_url: str | None = None
160
+ if share:
161
+ try:
162
+ share_tok = secrets.token_urlsafe(32)
163
+ proto = share_server_protocol or (
164
+ "http" if share_server_address is not None else "https"
165
+ )
166
+ raw = setup_tunnel(
167
+ local_host=server_name_r,
168
+ local_port=server_port_r,
169
+ share_token=share_tok,
170
+ share_server_address=share_server_address,
171
+ share_server_tls_certificate=share_server_tls_certificate,
172
+ )
173
+ share_url = normalize_share_url(raw, proto)
174
+ if not quiet:
175
+ print(f"* Running on public URL: {share_url}")
176
+ print(
177
+ "\nThis share link expires in 1 week. For permanent hosting, deploy to Hugging Face Spaces."
178
+ )
179
+ except Exception as e:
180
+ share_url = None
181
+ if not quiet:
182
+ if isinstance(e, ChecksumMismatchError):
183
+ print(
184
+ "\nCould not create share link. Checksum mismatch for frpc binary."
185
+ )
186
+ elif Path(BINARY_PATH).exists():
187
+ print(
188
+ "\nCould not create share link. Check your internet connection or https://status.gradio.app."
189
+ )
190
+ else:
191
+ print(
192
+ f"\nCould not create share link. Missing frpc at {BINARY_PATH}. {e}"
193
+ )
194
+
195
+ if not share_url and not quiet:
196
+ print("* To create a public link, set `share=True` in `trackio.show()`.")
197
+
198
+ return local_url, share_url, local_api_url, uv_server
199
+
200
+
201
+ def url_ok_local(local_url: str) -> bool:
202
+ return url_ok(local_url)
trackio/launch_utils.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ def colab_check() -> bool:
5
+ is_colab = False
6
+ try:
7
+ from IPython.core.getipython import get_ipython # noqa: PLC0415
8
+
9
+ from_ipynb = get_ipython()
10
+ if "google.colab" in str(from_ipynb):
11
+ is_colab = True
12
+ except (ImportError, NameError):
13
+ pass
14
+ return is_colab
15
+
16
+
17
+ def is_hosted_notebook() -> bool:
18
+ return bool(
19
+ os.environ.get("KAGGLE_KERNEL_RUN_TYPE")
20
+ or os.path.exists("/home/ec2-user/SageMaker")
21
+ )
22
+
23
+
24
+ def ipython_check() -> bool:
25
+ is_ipython = False
26
+ try:
27
+ from IPython.core.getipython import get_ipython # noqa: PLC0415
28
+
29
+ if get_ipython() is not None:
30
+ is_ipython = True
31
+ except (ImportError, NameError):
32
+ pass
33
+ return is_ipython
trackio/markdown.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class Markdown:
2
+ """
3
+ Markdown report data type for Trackio.
4
+
5
+ Args:
6
+ text (`str`):
7
+ Markdown content to log.
8
+ """
9
+
10
+ TYPE = "trackio.markdown"
11
+
12
+ def __init__(self, text: str = ""):
13
+ if not isinstance(text, str):
14
+ raise ValueError("Markdown text must be a string")
15
+ self.text = text
16
+
17
+ def _to_dict(self) -> dict:
18
+ return {
19
+ "_type": self.TYPE,
20
+ "_value": self.text,
21
+ }
trackio/mcp_setup.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any
7
+
8
+ from starlette.routing import Mount
9
+
10
+ from trackio.sqlite_storage import SQLiteStorage
11
+
12
+
13
+ def _assert_mcp_mutation_access(
14
+ *,
15
+ hf_token: str | None = None,
16
+ write_token: str | None = None,
17
+ ) -> None:
18
+ import trackio.server as trackio_server # noqa: PLC0415
19
+
20
+ if os.getenv("SYSTEM") == "spaces":
21
+ try:
22
+ trackio_server.check_hf_token_has_write_access(hf_token)
23
+ except PermissionError as e:
24
+ raise ValueError(str(e)) from e
25
+ return
26
+
27
+ if not secrets.compare_digest(write_token or "", trackio_server.write_token or ""):
28
+ raise ValueError(
29
+ "A write_token is required for Trackio MCP mutations. "
30
+ "Use the write token from the dashboard URL."
31
+ )
32
+
33
+
34
+ def create_mcp_integration() -> tuple[list[Any], Any]:
35
+ from mcp.server.fastmcp import FastMCP # noqa: PLC0415
36
+
37
+ import trackio.server as trackio_server # noqa: PLC0415
38
+
39
+ mcp = FastMCP(
40
+ "Trackio",
41
+ instructions="Inspect and manage Trackio experiment data.",
42
+ streamable_http_path="/",
43
+ log_level="WARNING",
44
+ )
45
+
46
+ mcp.add_tool(
47
+ trackio_server.get_all_projects,
48
+ description="List all Trackio projects available on this server.",
49
+ structured_output=True,
50
+ )
51
+ mcp.add_tool(
52
+ trackio_server.get_runs_for_project,
53
+ description="List runs for a given Trackio project.",
54
+ structured_output=True,
55
+ )
56
+ mcp.add_tool(
57
+ trackio_server.get_metrics_for_run,
58
+ description="List metric names recorded for a given Trackio run.",
59
+ structured_output=True,
60
+ )
61
+ mcp.add_tool(
62
+ trackio_server.get_project_summary,
63
+ description="Return summary metadata for a Trackio project.",
64
+ structured_output=True,
65
+ )
66
+ mcp.add_tool(
67
+ trackio_server.get_run_summary,
68
+ description="Return summary metadata for a Trackio run.",
69
+ structured_output=True,
70
+ )
71
+ mcp.add_tool(
72
+ trackio_server.get_metric_values,
73
+ description="Fetch metric values for a run, optionally around a step or time.",
74
+ structured_output=True,
75
+ )
76
+ mcp.add_tool(
77
+ trackio_server.get_system_metrics_for_run,
78
+ description="List system metric names recorded for a run.",
79
+ structured_output=True,
80
+ )
81
+ mcp.add_tool(
82
+ trackio_server.get_system_logs,
83
+ description="Fetch system metric logs for a run.",
84
+ structured_output=True,
85
+ )
86
+ mcp.add_tool(
87
+ trackio_server.get_snapshot,
88
+ description="Fetch a single Trackio snapshot around a step or timestamp.",
89
+ structured_output=True,
90
+ )
91
+ mcp.add_tool(
92
+ trackio_server.get_logs,
93
+ description="Fetch Trackio metric logs for a run.",
94
+ structured_output=True,
95
+ )
96
+ mcp.add_tool(
97
+ trackio_server.get_alerts,
98
+ description="Fetch alerts for a project, optionally filtered by run or level.",
99
+ structured_output=True,
100
+ )
101
+ mcp.add_tool(
102
+ trackio_server.get_settings,
103
+ description="Return Trackio dashboard settings and asset configuration.",
104
+ structured_output=True,
105
+ )
106
+
107
+ @mcp.tool(
108
+ description="Delete a run. On Spaces, pass an hf_token with write access.",
109
+ structured_output=True,
110
+ )
111
+ def delete_run(
112
+ project: str,
113
+ run: str,
114
+ hf_token: str | None = None,
115
+ write_token: str | None = None,
116
+ ) -> bool:
117
+ _assert_mcp_mutation_access(hf_token=hf_token, write_token=write_token)
118
+ return SQLiteStorage.delete_run(project, run)
119
+
120
+ @mcp.tool(
121
+ description="Rename a run. On Spaces, pass an hf_token with write access.",
122
+ structured_output=True,
123
+ )
124
+ def rename_run(
125
+ project: str,
126
+ old_name: str,
127
+ new_name: str,
128
+ hf_token: str | None = None,
129
+ write_token: str | None = None,
130
+ ) -> bool:
131
+ _assert_mcp_mutation_access(hf_token=hf_token, write_token=write_token)
132
+ SQLiteStorage.rename_run(project, old_name, new_name)
133
+ return True
134
+
135
+ @mcp.tool(
136
+ description=(
137
+ "Trigger a Trackio export/sync pass. On Spaces, pass an hf_token with "
138
+ "write access."
139
+ ),
140
+ structured_output=True,
141
+ )
142
+ def trigger_sync(
143
+ hf_token: str | None = None,
144
+ write_token: str | None = None,
145
+ ) -> bool:
146
+ _assert_mcp_mutation_access(hf_token=hf_token, write_token=write_token)
147
+ return trackio_server.force_sync()
148
+
149
+ mcp_app = mcp.streamable_http_app()
150
+
151
+ @asynccontextmanager
152
+ async def mcp_lifespan_context(app):
153
+ async with mcp.session_manager.run():
154
+ yield
155
+
156
+ return [Mount("/mcp", app=mcp_app)], mcp_lifespan_context
trackio/media/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Media module for Trackio.
3
+
4
+ This module contains all media-related functionality including:
5
+ - TrackioImage, TrackioVideo, TrackioAudio classes
6
+ - Video writing utilities
7
+ - Audio conversion utilities
8
+ """
9
+
10
+ from trackio.media.audio import TrackioAudio
11
+ from trackio.media.image import TrackioImage
12
+ from trackio.media.media import TrackioMedia
13
+ from trackio.media.utils import get_project_media_path
14
+ from trackio.media.video import TrackioVideo
15
+
16
+ write_audio = TrackioAudio.write_audio
17
+ write_video = TrackioVideo.write_video
18
+
19
+ __all__ = [
20
+ "TrackioMedia",
21
+ "TrackioImage",
22
+ "TrackioVideo",
23
+ "TrackioAudio",
24
+ "get_project_media_path",
25
+ "write_video",
26
+ "write_audio",
27
+ ]
trackio/media/audio.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import warnings
5
+ import wave
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+
11
+ from trackio.media.media import TrackioMedia
12
+ from trackio.media.utils import check_ffmpeg_installed, check_path
13
+
14
+ SUPPORTED_FORMATS = ["wav", "mp3"]
15
+ AudioFormatType = Literal["wav", "mp3"]
16
+ TrackioAudioSourceType = str | Path | np.ndarray
17
+
18
+
19
+ class TrackioAudio(TrackioMedia):
20
+ """
21
+ Initializes an Audio object.
22
+
23
+ Example:
24
+ ```python
25
+ import trackio
26
+ import numpy as np
27
+
28
+ # Generate a 1-second 440 Hz sine wave (mono)
29
+ sr = 16000
30
+ t = np.linspace(0, 1, sr, endpoint=False)
31
+ wave = 0.2 * np.sin(2 * np.pi * 440 * t)
32
+ audio = trackio.Audio(wave, caption="A4 sine", sample_rate=sr, format="wav")
33
+ trackio.log({"tone": audio})
34
+
35
+ # Stereo from numpy array (shape: samples, 2)
36
+ stereo = np.stack([wave, wave], axis=1)
37
+ audio = trackio.Audio(stereo, caption="Stereo", sample_rate=sr, format="mp3")
38
+ trackio.log({"stereo": audio})
39
+
40
+ # From an existing file
41
+ audio = trackio.Audio("path/to/audio.wav", caption="From file")
42
+ trackio.log({"file_audio": audio})
43
+ ```
44
+
45
+ Args:
46
+ value (`str`, `Path`, or `numpy.ndarray`, *optional*):
47
+ A path to an audio file, or a numpy array.
48
+ The array should be shaped `(samples,)` for mono or `(samples, 2)` for stereo.
49
+ Float arrays will be peak-normalized and converted to 16-bit PCM; integer arrays will be converted to 16-bit PCM as needed.
50
+ caption (`str`, *optional*):
51
+ A string caption for the audio.
52
+ sample_rate (`int`, *optional*):
53
+ Sample rate in Hz. Required when `value` is a numpy array.
54
+ format (`Literal["wav", "mp3"]`, *optional*):
55
+ Audio format used when `value` is a numpy array. Default is "wav".
56
+ """
57
+
58
+ TYPE = "trackio.audio"
59
+
60
+ def __init__(
61
+ self,
62
+ value: TrackioAudioSourceType,
63
+ caption: str | None = None,
64
+ sample_rate: int | None = None,
65
+ format: AudioFormatType | None = None,
66
+ ):
67
+ super().__init__(value, caption)
68
+ if isinstance(value, np.ndarray):
69
+ if sample_rate is None:
70
+ raise ValueError("Sample rate is required when value is an ndarray")
71
+ if format is None:
72
+ format = "wav"
73
+ self._format = format
74
+ self._sample_rate = sample_rate
75
+
76
+ def _save_media(self, file_path: Path):
77
+ if isinstance(self._value, np.ndarray):
78
+ TrackioAudio.write_audio(
79
+ data=self._value,
80
+ sample_rate=self._sample_rate,
81
+ filename=file_path,
82
+ format=self._format,
83
+ )
84
+ elif isinstance(self._value, str | Path):
85
+ if os.path.isfile(self._value):
86
+ shutil.copy(self._value, file_path)
87
+ else:
88
+ raise ValueError(f"File not found: {self._value}")
89
+
90
+ @staticmethod
91
+ def ensure_int16_pcm(data: np.ndarray) -> np.ndarray:
92
+ """
93
+ Convert input audio array to contiguous int16 PCM.
94
+ Peak normalization is applied to floating inputs.
95
+ """
96
+ arr = np.asarray(data)
97
+ if arr.ndim not in (1, 2):
98
+ raise ValueError("Audio data must be 1D (mono) or 2D ([samples, channels])")
99
+
100
+ if arr.dtype != np.int16:
101
+ warnings.warn(
102
+ f"Converting {arr.dtype} audio to int16 PCM; pass int16 to avoid conversion.",
103
+ stacklevel=2,
104
+ )
105
+
106
+ arr = np.nan_to_num(arr, copy=False)
107
+
108
+ # Floating types: normalize to peak 1.0, then scale to int16
109
+ if np.issubdtype(arr.dtype, np.floating):
110
+ max_abs = float(np.max(np.abs(arr))) if arr.size else 0.0
111
+ if max_abs > 0.0:
112
+ arr = arr / max_abs
113
+ out = (arr * 32767.0).clip(-32768, 32767).astype(np.int16, copy=False)
114
+ return np.ascontiguousarray(out)
115
+
116
+ converters: dict[np.dtype, callable] = {
117
+ np.dtype(np.int16): lambda a: a,
118
+ np.dtype(np.int32): lambda a: (a.astype(np.int32) // 65536).astype(
119
+ np.int16, copy=False
120
+ ),
121
+ np.dtype(np.uint16): lambda a: (a.astype(np.int32) - 32768).astype(
122
+ np.int16, copy=False
123
+ ),
124
+ np.dtype(np.uint8): lambda a: (a.astype(np.int32) * 257 - 32768).astype(
125
+ np.int16, copy=False
126
+ ),
127
+ np.dtype(np.int8): lambda a: (a.astype(np.int32) * 256).astype(
128
+ np.int16, copy=False
129
+ ),
130
+ }
131
+
132
+ conv = converters.get(arr.dtype)
133
+ if conv is not None:
134
+ out = conv(arr)
135
+ return np.ascontiguousarray(out)
136
+ raise TypeError(f"Unsupported audio dtype: {arr.dtype}")
137
+
138
+ @staticmethod
139
+ def write_audio(
140
+ data: np.ndarray,
141
+ sample_rate: int,
142
+ filename: str | Path,
143
+ format: AudioFormatType = "wav",
144
+ ) -> None:
145
+ if not isinstance(sample_rate, int) or sample_rate <= 0:
146
+ raise ValueError(f"Invalid sample_rate: {sample_rate}")
147
+ if format not in SUPPORTED_FORMATS:
148
+ raise ValueError(
149
+ f"Unsupported format: {format}. Supported: {SUPPORTED_FORMATS}"
150
+ )
151
+
152
+ check_path(filename)
153
+
154
+ pcm = TrackioAudio.ensure_int16_pcm(data)
155
+
156
+ if format != "wav":
157
+ check_ffmpeg_installed()
158
+
159
+ channels = 1 if pcm.ndim == 1 else pcm.shape[1]
160
+ pcm_bytes = pcm.tobytes()
161
+
162
+ if format == "wav":
163
+ with wave.open(str(filename), "wb") as wf:
164
+ wf.setnchannels(channels)
165
+ wf.setsampwidth(2)
166
+ wf.setframerate(sample_rate)
167
+ wf.writeframes(pcm_bytes)
168
+ else:
169
+ cmd = [
170
+ "ffmpeg",
171
+ "-y",
172
+ "-loglevel",
173
+ "error",
174
+ "-f",
175
+ "s16le",
176
+ "-ar",
177
+ str(sample_rate),
178
+ "-ac",
179
+ str(channels),
180
+ "-i",
181
+ "pipe:0",
182
+ str(filename),
183
+ ]
184
+ proc = subprocess.run(
185
+ cmd, input=pcm_bytes, capture_output=True, check=False
186
+ )
187
+ if proc.returncode != 0:
188
+ stderr = proc.stderr.decode("utf-8", errors="replace").strip()
189
+ raise RuntimeError(f"ffmpeg failed to encode {format} audio: {stderr}")
trackio/media/image.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ from PIL import Image as PILImage
7
+
8
+ from trackio.media.media import TrackioMedia
9
+
10
+ TrackioImageSourceType = str | Path | np.ndarray | PILImage.Image
11
+
12
+
13
+ class TrackioImage(TrackioMedia):
14
+ """
15
+ Initializes an Image object.
16
+
17
+ Example:
18
+ ```python
19
+ import trackio
20
+ import numpy as np
21
+ from PIL import Image
22
+
23
+ # Create an image from numpy array
24
+ image_data = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
25
+ image = trackio.Image(image_data, caption="Random image")
26
+ trackio.log({"my_image": image})
27
+
28
+ # Create an image from PIL Image
29
+ pil_image = Image.new('RGB', (100, 100), color='red')
30
+ image = trackio.Image(pil_image, caption="Red square")
31
+ trackio.log({"red_image": image})
32
+
33
+ # Create an image from file path
34
+ image = trackio.Image("path/to/image.jpg", caption="Photo from file")
35
+ trackio.log({"file_image": image})
36
+ ```
37
+
38
+ Args:
39
+ value (`str`, `Path`, `numpy.ndarray`, or `PIL.Image`, *optional*):
40
+ A path to an image, a PIL Image, or a numpy array of shape (height, width, channels).
41
+ If numpy array, should be of type `np.uint8` with RGB values in the range `[0, 255]`.
42
+ caption (`str`, *optional*):
43
+ A string caption for the image.
44
+ """
45
+
46
+ TYPE = "trackio.image"
47
+
48
+ def __init__(self, value: TrackioImageSourceType, caption: str | None = None):
49
+ super().__init__(value, caption)
50
+ self._format: str | None = None
51
+
52
+ if not isinstance(self._value, TrackioImageSourceType):
53
+ raise ValueError(
54
+ f"Invalid value type, expected {TrackioImageSourceType}, got {type(self._value)}"
55
+ )
56
+ if isinstance(self._value, np.ndarray) and self._value.dtype != np.uint8:
57
+ raise ValueError(
58
+ f"Invalid value dtype, expected np.uint8, got {self._value.dtype}"
59
+ )
60
+ if (
61
+ isinstance(self._value, np.ndarray | PILImage.Image)
62
+ and self._format is None
63
+ ):
64
+ self._format = "png"
65
+
66
+ def _as_pil(self) -> PILImage.Image | None:
67
+ try:
68
+ if isinstance(self._value, np.ndarray):
69
+ arr = np.asarray(self._value).astype("uint8")
70
+ return PILImage.fromarray(arr).convert("RGBA")
71
+ if isinstance(self._value, PILImage.Image):
72
+ return self._value.convert("RGBA")
73
+ except Exception as e:
74
+ raise ValueError(f"Failed to process image data: {self._value}") from e
75
+ return None
76
+
77
+ def _save_media(self, file_path: Path):
78
+ if pil := self._as_pil():
79
+ pil.save(file_path, format=self._format)
80
+ elif isinstance(self._value, str | Path):
81
+ if os.path.isfile(self._value):
82
+ shutil.copy(self._value, file_path)
83
+ else:
84
+ raise ValueError(f"File not found: {self._value}")
trackio/media/media.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+
6
+ from trackio.media.utils import get_project_media_path
7
+ from trackio.utils import MEDIA_DIR
8
+
9
+
10
+ class TrackioMedia(ABC):
11
+ """
12
+ Abstract base class for Trackio media objects
13
+ Provides shared functionality for file handling and serialization.
14
+ """
15
+
16
+ TYPE: str
17
+
18
+ def __init_subclass__(cls, **kwargs):
19
+ """Ensure subclasses define the TYPE attribute."""
20
+ super().__init_subclass__(**kwargs)
21
+ if not hasattr(cls, "TYPE") or cls.TYPE is None:
22
+ raise TypeError(f"Class {cls.__name__} must define TYPE attribute")
23
+
24
+ def __init__(self, value, caption: str | None = None):
25
+ """
26
+ Saves the value and caption, and if the value is a file path, checks if the file exists.
27
+ """
28
+ self.caption = caption
29
+ self._value = value
30
+ self._file_path: Path | None = None
31
+
32
+ if isinstance(self._value, str | Path):
33
+ if not os.path.isfile(self._value):
34
+ raise ValueError(f"File not found: {self._value}")
35
+
36
+ def _file_extension(self) -> str:
37
+ if self._file_path:
38
+ return self._file_path.suffix[1:].lower()
39
+ if isinstance(self._value, str | Path):
40
+ path = Path(self._value)
41
+ return path.suffix[1:].lower()
42
+ if hasattr(self, "_format") and self._format:
43
+ return self._format
44
+ return "unknown"
45
+
46
+ def _get_relative_file_path(self) -> Path | None:
47
+ return self._file_path
48
+
49
+ def _get_absolute_file_path(self) -> Path | None:
50
+ if self._file_path:
51
+ return MEDIA_DIR / self._file_path
52
+ return None
53
+
54
+ def _save(self, project: str, run: str, step: int = 0):
55
+ if self._file_path:
56
+ return
57
+
58
+ media_dir = get_project_media_path(project=project, run=run, step=step)
59
+ filename = f"{uuid.uuid4()}.{self._file_extension()}"
60
+ file_path = media_dir / filename
61
+
62
+ self._save_media(file_path)
63
+ self._file_path = file_path.relative_to(MEDIA_DIR)
64
+
65
+ @abstractmethod
66
+ def _save_media(self, file_path: Path):
67
+ """
68
+ Performs the actual media saving logic.
69
+ """
70
+ pass
71
+
72
+ def _to_dict(self) -> dict:
73
+ if not self._file_path:
74
+ raise ValueError("Media must be saved to file before serialization")
75
+ return {
76
+ "_type": self.TYPE,
77
+ "file_path": str(self._get_relative_file_path()),
78
+ "caption": self.caption,
79
+ }
trackio/media/utils.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+ from trackio.utils import MEDIA_DIR
5
+
6
+
7
+ def check_path(file_path: str | Path) -> None:
8
+ """Raise an error if the parent directory does not exist."""
9
+ file_path = Path(file_path)
10
+ if not file_path.parent.exists():
11
+ try:
12
+ file_path.parent.mkdir(parents=True, exist_ok=True)
13
+ except OSError as e:
14
+ raise ValueError(
15
+ f"Failed to create parent directory {file_path.parent}: {e}"
16
+ )
17
+
18
+
19
+ def check_ffmpeg_installed() -> None:
20
+ """Raise an error if ffmpeg is not available on the system PATH."""
21
+ if shutil.which("ffmpeg") is None:
22
+ raise RuntimeError(
23
+ "ffmpeg is required to write this media format but was not found on your system. "
24
+ "Please install ffmpeg and ensure it is available on your PATH."
25
+ )
26
+
27
+
28
+ def get_project_media_path(
29
+ project: str,
30
+ run: str | None = None,
31
+ step: int | None = None,
32
+ relative_path: str | Path | None = None,
33
+ ) -> Path:
34
+ """
35
+ Get the full path where uploaded files are stored for a Trackio project (and create the directory if it doesn't exist).
36
+ If a run is not provided, the files are stored in a project-level directory with the given relative path.
37
+
38
+ Args:
39
+ project: The project name
40
+ run: The run name
41
+ step: The step number
42
+ relative_path: The relative path within the directory (only used if run is not provided)
43
+
44
+ Returns:
45
+ The full path to the media file
46
+ """
47
+ if step is not None and run is None:
48
+ raise ValueError("Uploading files at a specific step requires a run")
49
+
50
+ path = MEDIA_DIR / project
51
+ if run:
52
+ path /= run
53
+ if step is not None:
54
+ path /= str(step)
55
+ else:
56
+ path /= "files"
57
+ if relative_path:
58
+ path /= relative_path
59
+ path.mkdir(parents=True, exist_ok=True)
60
+ return path