GitHub CI commited on
Commit
fc9bd9f
·
0 Parent(s):

Sync from leLab @ 7317f7103e3a9d7f45fe4c0d6e4660a8f9d295e3

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 +40 -0
  2. .gitignore +48 -0
  3. Dockerfile +35 -0
  4. HTTPS_SETUP.md +219 -0
  5. LICENSE +202 -0
  6. README.md +20 -0
  7. __init__.py +0 -0
  8. components.json +20 -0
  9. eslint.config.js +29 -0
  10. index.html +19 -0
  11. package-lock.json +0 -0
  12. package.json +88 -0
  13. postcss.config.js +6 -0
  14. public/favicon.ico +0 -0
  15. public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png +0 -0
  16. public/placeholder.svg +1 -0
  17. public/robots.txt +14 -0
  18. public/so-101-urdf/CMakeLists.txt +37 -0
  19. public/so-101-urdf/README.md +45 -0
  20. public/so-101-urdf/config/joint_names_so_arm_urdf.yaml +1 -0
  21. public/so-101-urdf/joints_properties.xml +12 -0
  22. public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl +3 -0
  23. public/so-101-urdf/meshes/base_so101_v2.stl +3 -0
  24. public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl +3 -0
  25. public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl +3 -0
  26. public/so-101-urdf/meshes/moving_jaw_so101_v1.stl +3 -0
  27. public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl +3 -0
  28. public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl +3 -0
  29. public/so-101-urdf/meshes/sts3215_03a_v1.stl +3 -0
  30. public/so-101-urdf/meshes/under_arm_so101_v1.stl +3 -0
  31. public/so-101-urdf/meshes/upper_arm_so101_v1.stl +3 -0
  32. public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl +3 -0
  33. public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl +3 -0
  34. public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl +3 -0
  35. public/so-101-urdf/package.xml +26 -0
  36. public/so-101-urdf/urdf/so101_new_calib.urdf +435 -0
  37. src/.gitignore +1 -0
  38. src/App.tsx +61 -0
  39. src/components/Footer.tsx +68 -0
  40. src/components/Logo.tsx +19 -0
  41. src/components/SingleTabGuard.tsx +136 -0
  42. src/components/UrdfViewer.tsx +304 -0
  43. src/components/control/CommandBar.tsx +81 -0
  44. src/components/control/MetricsPanel.tsx +190 -0
  45. src/components/control/VisualizerPanel.tsx +46 -0
  46. src/components/jobs/CheckpointDropdown.tsx +54 -0
  47. src/components/jobs/HubJobCard.tsx +106 -0
  48. src/components/jobs/HubModelCard.tsx +75 -0
  49. src/components/jobs/JobCard.tsx +199 -0
  50. src/components/jobs/JobsSection.tsx +379 -0
.gitattributes ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz 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
+ *.stl filter=lfs diff=lfs merge=lfs -text
37
+ *.lockb filter=lfs diff=lfs merge=lfs -text
38
+
39
+ # Build output: store as plain binary (overrides LFS) so the wheel can ship real files
40
+ dist/** -filter -diff -merge
.gitignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ /node_modules
3
+ /.pnp
4
+ .pnp.js
5
+
6
+ # Testing
7
+ /coverage
8
+
9
+ # Production
10
+ /build
11
+
12
+ # Misc
13
+ .DS_Store
14
+ .env
15
+ .env.local
16
+ .env.development.local
17
+ .env.test.local
18
+ .env.production.local
19
+ .env*.local
20
+
21
+ # Debug logs
22
+ npm-debug.log*
23
+ yarn-debug.log*
24
+ yarn-error.log*
25
+
26
+ # IDE
27
+ .idea/
28
+ .vscode/
29
+ *.swp
30
+ *.swo
31
+
32
+ # TypeScript
33
+ *.tsbuildinfo
34
+
35
+ # Optional npm cache directory
36
+ .npm
37
+
38
+ # Optional eslint cache
39
+ .eslintcache
40
+
41
+ # Optional REPL history
42
+ .node_repl_history
43
+
44
+ # Output of 'npm pack'
45
+ *.tgz
46
+
47
+ # Yarn Integrity file
48
+ .yarn-integrity
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine
2
+
3
+ # Use the existing node user (usually UID 1000)
4
+ # Set up environment variables for the node user
5
+ ENV HOME=/home/node \
6
+ PATH=/home/node/.local/bin:$PATH
7
+
8
+ # Create and set up app directory owned by node user
9
+ # Go to user's home directory first to ensure it exists
10
+ WORKDIR $HOME
11
+ RUN mkdir -p $HOME/app && \
12
+ chown -R node:node $HOME/app && \
13
+ chmod -R 755 $HOME/app # Set initial permissions
14
+ WORKDIR $HOME/app
15
+
16
+ # Switch to the node user
17
+ USER node
18
+
19
+ # Copy package files (owned by node)
20
+ COPY --chown=node:node package*.json ./
21
+
22
+ # Install dependencies
23
+ RUN npm install
24
+
25
+ # Copy the entire viewer directory (owned by node)
26
+ COPY --chown=node:node . .
27
+
28
+ # Build the application
29
+ RUN npm run build
30
+
31
+ # Expose port
32
+ EXPOSE 7860
33
+
34
+ # Start the application
35
+ CMD ["npm", "run", "preview", "--", "--port", "7860", "--host"]
HTTPS_SETUP.md ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HTTPS Setup for Local Development
2
+
3
+ This guide explains how to enable HTTPS for the frontend development server to ensure camera access works properly from mobile devices.
4
+
5
+ ## Why HTTPS is Required
6
+
7
+ Modern browsers require HTTPS for accessing sensitive APIs like `getUserMedia()` (camera access), especially when:
8
+
9
+ - Accessing from a different device (e.g., phone connecting to laptop's dev server)
10
+ - The origin is not `localhost` or `127.0.0.1`
11
+
12
+ Without HTTPS, camera access will be blocked by the browser's security policies.
13
+
14
+ ## Automatic Setup
15
+
16
+ We've already set up HTTPS for you! The certificates are generated and the development server is configured to use them.
17
+
18
+ ### Quick Start
19
+
20
+ 1. **Start the development server:**
21
+
22
+ ```bash
23
+ npm run dev
24
+ ```
25
+
26
+ 2. **Access from your computer:**
27
+
28
+ - `https://localhost:8080`
29
+ - `https://127.0.0.1:8080`
30
+
31
+ 3. **Access from your phone:**
32
+ - `https://192.168.1.103:8080` (or your current local IP)
33
+
34
+ ## Manual Setup (if needed)
35
+
36
+ If you need to regenerate certificates or set up on a new machine:
37
+
38
+ ```bash
39
+ # Run our setup script
40
+ ./scripts/setup-https.sh
41
+ ```
42
+
43
+ Or manually:
44
+
45
+ ```bash
46
+ # Install mkcert
47
+ brew install mkcert # macOS
48
+ # For other platforms: https://github.com/FiloSottile/mkcert#installation
49
+
50
+ # Install local CA
51
+ mkcert -install
52
+
53
+ # Generate certificates
54
+ mkdir -p certs
55
+ cd certs
56
+ mkcert localhost 127.0.0.1 $(ipconfig getifaddr en0) ::1
57
+ cd ..
58
+
59
+ # Start development server
60
+ npm run dev
61
+ ```
62
+
63
+ ## Certificate Details
64
+
65
+ - **Location:** `./certs/`
66
+ - **Files:**
67
+ - `localhost+3.pem` (certificate)
68
+ - `localhost+3-key.pem` (private key)
69
+ - **Validity:** 3 months
70
+ - **Domains:** localhost, 127.0.0.1, your local IP, IPv6 localhost
71
+
72
+ ## Mobile Device Setup
73
+
74
+ ### For Phone Access:
75
+
76
+ 1. **Ensure same WiFi network:** Your phone and development machine must be on the same WiFi network
77
+
78
+ 2. **Find your local IP:**
79
+
80
+ ```bash
81
+ ipconfig getifaddr en0 # macOS
82
+ # or check the console output when starting the dev server
83
+ ```
84
+
85
+ 3. **Access from phone:**
86
+
87
+ - Open Safari (iOS) or Chrome (Android)
88
+ - Navigate to `https://YOUR_LOCAL_IP:8080`
89
+ - You may see a security warning - this is normal for local certificates
90
+
91
+ 4. **Accept the certificate:**
92
+
93
+ - **iOS Safari:** Tap "Advanced" → "Proceed to website"
94
+ - **Android Chrome:** Tap "Advanced" → "Proceed to site (unsafe)"
95
+
96
+ 5. **Test camera access:**
97
+ - Go to the recording page OR use our camera test page: `https://YOUR_LOCAL_IP:8080/test-camera.html`
98
+ - Try to add a camera or click "Start Camera"
99
+ - The browser should prompt for camera permission
100
+ - Grant permission to test `getUserMedia()` functionality
101
+
102
+ ## Troubleshooting
103
+
104
+ ### Certificate Issues
105
+
106
+ If you see certificate errors:
107
+
108
+ ```bash
109
+ # Regenerate certificates
110
+ rm -rf certs/
111
+ ./scripts/setup-https.sh
112
+ ```
113
+
114
+ ### IP Address Changes
115
+
116
+ If your local IP changes (common with DHCP):
117
+
118
+ ```bash
119
+ # Check current IP
120
+ ipconfig getifaddr en0
121
+
122
+ # Regenerate certificates with new IP
123
+ rm -rf certs/
124
+ ./scripts/setup-https.sh
125
+ ```
126
+
127
+ ### Port Already in Use
128
+
129
+ If port 8080 is busy:
130
+
131
+ ```bash
132
+ # Check what's using the port
133
+ lsof -ti:8080
134
+
135
+ # Kill the process if needed
136
+ kill -9 $(lsof -ti:8080)
137
+ ```
138
+
139
+ ### Browser Cache Issues
140
+
141
+ If you're having issues after certificate changes:
142
+
143
+ 1. Clear browser cache and cookies for localhost
144
+ 2. Restart the browser
145
+ 3. Try incognito/private mode
146
+
147
+ ## Network Address Detection
148
+
149
+ The app automatically detects the appropriate URL for phone access:
150
+
151
+ - **Localhost access:** Automatically converts to network IP for QR codes
152
+ - **Network access:** Uses current URL as-is
153
+ - **Domain access:** Works with existing SSL certificates
154
+
155
+ You can see the detected address in:
156
+
157
+ - Browser console logs
158
+ - Camera configuration UI (blue info box)
159
+
160
+ ## Security Notes
161
+
162
+ - These certificates are only trusted on your local machine
163
+ - They're perfect for development but should never be used in production
164
+ - The private key is stored locally and should not be shared
165
+ - Certificates expire after 3 months for security
166
+
167
+ ## Production Deployment
168
+
169
+ For production:
170
+
171
+ - Use a proper SSL certificate from a trusted CA
172
+ - Configure your reverse proxy (nginx, Apache) or hosting platform
173
+ - Ensure HTTPS is enforced across your entire application
174
+
175
+ ## Verification
176
+
177
+ To verify HTTPS is working:
178
+
179
+ 1. **Check the URL bar:** Should show `https://` with a lock icon
180
+ 2. **Check console:** Network address detection should show HTTPS URLs
181
+ 3. **Test camera access:** `getUserMedia()` should work without security errors
182
+ 4. **Check from phone:** Camera permissions should be available
183
+
184
+ ### Quick Test Page
185
+
186
+ We've included a dedicated camera test page for easy verification:
187
+
188
+ **Desktop:** `https://localhost:8080/test-camera.html`
189
+ **Phone:** `https://192.168.1.103:8080/test-camera.html`
190
+
191
+ This page will:
192
+
193
+ - ✅ Verify HTTPS and secure context
194
+ - ✅ Test camera permissions and `getUserMedia()`
195
+ - ✅ Display real-time video stream
196
+ - ✅ Show detailed connection information
197
+ - ✅ Provide troubleshooting guidance
198
+
199
+ ### Expected Results
200
+
201
+ **✅ Success indicators:**
202
+
203
+ - Green protocol check: "Camera access should work!"
204
+ - Camera permission prompt appears
205
+ - Video stream displays without errors
206
+ - No console security warnings
207
+
208
+ **❌ Failure indicators:**
209
+
210
+ - Red protocol check: "Camera access may be blocked"
211
+ - `NotAllowedError` or `NotSecureError` in console
212
+ - No camera permission prompt
213
+ - "This site is not secure" warnings
214
+
215
+ ## Additional Resources
216
+
217
+ - [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert)
218
+ - [MDN: getUserMedia()](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)
219
+ - [Chrome Developer: HTTPS for Localhost](https://web.dev/how-to-use-local-https/)
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LeLab
3
+ emoji: ⚡
4
+ colorFrom: yellow
5
+ colorTo: red
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ short_description: Simple Interface to use LeRobot
10
+ ---
11
+
12
+ <img width="1283" alt="Screenshot 2025-06-19 at 00 23 55" src="https://github.com/user-attachments/assets/ce1b5888-d24b-4adf-a422-694d4b0aa751" />
13
+
14
+ # LeLab official Space repository
15
+
16
+ If you've used [LeLab](https://huggingface.co/spaces/lerobot/LeLab) and want to contribute or found a bug, this is the place to be. This repo is directly hooked up to the Hugging Face space.
17
+
18
+ Here's the equivalent [backend](https://github.com/huggingface/leLab) that keeps the FastAPI server you run to actually wrap the LeRobot library.
19
+
20
+ We'll be updating this README shortly.
__init__.py ADDED
File without changes
components.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "src/index.css",
9
+ "baseColor": "slate",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ }
20
+ }
eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import reactHooks from "eslint-plugin-react-hooks";
4
+ import reactRefresh from "eslint-plugin-react-refresh";
5
+ import tseslint from "typescript-eslint";
6
+
7
+ export default tseslint.config(
8
+ { ignores: ["dist"] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ["**/*.{ts,tsx}"],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ "react-hooks": reactHooks,
18
+ "react-refresh": reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ "react-refresh/only-export-components": [
23
+ "warn",
24
+ { allowConstantExport: true },
25
+ ],
26
+ "@typescript-eslint/no-unused-vars": "off",
27
+ },
28
+ }
29
+ );
index.html ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>LeLab</title>
7
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
8
+ <meta name="description" content="LeRobot but on your browser." />
9
+
10
+ <meta property="og:title" content="LeLab" />
11
+ <meta property="og:description" content="LeRobot but on your browser." />
12
+ <meta property="og:type" content="website" />
13
+ </head>
14
+
15
+ <body>
16
+ <div id="root"></div>
17
+ <script type="module" src="/src/main.tsx"></script>
18
+ </body>
19
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vite_react_shadcn_ts",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "build:dev": "vite build --mode development",
10
+ "lint": "eslint .",
11
+ "preview": "vite preview"
12
+ },
13
+ "dependencies": {
14
+ "@hookform/resolvers": "^3.9.0",
15
+ "@radix-ui/react-accordion": "^1.2.0",
16
+ "@radix-ui/react-alert-dialog": "^1.1.1",
17
+ "@radix-ui/react-aspect-ratio": "^1.1.0",
18
+ "@radix-ui/react-avatar": "^1.1.0",
19
+ "@radix-ui/react-checkbox": "^1.1.1",
20
+ "@radix-ui/react-collapsible": "^1.1.0",
21
+ "@radix-ui/react-context-menu": "^2.2.1",
22
+ "@radix-ui/react-dialog": "^1.1.2",
23
+ "@radix-ui/react-dropdown-menu": "^2.1.1",
24
+ "@radix-ui/react-hover-card": "^1.1.1",
25
+ "@radix-ui/react-label": "^2.1.0",
26
+ "@radix-ui/react-menubar": "^1.1.1",
27
+ "@radix-ui/react-navigation-menu": "^1.2.0",
28
+ "@radix-ui/react-popover": "^1.1.1",
29
+ "@radix-ui/react-progress": "^1.1.0",
30
+ "@radix-ui/react-radio-group": "^1.2.0",
31
+ "@radix-ui/react-scroll-area": "^1.1.0",
32
+ "@radix-ui/react-select": "^2.1.1",
33
+ "@radix-ui/react-separator": "^1.1.0",
34
+ "@radix-ui/react-slider": "^1.2.0",
35
+ "@radix-ui/react-slot": "^1.1.0",
36
+ "@radix-ui/react-switch": "^1.1.0",
37
+ "@radix-ui/react-tabs": "^1.1.0",
38
+ "@radix-ui/react-toast": "^1.2.1",
39
+ "@radix-ui/react-toggle": "^1.1.0",
40
+ "@radix-ui/react-toggle-group": "^1.1.0",
41
+ "@radix-ui/react-tooltip": "^1.1.4",
42
+ "@react-three/drei": "^9.122.0",
43
+ "@react-three/fiber": "^8.18.0",
44
+ "@tanstack/react-query": "^5.56.2",
45
+ "class-variance-authority": "^0.7.1",
46
+ "clsx": "^2.1.1",
47
+ "cmdk": "^1.0.0",
48
+ "date-fns": "^3.6.0",
49
+ "embla-carousel-react": "^8.3.0",
50
+ "input-otp": "^1.2.4",
51
+ "jszip": "^3.10.1",
52
+ "lucide-react": "^0.462.0",
53
+ "next-themes": "^0.3.0",
54
+ "react": "^18.3.1",
55
+ "react-day-picker": "^8.10.1",
56
+ "react-dom": "^18.3.1",
57
+ "react-hook-form": "^7.53.0",
58
+ "react-resizable-panels": "^2.1.3",
59
+ "react-router-dom": "^6.26.2",
60
+ "recharts": "^2.12.7",
61
+ "sonner": "^1.5.0",
62
+ "tailwind-merge": "^2.5.2",
63
+ "tailwindcss-animate": "^1.0.7",
64
+ "three": "^0.177.0",
65
+ "urdf-loader": "^0.12.6",
66
+ "vaul": "^0.9.3",
67
+ "zod": "^3.23.8"
68
+ },
69
+ "devDependencies": {
70
+ "@eslint/js": "^9.9.0",
71
+ "@tailwindcss/typography": "^0.5.15",
72
+ "@types/node": "^22.5.5",
73
+ "@types/react": "^18.3.3",
74
+ "@types/react-dom": "^18.3.0",
75
+ "@vitejs/plugin-react-swc": "^3.5.0",
76
+ "autoprefixer": "^10.4.20",
77
+ "eslint": "^9.9.0",
78
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
79
+ "eslint-plugin-react-refresh": "^0.4.9",
80
+ "globals": "^15.9.0",
81
+ "lovable-tagger": "^1.1.7",
82
+ "postcss": "^8.4.47",
83
+ "tailwindcss": "^3.4.11",
84
+ "typescript": "^5.5.3",
85
+ "typescript-eslint": "^8.0.1",
86
+ "vite": "^5.4.1"
87
+ }
88
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
public/favicon.ico ADDED
public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png ADDED
public/placeholder.svg ADDED
public/robots.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ User-agent: Googlebot
2
+ Allow: /
3
+
4
+ User-agent: Bingbot
5
+ Allow: /
6
+
7
+ User-agent: Twitterbot
8
+ Allow: /
9
+
10
+ User-agent: facebookexternalhit
11
+ Allow: /
12
+
13
+ User-agent: *
14
+ Allow: /
public/so-101-urdf/CMakeLists.txt ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cmake_minimum_required(VERSION 3.10.2)
2
+
3
+ project(so_arm_description)
4
+
5
+ find_package(ament_cmake REQUIRED)
6
+ find_package(urdf REQUIRED)
7
+
8
+ # Install the mesh files from SO101/assets
9
+ install(
10
+ DIRECTORY
11
+ SO101/assets/
12
+ DESTINATION
13
+ share/${PROJECT_NAME}/meshes
14
+ FILES_MATCHING PATTERN "*.stl"
15
+ )
16
+
17
+ # Install URDF files
18
+ install(
19
+ DIRECTORY
20
+ urdf/
21
+ DESTINATION
22
+ share/${PROJECT_NAME}/urdf
23
+ FILES_MATCHING PATTERN "*.urdf"
24
+ )
25
+
26
+ # Install other directories
27
+ install(
28
+ DIRECTORY
29
+ meshes
30
+ config
31
+ launch
32
+ DESTINATION
33
+ share/${PROJECT_NAME}
34
+ OPTIONAL
35
+ )
36
+
37
+ ament_package()
public/so-101-urdf/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SO-ARM ROS2 URDF Package
2
+
3
+ A complete ROS2 package for the SO-ARM101 robotic arm with URDF description.
4
+
5
+ ## 📋 Overview
6
+
7
+ This package provides a complete ROS2 implementation for the SO-ARM101 robotic arm, including:
8
+
9
+ - URDF robot description with visual and collision meshes
10
+ - RViz visualization with pre-configured displays
11
+ - Launch files for easy robot visualization
12
+ - Integration with MoveIt for motion planning
13
+ - Joint state publishers for interactive control
14
+
15
+ ## 🎯 Original Source
16
+
17
+ https://github.com/TheRobotStudio/SO-ARM100/tree/main/Simulation/SO101
18
+
19
+ ## 🚀 Key Improvements Made
20
+
21
+ ### 1. **Complete ROS2 Package Structure**
22
+
23
+ - ✅ Proper `package.xml` with all necessary dependencies
24
+ - ✅ CMakeLists.txt for ROS2 build system
25
+ - ✅ Organized directory structure following ROS2 conventions
26
+
27
+ ### 2. **Enhanced Visualization**
28
+
29
+ - ✅ Fixed mesh file paths for proper package integration
30
+
31
+ ### Build Instructions
32
+
33
+ 1. Clone this repository into your ROS2 workspace:
34
+
35
+ ```bash
36
+ cd ~/your_ros2_ws/src
37
+ git clone <your-repo-url> so_arm_description
38
+ ```
39
+
40
+ 2. Build the package:
41
+ ```bash
42
+ cd ~/your_ros2_ws
43
+ colcon build --packages-select so_arm_description
44
+ source install/setup.bash
45
+ ```
public/so-101-urdf/config/joint_names_so_arm_urdf.yaml ADDED
@@ -0,0 +1 @@
 
 
1
+ controller_joint_names: ['', 'Rotation', 'Pitch', 'Elbow', 'Wrist_Pitch', 'Wrist_Roll', 'Jaw', ]
public/so-101-urdf/joints_properties.xml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <default>
2
+ <default class="sts3215">
3
+ <geom contype="0" conaffinity="0"/>
4
+ <joint damping="0.60" frictionloss="0.052" armature="0.028"/>
5
+ <position kp="17.8" kv="0.0" forcerange="-3.35 3.35"/>
6
+ </default>
7
+ <default class="backlash">
8
+ <!-- +/- 0.5° of backlash -->
9
+ <joint damping="0.01" frictionloss="0" armature="0.01" limited="true"
10
+ range="-0.008726646259971648 0.008726646259971648"/>
11
+ </default>
12
+ </default>
public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8cd2f241037ea377af1191fffe0dd9d9006beea6dcc48543660ed41647072424
3
+ size 1877084
public/so-101-urdf/meshes/base_so101_v2.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bb12b7026575e1f70ccc7240051f9d943553bf34e5128537de6cd86fae33924d
3
+ size 471584
public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:31242ae6fb59d8b15c66617b88ad8e9bded62d57c35d11c0c43a70d2f4caa95b
3
+ size 1129384
public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:887f92e6013cb64ea3a1ab8675e92da1e0beacfd5e001f972523540545e08011
3
+ size 1052184
public/so-101-urdf/meshes/moving_jaw_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:785a9dded2f474bc1d869e0d3dae398a3dcd9c0c345640040472210d2861fa9d
3
+ size 1413584
public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9be900cc2a2bf718102841ef82ef8d2873842427648092c8ed2ca1e2ef4ffa34
3
+ size 883684
public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:75ef3781b752e4065891aea855e34dc161a38a549549cd0970cedd07eae6f887
3
+ size 865884
public/so-101-urdf/meshes/sts3215_03a_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a37c871fb502483ab96c256baf457d36f2e97afc9205313d9c5ab275ef941cd0
3
+ size 954084
public/so-101-urdf/meshes/under_arm_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d01d1f2de365651dcad9d6669e94ff87ff7652b5bb2d10752a66a456a86dbc71
3
+ size 1975884
public/so-101-urdf/meshes/upper_arm_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:475056e03a17e71919b82fd88ab9a0b898ab50164f2a7943652a6b2941bb2d4f
3
+ size 1303484
public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e197e24005a07d01bbc06a8c42311664eaeda415bf859f68fa247884d0f1a6e9
3
+ size 62784
public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b17b410a12d64ec39554abc3e8054d8a97384b2dc4a8d95a5ecb2a93670f5f4
3
+ size 1439884
public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c7ec5525b4d8b9e397a30ab4bb0037156a5d5f38a4adf2c7d943d6c56eda5ae
3
+ size 2699784
public/so-101-urdf/package.xml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0"?>
2
+ <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3
+ <package format="3">
4
+ <name>so_arm_description</name>
5
+ <version>1.0.0</version>
6
+ <description>SO-ARM101 URDF Resources</description>
7
+
8
+ <author email="contact@lycheeai-hub.com">LycheeAI</author>
9
+
10
+ <maintainer email="contact@lycheeai-hub.com">LycheeAI</maintainer>
11
+
12
+ <license>BSD</license>
13
+
14
+ <buildtool_depend>ament_cmake</buildtool_depend>
15
+
16
+ <depend>urdf</depend>
17
+ <exec_depend>robot_state_publisher</exec_depend>
18
+ <exec_depend>joint_state_publisher</exec_depend>
19
+ <exec_depend>joint_state_publisher_gui</exec_depend>
20
+ <exec_depend>rviz2</exec_depend>
21
+ <exec_depend>xacro</exec_depend>
22
+
23
+ <export>
24
+ <build_type>ament_cmake</build_type>
25
+ </export>
26
+ </package>
public/so-101-urdf/urdf/so101_new_calib.urdf ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version='1.0' encoding='utf-8'?>
2
+ <!-- Generated using onshape-to-robot -->
3
+ <!-- Onshape https://cad.onshape.com/documents/7715cc284bb430fe6dab4ffd/w/4fd0791b683777b02f8d975a/e/826c553ede3b7592eb9ca800 -->
4
+ <robot name="so101_new_calib">
5
+
6
+ <!-- Materials -->
7
+ <material name="3d_printed">
8
+ <color rgba="1.0 0.82 0.12 1.0"/>
9
+ </material>
10
+ <material name="sts3215">
11
+ <color rgba="0.1 0.1 0.1 1.0"/>
12
+ </material>
13
+
14
+ <!-- Link base -->
15
+ <link name="base">
16
+ <inertial>
17
+ <origin xyz="0.020739 0.00204287 0.065966" rpy="0 0 0"/>
18
+ <mass value="0.147"/>
19
+ <inertia ixx="0.000136117" ixy="4.59787e-07" ixz="9.75275e-08" iyy="0.000114686" iyz="-4.97151e-06" izz="0.000130364"/>
20
+ </inertial>
21
+ <!-- Part base_motor_holder_so101_v1 -->
22
+ <visual>
23
+ <origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/>
24
+ <geometry>
25
+ <mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/>
26
+ </geometry>
27
+ <material name="3d_printed"/>
28
+ </visual>
29
+ <collision>
30
+ <origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/>
31
+ <geometry>
32
+ <mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/>
33
+ </geometry>
34
+ </collision>
35
+ <!-- Part base_so101_v2 -->
36
+ <visual>
37
+ <origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/>
38
+ <geometry>
39
+ <mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/>
40
+ </geometry>
41
+ <material name="3d_printed"/>
42
+ </visual>
43
+ <collision>
44
+ <origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/>
45
+ <geometry>
46
+ <mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/>
47
+ </geometry>
48
+ </collision>
49
+ <!-- Part sts3215_03a_v1 -->
50
+ <visual>
51
+ <origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/>
52
+ <geometry>
53
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
54
+ </geometry>
55
+ <material name="sts3215"/>
56
+ </visual>
57
+ <collision>
58
+ <origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/>
59
+ <geometry>
60
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
61
+ </geometry>
62
+ </collision>
63
+ <!-- Part waveshare_mounting_plate_so101_v2 -->
64
+ <visual>
65
+ <origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/>
66
+ <geometry>
67
+ <mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/>
68
+ </geometry>
69
+ <material name="3d_printed"/>
70
+ </visual>
71
+ <collision>
72
+ <origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/>
73
+ <geometry>
74
+ <mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/>
75
+ </geometry>
76
+ </collision>
77
+ </link>
78
+
79
+ <!-- Link shoulder -->
80
+ <link name="shoulder">
81
+ <inertial>
82
+ <origin xyz="-0.0307604 -1.66727e-05 -0.0252713" rpy="0 0 0"/>
83
+ <mass value="0.100006"/>
84
+ <inertia ixx="8.3759e-05" ixy="7.55525e-08" ixz="-1.16342e-06" iyy="8.10403e-05" iyz="1.54663e-07" izz="2.39783e-05"/>
85
+ </inertial>
86
+ <!-- Part sts3215_03a_v1_2 -->
87
+ <visual>
88
+ <origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/>
89
+ <geometry>
90
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
91
+ </geometry>
92
+ <material name="sts3215"/>
93
+ </visual>
94
+ <collision>
95
+ <origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/>
96
+ <geometry>
97
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
98
+ </geometry>
99
+ </collision>
100
+ <!-- Part motor_holder_so101_base_v1 -->
101
+ <visual>
102
+ <origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/>
103
+ <geometry>
104
+ <mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/>
105
+ </geometry>
106
+ <material name="3d_printed"/>
107
+ </visual>
108
+ <collision>
109
+ <origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/>
110
+ <geometry>
111
+ <mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/>
112
+ </geometry>
113
+ </collision>
114
+ <!-- Part rotation_pitch_so101_v1 -->
115
+ <visual>
116
+ <origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/>
117
+ <geometry>
118
+ <mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/>
119
+ </geometry>
120
+ <material name="3d_printed"/>
121
+ </visual>
122
+ <collision>
123
+ <origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/>
124
+ <geometry>
125
+ <mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/>
126
+ </geometry>
127
+ </collision>
128
+ </link>
129
+
130
+ <!-- Link upper_arm -->
131
+ <link name="upper_arm">
132
+ <inertial>
133
+ <origin xyz="-0.0898471 -0.00838224 0.0184089" rpy="0 0 0"/>
134
+ <mass value="0.103"/>
135
+ <inertia ixx="4.08002e-05" ixy="-1.97819e-05" ixz="-4.03016e-08" iyy="0.000147318" iyz="8.97326e-09" izz="0.000142487"/>
136
+ </inertial>
137
+ <!-- Part sts3215_03a_v1_3 -->
138
+ <visual>
139
+ <origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/>
140
+ <geometry>
141
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
142
+ </geometry>
143
+ <material name="sts3215"/>
144
+ </visual>
145
+ <collision>
146
+ <origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/>
147
+ <geometry>
148
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
149
+ </geometry>
150
+ </collision>
151
+ <!-- Part upper_arm_so101_v1 -->
152
+ <visual>
153
+ <origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/>
154
+ <geometry>
155
+ <mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/>
156
+ </geometry>
157
+ <material name="3d_printed"/>
158
+ </visual>
159
+ <collision>
160
+ <origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/>
161
+ <geometry>
162
+ <mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/>
163
+ </geometry>
164
+ </collision>
165
+ </link>
166
+
167
+ <!-- Link lower_arm -->
168
+ <link name="lower_arm">
169
+ <inertial>
170
+ <origin xyz="-0.0980701 0.00324376 0.0182831" rpy="0 0 0"/>
171
+ <mass value="0.104"/>
172
+ <inertia ixx="2.87438e-05" ixy="7.41152e-06" ixz="1.26409e-06" iyy="0.000159844" iyz="-4.90188e-08" izz="0.00014529"/>
173
+ </inertial>
174
+ <!-- Part under_arm_so101_v1 -->
175
+ <visual>
176
+ <origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/>
177
+ <geometry>
178
+ <mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/>
179
+ </geometry>
180
+ <material name="3d_printed"/>
181
+ </visual>
182
+ <collision>
183
+ <origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/>
184
+ <geometry>
185
+ <mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/>
186
+ </geometry>
187
+ </collision>
188
+ <!-- Part motor_holder_so101_wrist_v1 -->
189
+ <visual>
190
+ <origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/>
191
+ <geometry>
192
+ <mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/>
193
+ </geometry>
194
+ <material name="3d_printed"/>
195
+ </visual>
196
+ <collision>
197
+ <origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/>
198
+ <geometry>
199
+ <mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/>
200
+ </geometry>
201
+ </collision>
202
+ <!-- Part sts3215_03a_v1_4 -->
203
+ <visual>
204
+ <origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/>
205
+ <geometry>
206
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
207
+ </geometry>
208
+ <material name="sts3215"/>
209
+ </visual>
210
+ <collision>
211
+ <origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/>
212
+ <geometry>
213
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
214
+ </geometry>
215
+ </collision>
216
+ </link>
217
+
218
+ <!-- Link wrist -->
219
+ <link name="wrist">
220
+ <inertial>
221
+ <origin xyz="-0.000103312 -0.0386143 0.0281156" rpy="0 0 0"/>
222
+ <mass value="0.079"/>
223
+ <inertia ixx="3.68263e-05" ixy="1.7893e-08" ixz="-5.28128e-08" iyy="2.5391e-05" iyz="3.6412e-06" izz="2.1e-05"/>
224
+ </inertial>
225
+ <!-- Part sts3215_03a_no_horn_v1 -->
226
+ <visual>
227
+ <origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/>
228
+ <geometry>
229
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/>
230
+ </geometry>
231
+ <material name="sts3215"/>
232
+ </visual>
233
+ <collision>
234
+ <origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/>
235
+ <geometry>
236
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/>
237
+ </geometry>
238
+ </collision>
239
+ <!-- Part wrist_roll_pitch_so101_v2 -->
240
+ <visual>
241
+ <origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/>
242
+ <geometry>
243
+ <mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/>
244
+ </geometry>
245
+ <material name="3d_printed"/>
246
+ </visual>
247
+ <collision>
248
+ <origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/>
249
+ <geometry>
250
+ <mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/>
251
+ </geometry>
252
+ </collision>
253
+ </link>
254
+
255
+ <!-- Link gripper -->
256
+ <link name="gripper">
257
+ <inertial>
258
+ <origin xyz="0.000213627 0.000245138 -0.025187" rpy="0 0 0"/>
259
+ <mass value="0.087"/>
260
+ <inertia ixx="2.75087e-05" ixy="-3.35241e-07" ixz="-5.7352e-06" iyy="4.33657e-05" iyz="-5.17847e-08" izz="3.45059e-05"/>
261
+ </inertial>
262
+ <!-- Part sts3215_03a_v1_5 -->
263
+ <visual>
264
+ <origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/>
265
+ <geometry>
266
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
267
+ </geometry>
268
+ <material name="sts3215"/>
269
+ </visual>
270
+ <collision>
271
+ <origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/>
272
+ <geometry>
273
+ <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/>
274
+ </geometry>
275
+ </collision>
276
+ <!-- Part wrist_roll_follower_so101_v1 -->
277
+ <visual>
278
+ <origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/>
279
+ <geometry>
280
+ <mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/>
281
+ </geometry>
282
+ <material name="3d_printed"/>
283
+ </visual>
284
+ <collision>
285
+ <origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/>
286
+ <geometry>
287
+ <mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/>
288
+ </geometry>
289
+ </collision>
290
+ </link>
291
+
292
+ <!-- Link jaw -->
293
+ <link name="jaw">
294
+ <inertial>
295
+ <origin xyz="-0.00157495 -0.0300244 0.0192755" rpy="0 0 0"/>
296
+ <mass value="0.012"/>
297
+ <inertia ixx="6.61427e-06" ixy="-3.19807e-07" ixz="-5.90717e-09" iyy="1.89032e-06" iyz="-1.09945e-07" izz="5.28738e-06"/>
298
+ </inertial>
299
+ <!-- Part moving_jaw_so101_v1 -->
300
+ <visual>
301
+ <origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/>
302
+ <geometry>
303
+ <mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/>
304
+ </geometry>
305
+ <material name="3d_printed"/>
306
+ </visual>
307
+ <collision>
308
+ <origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/>
309
+ <geometry>
310
+ <mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/>
311
+ </geometry>
312
+ </collision>
313
+ </link>
314
+
315
+ <!-- Joint from gripper to jaw -->
316
+ <joint name="Jaw" type="revolute">
317
+ <origin xyz="0.0202 0.0188 -0.0234" rpy="1.5708 -5.14108e-17 -1.38655e-14"/>
318
+ <parent link="gripper"/>
319
+ <child link="jaw"/>
320
+ <axis xyz="0 0 1"/>
321
+ <limit effort="10" velocity="10" lower="-0.174533" upper="1.74533"/>
322
+ </joint>
323
+
324
+ <transmission name="6_trans">
325
+ <type>transmission_interface/SimpleTransmission</type>
326
+ <joint name="Jaw">
327
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
328
+ </joint>
329
+ <actuator name="motor6">
330
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
331
+ <mechanicalReduction>1</mechanicalReduction>
332
+ </actuator>
333
+ </transmission>
334
+
335
+ <!-- Joint from wrist to gripper -->
336
+ <joint name="Wrist_Roll" type="revolute">
337
+ <origin xyz="0 -0.0611 0.0181" rpy="1.5708 -9.38083e-08 3.14159"/>
338
+ <parent link="wrist"/>
339
+ <child link="gripper"/>
340
+ <axis xyz="0 0 1"/>
341
+ <limit effort="10" velocity="10" lower="-2.79253" upper="2.79253"/>
342
+ </joint>
343
+
344
+ <transmission name="5_trans">
345
+ <type>transmission_interface/SimpleTransmission</type>
346
+ <joint name="Wrist_Roll">
347
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
348
+ </joint>
349
+ <actuator name="motor5">
350
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
351
+ <mechanicalReduction>1</mechanicalReduction>
352
+ </actuator>
353
+ </transmission>
354
+
355
+ <!-- Joint from lower_arm to wrist -->
356
+ <joint name="Wrist_Pitch" type="revolute">
357
+ <origin xyz="-0.1349 0.0052 1.65232e-16" rpy="3.2474e-15 2.86219e-15 -1.5708"/>
358
+ <parent link="lower_arm"/>
359
+ <child link="wrist"/>
360
+ <axis xyz="0 0 1"/>
361
+ <limit effort="10" velocity="10" lower="-1.65806" upper="1.65806"/>
362
+ </joint>
363
+
364
+ <transmission name="4_trans">
365
+ <type>transmission_interface/SimpleTransmission</type>
366
+ <joint name="Wrist_Pitch">
367
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
368
+ </joint>
369
+ <actuator name="motor4">
370
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
371
+ <mechanicalReduction>1</mechanicalReduction>
372
+ </actuator>
373
+ </transmission>
374
+
375
+ <!-- Joint from upper_arm to lower_arm -->
376
+ <joint name="Elbow" type="revolute">
377
+ <origin xyz="-0.11257 -0.028 2.46331e-16" rpy="-1.22818e-15 5.75928e-16 1.5708"/>
378
+ <parent link="upper_arm"/>
379
+ <child link="lower_arm"/>
380
+ <axis xyz="0 0 1"/>
381
+ <limit effort="10" velocity="10" lower="-1.74533" upper="1.5708"/>
382
+ </joint>
383
+
384
+ <transmission name="3_trans">
385
+ <type>transmission_interface/SimpleTransmission</type>
386
+ <joint name="Elbow">
387
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
388
+ </joint>
389
+ <actuator name="motor3">
390
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
391
+ <mechanicalReduction>1</mechanicalReduction>
392
+ </actuator>
393
+ </transmission>
394
+
395
+ <!-- Joint from shoulder to upper_arm -->
396
+ <joint name="Pitch" type="revolute">
397
+ <origin xyz="-0.0303992 -0.0182778 -0.0542" rpy="-1.5708 -1.5708 0"/>
398
+ <parent link="shoulder"/>
399
+ <child link="upper_arm"/>
400
+ <axis xyz="0 0 1"/>
401
+ <limit effort="10" velocity="10" lower="-1.74533" upper="1.74533"/>
402
+ </joint>
403
+
404
+ <transmission name="2_trans">
405
+ <type>transmission_interface/SimpleTransmission</type>
406
+ <joint name="Pitch">
407
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
408
+ </joint>
409
+ <actuator name="motor2">
410
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
411
+ <mechanicalReduction>1</mechanicalReduction>
412
+ </actuator>
413
+ </transmission>
414
+
415
+ <!-- Joint from base to shoulder -->
416
+ <joint name="Rotation" type="revolute">
417
+ <origin xyz="0.0207909 -0.0230745 0.0948817" rpy="-3.14159 6.03684e-16 1.5708"/>
418
+ <parent link="base"/>
419
+ <child link="shoulder"/>
420
+ <axis xyz="0 0 1"/>
421
+ <limit effort="10" velocity="10" lower="-1.91986" upper="1.91986"/>
422
+ </joint>
423
+
424
+ <transmission name="1_trans">
425
+ <type>transmission_interface/SimpleTransmission</type>
426
+ <joint name="Rotation">
427
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
428
+ </joint>
429
+ <actuator name="motor1">
430
+ <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
431
+ <mechanicalReduction>1</mechanicalReduction>
432
+ </actuator>
433
+ </transmission>
434
+
435
+ </robot>
src/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ node_modules/
src/App.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { ThemeProvider } from "@/contexts/ThemeContext";
4
+ import { UrdfProvider } from "@/contexts/UrdfContext";
5
+ import { DragAndDropProvider } from "@/contexts/DragAndDropContext";
6
+ import { Toaster } from "@/components/ui/toaster";
7
+ import Landing from "@/pages/Landing";
8
+ import Teleoperation from "@/pages/Teleoperation";
9
+ import Calibration from "@/pages/Calibration";
10
+ import Recording from "@/pages/Recording";
11
+ import Training from "@/pages/Training";
12
+ import Inference from "@/pages/Inference";
13
+ import EditDataset from "@/pages/EditDataset";
14
+ import Upload from "@/pages/Upload";
15
+
16
+ import NotFound from "@/pages/NotFound";
17
+ import SingleTabGuard from "@/components/SingleTabGuard";
18
+ import { TooltipProvider } from "@radix-ui/react-tooltip";
19
+ import { ApiProvider } from "./contexts/ApiContext";
20
+ import { HfAuthProvider } from "./contexts/HfAuthContext";
21
+
22
+ const queryClient = new QueryClient();
23
+
24
+ function App() {
25
+ return (
26
+ <QueryClientProvider client={queryClient}>
27
+ <TooltipProvider>
28
+ <ThemeProvider>
29
+ <ApiProvider>
30
+ <HfAuthProvider>
31
+ <UrdfProvider>
32
+ <DragAndDropProvider>
33
+ <BrowserRouter>
34
+ <SingleTabGuard>
35
+ <Routes>
36
+ <Route path="/" element={<Landing />} />
37
+ <Route path="/teleoperation" element={<Teleoperation />} />
38
+ <Route path="/recording" element={<Recording />} />
39
+ <Route path="/upload" element={<Upload />} />
40
+ <Route path="/training" element={<Training />} />
41
+ <Route path="/training/:jobId" element={<Training />} />
42
+ <Route path="/inference" element={<Inference />} />
43
+ <Route path="/calibration" element={<Calibration />} />
44
+ <Route path="/edit-dataset" element={<EditDataset />} />
45
+
46
+ <Route path="*" element={<NotFound />} />
47
+ </Routes>
48
+ </SingleTabGuard>
49
+ <Toaster />
50
+ </BrowserRouter>
51
+ </DragAndDropProvider>
52
+ </UrdfProvider>
53
+ </HfAuthProvider>
54
+ </ApiProvider>
55
+ </ThemeProvider>
56
+ </TooltipProvider>
57
+ </QueryClientProvider>
58
+ );
59
+ }
60
+
61
+ export default App;
src/components/Footer.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Github, BookOpen } from "lucide-react";
3
+
4
+ const DiscordIcon: React.FC<{ className?: string }> = ({ className }) => (
5
+ <svg
6
+ role="img"
7
+ viewBox="0 0 24 24"
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ fill="currentColor"
10
+ className={className}
11
+ >
12
+ <path d="M20.317 4.369A19.79 19.79 0 0 0 16.558 3.2a.07.07 0 0 0-.074.035c-.211.375-.444.864-.608 1.249a18.27 18.27 0 0 0-5.487 0 12.51 12.51 0 0 0-.617-1.249.077.077 0 0 0-.074-.035 19.736 19.736 0 0 0-3.76 1.169.07.07 0 0 0-.032.027C2.533 8.046 1.79 11.624 2.155 15.157a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.027c.462-.63.873-1.295 1.226-1.994a.076.076 0 0 0-.041-.105 13.13 13.13 0 0 1-1.873-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.006.128 12.32 12.32 0 0 1-1.873.891.077.077 0 0 0-.04.106c.36.698.772 1.363 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-4.087-.838-7.636-3.548-10.787a.061.061 0 0 0-.031-.028zM8.02 12.997c-1.182 0-2.156-1.085-2.156-2.419 0-1.333.955-2.419 2.156-2.419 1.21 0 2.175 1.095 2.156 2.42 0 1.333-.955 2.418-2.156 2.418zm7.974 0c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.175 1.095 2.156 2.42 0 1.333-.946 2.418-2.156 2.418z" />
13
+ </svg>
14
+ );
15
+
16
+ const links = [
17
+ {
18
+ href: "https://github.com/huggingface/lerobot",
19
+ label: "GitHub",
20
+ Icon: Github,
21
+ },
22
+ {
23
+ href: "https://huggingface.co/docs/lerobot",
24
+ label: "Documentation",
25
+ Icon: BookOpen,
26
+ },
27
+ {
28
+ href: "https://discord.com/invite/s3KuuzsPFb",
29
+ label: "Discord",
30
+ Icon: DiscordIcon,
31
+ },
32
+ ];
33
+
34
+ const Footer: React.FC = () => {
35
+ return (
36
+ <footer className="fixed inset-x-0 bottom-0 z-30 border-t border-gray-800 bg-black/95">
37
+ <div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-4 py-4 text-sm text-gray-400 sm:flex-row">
38
+ <span>
39
+ Powered by{" "}
40
+ <a
41
+ href="https://github.com/huggingface/lerobot"
42
+ target="_blank"
43
+ rel="noopener noreferrer"
44
+ className="font-medium text-gray-200 hover:text-white"
45
+ >
46
+ LeRobot
47
+ </a>
48
+ </span>
49
+ <nav className="flex items-center gap-4">
50
+ {links.map(({ href, label, Icon }) => (
51
+ <a
52
+ key={label}
53
+ href={href}
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ className="flex items-center gap-1.5 text-gray-400 hover:text-white"
57
+ >
58
+ <Icon className="h-4 w-4" />
59
+ <span>{label}</span>
60
+ </a>
61
+ ))}
62
+ </nav>
63
+ </div>
64
+ </footer>
65
+ );
66
+ };
67
+
68
+ export default Footer;
src/components/Logo.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { cn } from '@/lib/utils';
4
+
5
+ interface LogoProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ iconOnly?: boolean;
7
+ }
8
+
9
+ const Logo: React.FC<LogoProps> = ({
10
+ className,
11
+ iconOnly = false
12
+ }) => {
13
+ return <div className={cn("flex items-center gap-2", className)}>
14
+ <img src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png" alt="LeLab Logo" className="h-8 w-8" />
15
+ {!iconOnly && <span className="font-bold text-white text-2xl">LeLab</span>}
16
+ </div>;
17
+ };
18
+
19
+ export default Logo;
src/components/SingleTabGuard.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState, ReactNode } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+
4
+ type Peer = { id: string; openedAt: number; lastSeen: number };
5
+
6
+ const CHANNEL = "lelab-tabs-v1";
7
+ const HEARTBEAT_MS = 1000;
8
+ const PEER_TIMEOUT_MS = 3000;
9
+
10
+ const SingleTabGuard = ({ children }: { children: ReactNode }) => {
11
+ const [isPrimary, setIsPrimary] = useState(true);
12
+ const peersRef = useRef<Map<string, Peer>>(new Map());
13
+ const myIdRef = useRef<string>("");
14
+ const myOpenedAtRef = useRef<number>(0);
15
+ const channelRef = useRef<BroadcastChannel | null>(null);
16
+
17
+ const recompute = useCallback(() => {
18
+ const peers = peersRef.current;
19
+ const cutoff = Date.now() - PEER_TIMEOUT_MS;
20
+ for (const [id, peer] of peers) {
21
+ if (peer.lastSeen < cutoff) peers.delete(id);
22
+ }
23
+ let winnerId = myIdRef.current;
24
+ let winnerOpenedAt = myOpenedAtRef.current;
25
+ for (const peer of peers.values()) {
26
+ if (
27
+ peer.openedAt < winnerOpenedAt ||
28
+ (peer.openedAt === winnerOpenedAt && peer.id < winnerId)
29
+ ) {
30
+ winnerId = peer.id;
31
+ winnerOpenedAt = peer.openedAt;
32
+ }
33
+ }
34
+ setIsPrimary(winnerId === myIdRef.current);
35
+ }, []);
36
+
37
+ useEffect(() => {
38
+ if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") {
39
+ return;
40
+ }
41
+
42
+ myIdRef.current = crypto.randomUUID();
43
+ myOpenedAtRef.current = Date.now();
44
+
45
+ const channel = new BroadcastChannel(CHANNEL);
46
+ channelRef.current = channel;
47
+
48
+ const send = (type: string) => {
49
+ channel.postMessage({
50
+ type,
51
+ id: myIdRef.current,
52
+ openedAt: myOpenedAtRef.current,
53
+ });
54
+ };
55
+
56
+ channel.onmessage = (e) => {
57
+ const msg = e.data;
58
+ if (!msg || msg.id === myIdRef.current) return;
59
+ const peers = peersRef.current;
60
+
61
+ if (msg.type === "HEARTBEAT") {
62
+ peers.set(msg.id, {
63
+ id: msg.id,
64
+ openedAt: msg.openedAt,
65
+ lastSeen: Date.now(),
66
+ });
67
+ } else if (msg.type === "RELEASE") {
68
+ peers.delete(msg.id);
69
+ } else if (msg.type === "TAKEOVER") {
70
+ peers.set(msg.id, {
71
+ id: msg.id,
72
+ openedAt: msg.openedAt,
73
+ lastSeen: Date.now(),
74
+ });
75
+ // Move ourselves behind the taker so the election flips.
76
+ if (myOpenedAtRef.current <= msg.openedAt) {
77
+ myOpenedAtRef.current = msg.openedAt + 1;
78
+ }
79
+ }
80
+ recompute();
81
+ };
82
+
83
+ send("HEARTBEAT");
84
+ const interval = setInterval(() => {
85
+ send("HEARTBEAT");
86
+ recompute();
87
+ }, HEARTBEAT_MS);
88
+
89
+ const onUnload = () => send("RELEASE");
90
+ window.addEventListener("beforeunload", onUnload);
91
+
92
+ return () => {
93
+ window.removeEventListener("beforeunload", onUnload);
94
+ clearInterval(interval);
95
+ send("RELEASE");
96
+ channel.close();
97
+ channelRef.current = null;
98
+ };
99
+ }, [recompute]);
100
+
101
+ const takeOver = useCallback(() => {
102
+ myOpenedAtRef.current = 0;
103
+ channelRef.current?.postMessage({
104
+ type: "TAKEOVER",
105
+ id: myIdRef.current,
106
+ openedAt: 0,
107
+ });
108
+ recompute();
109
+ }, [recompute]);
110
+
111
+ return (
112
+ <>
113
+ {children}
114
+ {!isPrimary && (
115
+ <div
116
+ className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
117
+ role="dialog"
118
+ aria-modal="true"
119
+ >
120
+ <div className="mx-4 max-w-md space-y-4 rounded-lg border bg-background p-6 text-center shadow-lg">
121
+ <h2 className="text-lg font-semibold">
122
+ LeLab is already open in another tab
123
+ </h2>
124
+ <p className="text-sm text-muted-foreground">
125
+ Only one tab can control the robot at a time. Switch back to the
126
+ original tab, or take over here — the other tab will lock.
127
+ </p>
128
+ <Button onClick={takeOver}>Use this tab</Button>
129
+ </div>
130
+ </div>
131
+ )}
132
+ </>
133
+ );
134
+ };
135
+
136
+ export default SingleTabGuard;
src/components/UrdfViewer.tsx ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ useMemo,
6
+ useCallback,
7
+ memo,
8
+ } from "react";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js";
12
+ import { useUrdf } from "@/hooks/useUrdf";
13
+ import { useRealTimeJoints } from "@/hooks/useRealTimeJoints";
14
+ import {
15
+ createUrdfViewer,
16
+ setupMeshLoader,
17
+ setupJointHighlighting,
18
+ setupModelLoading,
19
+ URDFViewerElement,
20
+ } from "@/lib/urdfViewerHelpers";
21
+
22
+ // Register the URDFManipulator as a custom element if it hasn't been already
23
+ if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) {
24
+ customElements.define("urdf-viewer", URDFManipulator);
25
+ }
26
+ import * as THREE from "three";
27
+
28
+ // Extend the interface for the URDF viewer element to include background property
29
+ interface UrdfViewerElement extends HTMLElement {
30
+ background?: string;
31
+ setJointValue?: (jointName: string, value: number) => void;
32
+ }
33
+
34
+ const UrdfViewer: React.FC = () => {
35
+ const containerRef = useRef<HTMLDivElement>(null);
36
+ const [highlightedJoint, setHighlightedJoint] = useState<string | null>(null);
37
+ const { registerUrdfProcessor, alternativeUrdfModels, isDefaultModel } =
38
+ useUrdf();
39
+
40
+ const cleanupAnimationRef = useRef<(() => void) | null>(null);
41
+ const viewerRef = useRef<URDFViewerElement | null>(null);
42
+ const hasInitializedRef = useRef<boolean>(false);
43
+
44
+ // Real-time joint updates via WebSocket
45
+ const { isConnected: isWebSocketConnected } = useRealTimeJoints({
46
+ viewerRef,
47
+ enabled: isDefaultModel, // Only enable WebSocket for default model
48
+ });
49
+
50
+ // Add state for custom URDF path
51
+ const [customUrdfPath, setCustomUrdfPath] = useState<string | null>(null);
52
+ const [urlModifierFunc, setUrlModifierFunc] = useState<
53
+ ((url: string) => string) | null
54
+ >(null);
55
+
56
+ const packageRef = useRef<string>("");
57
+
58
+ // Implement UrdfProcessor interface for drag and drop
59
+ const urdfProcessor = useMemo(
60
+ () => ({
61
+ loadUrdf: (urdfPath: string) => {
62
+ setCustomUrdfPath(urdfPath);
63
+ },
64
+ setUrlModifierFunc: (func: (url: string) => string) => {
65
+ setUrlModifierFunc(() => func);
66
+ },
67
+ getPackage: () => {
68
+ return packageRef.current;
69
+ },
70
+ }),
71
+ []
72
+ );
73
+
74
+ // Register the URDF processor with the global drag and drop context
75
+ useEffect(() => {
76
+ registerUrdfProcessor(urdfProcessor);
77
+ }, [registerUrdfProcessor, urdfProcessor]);
78
+
79
+ // Create URL modifier function for default model
80
+ const defaultUrlModifier = useCallback((url: string) => {
81
+ console.log(`🔗 defaultUrlModifier called with: ${url}`);
82
+
83
+ // Handle various package:// URL formats for the default SO-101 model
84
+ if (url.startsWith("package://so_arm_description/meshes/")) {
85
+ const modifiedUrl = url.replace(
86
+ "package://so_arm_description/meshes/",
87
+ "/so-101-urdf/meshes/"
88
+ );
89
+ console.log(`🔗 Modified URL (package): ${modifiedUrl}`);
90
+ return modifiedUrl;
91
+ }
92
+
93
+ // Handle case where package path might be partially resolved
94
+ if (url.includes("so_arm_description/meshes/")) {
95
+ const modifiedUrl = url.replace(
96
+ /.*so_arm_description\/meshes\//,
97
+ "/so-101-urdf/meshes/"
98
+ );
99
+ console.log(`🔗 Modified URL (partial): ${modifiedUrl}`);
100
+ return modifiedUrl;
101
+ }
102
+
103
+ // Handle the specific problematic path pattern we're seeing in logs
104
+ if (url.includes("/so-101-urdf/so_arm_description/meshes/")) {
105
+ const modifiedUrl = url.replace(
106
+ "/so-101-urdf/so_arm_description/meshes/",
107
+ "/so-101-urdf/meshes/"
108
+ );
109
+ console.log(`🔗 Modified URL (problematic path): ${modifiedUrl}`);
110
+ return modifiedUrl;
111
+ }
112
+
113
+ // Handle relative paths that might need mesh folder prefix
114
+ if (
115
+ url.endsWith(".stl") &&
116
+ !url.startsWith("/") &&
117
+ !url.startsWith("http")
118
+ ) {
119
+ const modifiedUrl = `/so-101-urdf/meshes/${url}`;
120
+ console.log(`🔗 Modified URL (relative): ${modifiedUrl}`);
121
+ return modifiedUrl;
122
+ }
123
+
124
+ console.log(`🔗 Unmodified URL: ${url}`);
125
+ return url;
126
+ }, []);
127
+
128
+ // Main effect to create and setup the viewer only once
129
+ useEffect(() => {
130
+ if (!containerRef.current) return;
131
+
132
+ // Create and configure the URDF viewer element
133
+ const viewer = createUrdfViewer(containerRef.current, true);
134
+ viewerRef.current = viewer; // Store reference to the viewer
135
+
136
+ // Setup mesh loading function with appropriate URL modifier
137
+ const activeUrlModifier = isDefaultModel
138
+ ? defaultUrlModifier
139
+ : urlModifierFunc;
140
+ setupMeshLoader(viewer, activeUrlModifier);
141
+
142
+ // Determine which URDF to load - fixed path to match the actual available file
143
+ const urdfPath = isDefaultModel
144
+ ? "/so-101-urdf/urdf/so101_new_calib.urdf"
145
+ : customUrdfPath || "";
146
+
147
+ // Set the package path for the default model
148
+ if (isDefaultModel) {
149
+ packageRef.current = "/"; // Set to root so we can handle full path resolution in URL modifier
150
+ }
151
+
152
+ // Setup model loading if a path is available
153
+ let cleanupModelLoading = () => {};
154
+ if (urdfPath) {
155
+ cleanupModelLoading = setupModelLoading(
156
+ viewer,
157
+ urdfPath,
158
+ packageRef.current,
159
+ setCustomUrdfPath,
160
+ alternativeUrdfModels
161
+ );
162
+ }
163
+
164
+ // Setup joint highlighting
165
+ const cleanupJointHighlighting = setupJointHighlighting(
166
+ viewer,
167
+ setHighlightedJoint
168
+ );
169
+
170
+ // Function to fit the robot to the camera view
171
+ const fitRobotToView = (viewer: URDFViewerElement) => {
172
+ if (!viewer || !viewer.robot) {
173
+ console.log(
174
+ "[RobotViewer] Cannot fit to view: No viewer or robot available"
175
+ );
176
+ return;
177
+ }
178
+
179
+ try {
180
+ // Create a bounding box for the robot
181
+ const boundingBox = new THREE.Box3().setFromObject(viewer.robot);
182
+
183
+ // Calculate the center of the bounding box
184
+ const center = new THREE.Vector3();
185
+ boundingBox.getCenter(center);
186
+
187
+ // Calculate the size of the bounding box
188
+ const size = new THREE.Vector3();
189
+ boundingBox.getSize(size);
190
+
191
+ // Get the maximum dimension to ensure the entire robot is visible
192
+ const maxDim = Math.max(size.x, size.y, size.z);
193
+
194
+ // Position camera to see the center of the model
195
+ viewer.camera.position.copy(center);
196
+
197
+ // Move the camera back to see the entire robot
198
+ // Use the model's up direction to determine which axis to move along
199
+ const upVector = new THREE.Vector3();
200
+ if (viewer.up === "+Z" || viewer.up === "Z") {
201
+ upVector.set(1, 1, 1); // Move back in a diagonal
202
+ } else if (viewer.up === "+Y" || viewer.up === "Y") {
203
+ upVector.set(1, 1, 1); // Move back in a diagonal
204
+ } else {
205
+ upVector.set(1, 1, 1); // Default direction
206
+ }
207
+
208
+ // Normalize the vector and multiply by the size
209
+ upVector.normalize().multiplyScalar(maxDim * 1.3);
210
+ viewer.camera.position.add(upVector);
211
+
212
+ // Make the camera look at the center of the model
213
+ viewer.controls.target.copy(center);
214
+
215
+ // Update controls and mark for redraw
216
+ viewer.controls.update();
217
+ viewer.redraw();
218
+
219
+ console.log("[RobotViewer] Robot auto-fitted to view");
220
+ } catch (error) {
221
+ console.error("[RobotViewer] Error fitting robot to view:", error);
222
+ }
223
+ };
224
+
225
+ // Add event listener for when the robot is loaded to auto-fit to view
226
+ const onRobotLoad = () => {
227
+ fitRobotToView(viewer);
228
+ };
229
+
230
+ // Setup animation event handler for the default model or when hasAnimation is true
231
+ const onModelProcessed = () => {
232
+ hasInitializedRef.current = true;
233
+ if ("setJointValue" in viewer) {
234
+ // Clear any existing animation
235
+ if (cleanupAnimationRef.current) {
236
+ cleanupAnimationRef.current();
237
+ cleanupAnimationRef.current = null;
238
+ }
239
+ }
240
+ // Auto-fit the robot to view when the model is processed
241
+ onRobotLoad();
242
+ };
243
+
244
+ viewer.addEventListener("urdf-processed", onModelProcessed);
245
+
246
+ // Return cleanup function
247
+ return () => {
248
+ if (cleanupAnimationRef.current) {
249
+ cleanupAnimationRef.current();
250
+ cleanupAnimationRef.current = null;
251
+ }
252
+ hasInitializedRef.current = false;
253
+ cleanupJointHighlighting();
254
+ cleanupModelLoading();
255
+ viewer.removeEventListener("urdf-processed", onModelProcessed);
256
+ };
257
+ }, [
258
+ isDefaultModel,
259
+ customUrdfPath,
260
+ urlModifierFunc,
261
+ defaultUrlModifier,
262
+ alternativeUrdfModels,
263
+ ]);
264
+
265
+ return (
266
+ <div
267
+ className={cn(
268
+ "w-full h-full transition-all duration-300 ease-in-out relative",
269
+ "bg-gradient-to-br from-gray-900 to-gray-800"
270
+ )}
271
+ >
272
+ <div ref={containerRef} className="w-full h-full" />
273
+
274
+ {/* Joint highlight indicator */}
275
+ {highlightedJoint && (
276
+ <div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-2 rounded-md text-sm font-mono z-10">
277
+ Joint: {highlightedJoint}
278
+ </div>
279
+ )}
280
+
281
+ {/* WebSocket connection status */}
282
+ {isDefaultModel && (
283
+ <div className="absolute top-4 right-4 z-10">
284
+ <div
285
+ className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono ${
286
+ isWebSocketConnected
287
+ ? "bg-green-900/70 text-green-300"
288
+ : "bg-red-900/70 text-red-300"
289
+ }`}
290
+ >
291
+ <div
292
+ className={`w-2 h-2 rounded-full ${
293
+ isWebSocketConnected ? "bg-green-400" : "bg-red-400"
294
+ }`}
295
+ />
296
+ {isWebSocketConnected ? "Live Robot Data" : "Disconnected"}
297
+ </div>
298
+ </div>
299
+ )}
300
+ </div>
301
+ );
302
+ };
303
+
304
+ export default memo(UrdfViewer);
src/components/control/CommandBar.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Mic, MicOff, Send, Camera } from 'lucide-react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+
7
+ interface CommandBarProps {
8
+ command: string;
9
+ setCommand: (command: string) => void;
10
+ handleSendCommand: () => void;
11
+ isVoiceActive: boolean;
12
+ setIsVoiceActive: (isActive: boolean) => void;
13
+ showCamera: boolean;
14
+ setShowCamera: (show: boolean) => void;
15
+ handleEndSession: () => void;
16
+ }
17
+
18
+ const CommandBar: React.FC<CommandBarProps> = ({
19
+ command,
20
+ setCommand,
21
+ handleSendCommand,
22
+ isVoiceActive,
23
+ setIsVoiceActive,
24
+ showCamera,
25
+ setShowCamera,
26
+ handleEndSession
27
+ }) => {
28
+ return (
29
+ <div className="bg-gray-900 p-4 space-y-4">
30
+ <div className="flex flex-col sm:flex-row gap-4 items-center max-w-4xl mx-auto w-full">
31
+ <Input
32
+ value={command}
33
+ onChange={(e) => setCommand(e.target.value)}
34
+ placeholder="Tell the robot what to do..."
35
+ className="flex-1 bg-gray-800 border-gray-600 text-white placeholder-gray-400 text-lg py-3"
36
+ onKeyPress={(e) => e.key === 'Enter' && handleSendCommand()}
37
+ />
38
+ <Button
39
+ onClick={handleSendCommand}
40
+ className="bg-orange-500 hover:bg-orange-600 px-6 py-3 self-stretch sm:self-auto"
41
+ >
42
+ <Send strokeWidth={1.5} />
43
+ Send
44
+ </Button>
45
+ </div>
46
+
47
+ <div className="flex justify-center items-center gap-6">
48
+ <div className="flex flex-wrap justify-center gap-2 sm:gap-4">
49
+ <Button
50
+ onClick={() => setIsVoiceActive(!isVoiceActive)}
51
+ className={`px-6 py-2 ${
52
+ isVoiceActive ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
53
+ }`}
54
+ >
55
+ {isVoiceActive ? <Mic strokeWidth={1.5} /> : <MicOff strokeWidth={1.5} />}
56
+ Voice Command
57
+ </Button>
58
+
59
+ <Button
60
+ onClick={() => setShowCamera(!showCamera)}
61
+ className={`px-6 py-2 ${
62
+ showCamera ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'
63
+ }`}
64
+ >
65
+ <Camera strokeWidth={1.5} />
66
+ Show Camera
67
+ </Button>
68
+
69
+ <Button
70
+ onClick={handleEndSession}
71
+ className="bg-red-600 hover:bg-red-700 px-6 py-2"
72
+ >
73
+ End Session
74
+ </Button>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ );
79
+ };
80
+
81
+ export default CommandBar;
src/components/control/MetricsPanel.tsx ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useEffect, useRef } from 'react';
3
+ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
4
+ import { Camera, MicOff } from 'lucide-react';
5
+
6
+ interface MetricsPanelProps {
7
+ activeTab: 'SENSORS' | 'MOTORS';
8
+ setActiveTab: (tab: 'SENSORS' | 'MOTORS') => void;
9
+ sensorData: any[];
10
+ motorData: any[];
11
+ hasPermissions: boolean;
12
+ streamRef: React.RefObject<MediaStream | null>;
13
+ isVoiceActive: boolean;
14
+ micLevel: number;
15
+ }
16
+
17
+ const MetricsPanel: React.FC<MetricsPanelProps> = ({
18
+ activeTab,
19
+ setActiveTab,
20
+ sensorData,
21
+ motorData,
22
+ hasPermissions,
23
+ streamRef,
24
+ isVoiceActive,
25
+ micLevel,
26
+ }) => {
27
+ const sensorVideoRef = useRef<HTMLVideoElement>(null);
28
+
29
+ useEffect(() => {
30
+ if (activeTab === 'SENSORS' && hasPermissions && sensorVideoRef.current && streamRef.current) {
31
+ if (sensorVideoRef.current.srcObject !== streamRef.current) {
32
+ sensorVideoRef.current.srcObject = streamRef.current;
33
+ }
34
+ }
35
+ }, [activeTab, hasPermissions, streamRef]);
36
+
37
+ return (
38
+ <div className="w-full lg:w-1/2 p-2 sm:p-4">
39
+ <div className="bg-gray-900 rounded-lg p-4 h-full flex flex-col">
40
+ {/* Tab Headers */}
41
+ <div className="flex mb-4">
42
+ <button
43
+ onClick={() => setActiveTab('MOTORS')}
44
+ className={`px-6 py-2 rounded-t-lg text-sm sm:text-base ${
45
+ activeTab === 'MOTORS'
46
+ ? 'bg-orange-500 text-white'
47
+ : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
48
+ }`}
49
+ >
50
+ MOTORS
51
+ </button>
52
+ <button
53
+ onClick={() => setActiveTab('SENSORS')}
54
+ className={`px-6 py-2 rounded-t-lg ml-2 text-sm sm:text-base ${
55
+ activeTab === 'SENSORS'
56
+ ? 'bg-orange-500 text-white'
57
+ : 'bg-gray-700 text-gray-300 hover:bg-gray-600'
58
+ }`}
59
+ >
60
+ SENSORS
61
+ </button>
62
+ </div>
63
+
64
+ {/* Chart Content */}
65
+ <div className="flex-1 overflow-y-auto">
66
+ {activeTab === 'SENSORS' && (
67
+ <div className="space-y-4">
68
+ {/* Webcam Feed */}
69
+ <div className="border border-gray-800 rounded p-2 flex flex-col h-64">
70
+ <h3 className="text-sm text-white font-medium mb-2">Live Camera Feed</h3>
71
+ {hasPermissions ? (
72
+ <div className="flex-1 bg-black rounded overflow-hidden">
73
+ <video
74
+ ref={sensorVideoRef}
75
+ autoPlay
76
+ muted
77
+ playsInline
78
+ className="w-full h-full object-contain"
79
+ />
80
+ </div>
81
+ ) : (
82
+ <div className="flex-1 flex items-center justify-center bg-black rounded">
83
+ <div className="text-center">
84
+ <Camera className="w-12 h-12 mx-auto text-gray-500 mb-2" />
85
+ <p className="text-gray-400">Camera permission not granted.</p>
86
+ </div>
87
+ </div>
88
+ )}
89
+ </div>
90
+
91
+ {/* Mic Detection & Other Sensors */}
92
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
93
+ <div className="border border-gray-800 rounded p-2 flex flex-col justify-center min-h-[120px]">
94
+ <h3 className="text-sm text-center text-white font-medium mb-2">Voice Activity</h3>
95
+ {hasPermissions ? (
96
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 text-center">
97
+ <div className="flex items-end h-10 gap-px w-full justify-center">
98
+ {[...Array(15)].map((_, i) => {
99
+ const barIsActive = isVoiceActive && i < (micLevel / 120 * 15);
100
+ return (
101
+ <div
102
+ key={i}
103
+ className={`w-1.5 rounded-full transition-colors duration-75 ${barIsActive ? 'bg-orange-500' : 'bg-gray-700'}`}
104
+ style={{ height: `${(i / 15 * 60) + 20}%` }}
105
+ />
106
+ );
107
+ })}
108
+ </div>
109
+ <p className="text-xs text-gray-300">
110
+ {isVoiceActive ? "Voice commands active" : "Voice commands muted"}
111
+ </p>
112
+ </div>
113
+ ) : (
114
+ <div className="flex-1 flex items-center justify-center bg-black rounded">
115
+ <div className="text-center">
116
+ <MicOff className="w-8 h-8 mx-auto text-gray-500 mb-2" />
117
+ <p className="text-gray-400">Microphone permission not granted.</p>
118
+ </div>
119
+ </div>
120
+ )}
121
+ </div>
122
+
123
+ {/* Sensor Charts */}
124
+ {['sensor3', 'sensor4'].map((sensor, index) => (
125
+ <div key={sensor} className="border border-gray-800 rounded p-2 flex flex-col h-auto min-h-[120px]">
126
+ <h3 className="text-sm text-white font-medium mb-2">Sensor {index + 3}</h3>
127
+ <ResponsiveContainer width="100%" height="90%">
128
+ <LineChart data={sensorData}>
129
+ <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
130
+ <XAxis hide />
131
+ <YAxis fontSize={12} stroke="#9CA3AF" />
132
+ <Tooltip
133
+ contentStyle={{
134
+ backgroundColor: '#1F2937',
135
+ border: '1px solid #374151',
136
+ color: '#fff'
137
+ }}
138
+ />
139
+ <Line
140
+ type="monotone"
141
+ dataKey={sensor}
142
+ stroke={index % 2 === 1 ? '#ff6b35' : '#ffdd44'}
143
+ strokeWidth={2}
144
+ dot={false}
145
+ />
146
+ </LineChart>
147
+ </ResponsiveContainer>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ )}
153
+
154
+ {activeTab === 'MOTORS' && (
155
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
156
+ {['motor1', 'motor2', 'motor3', 'motor4', 'motor5', 'motor6'].map((motor, index) => (
157
+ <div key={motor} className="border border-gray-800 rounded p-2 h-40">
158
+ <h3 className="text-sm text-white font-medium mb-2">Motor {index + 1}</h3>
159
+ <ResponsiveContainer width="100%" height="80%">
160
+ <LineChart data={motorData}>
161
+ <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
162
+ <XAxis hide />
163
+ <YAxis fontSize={12} stroke="#9CA3AF" />
164
+ <Tooltip
165
+ contentStyle={{
166
+ backgroundColor: '#1F2937',
167
+ border: '1px solid #374151',
168
+ color: '#fff'
169
+ }}
170
+ />
171
+ <Line
172
+ type="monotone"
173
+ dataKey={motor}
174
+ stroke={index % 2 === 0 ? '#ff6b35' : '#ffdd44'}
175
+ strokeWidth={2}
176
+ dot={false}
177
+ />
178
+ </LineChart>
179
+ </ResponsiveContainer>
180
+ </div>
181
+ ))}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ </div>
187
+ );
188
+ };
189
+
190
+ export default MetricsPanel;
src/components/control/VisualizerPanel.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { ArrowLeft } from "lucide-react";
4
+ import { cn } from "@/lib/utils";
5
+ import UrdfViewer from "../UrdfViewer";
6
+ import Logo from "@/components/Logo";
7
+
8
+ interface VisualizerPanelProps {
9
+ onGoBack: () => void;
10
+ className?: string;
11
+ }
12
+
13
+ const VisualizerPanel: React.FC<VisualizerPanelProps> = ({
14
+ onGoBack,
15
+ className,
16
+ }) => {
17
+ return (
18
+ <div
19
+ className={cn(
20
+ "w-full p-2 sm:p-4 space-y-4 lg:space-y-0 lg:space-x-4 flex flex-col lg:flex-row",
21
+ className
22
+ )}
23
+ >
24
+ <div className="bg-gray-900 rounded-lg p-4 flex-1 flex flex-col">
25
+ <div className="flex items-center gap-4 mb-4">
26
+ <Button
27
+ variant="ghost"
28
+ size="icon"
29
+ onClick={onGoBack}
30
+ className="text-gray-400 hover:text-white hover:bg-gray-800 flex-shrink-0"
31
+ >
32
+ <ArrowLeft className="h-5 w-5" />
33
+ </Button>
34
+ <Logo iconOnly={true} />
35
+ <div className="w-px h-6 bg-gray-700" />
36
+ <h2 className="text-xl font-medium text-gray-200">Teleoperation</h2>
37
+ </div>
38
+ <div className="flex-1 bg-black rounded border border-gray-800 min-h-[50vh] lg:min-h-0">
39
+ <UrdfViewer />
40
+ </div>
41
+ </div>
42
+ </div>
43
+ );
44
+ };
45
+
46
+ export default VisualizerPanel;
src/components/jobs/CheckpointDropdown.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from "@/components/ui/select";
9
+ import { JobCheckpoint } from "@/lib/checkpointsApi";
10
+
11
+ interface Props {
12
+ checkpoints: JobCheckpoint[];
13
+ selectedStep: number | null;
14
+ onChange: (step: number) => void;
15
+ disabled?: boolean;
16
+ placeholder?: string;
17
+ }
18
+
19
+ export const CheckpointDropdown: React.FC<Props> = ({
20
+ checkpoints,
21
+ selectedStep,
22
+ onChange,
23
+ disabled,
24
+ placeholder = "Select checkpoint",
25
+ }) => {
26
+ const value = selectedStep != null ? String(selectedStep) : undefined;
27
+ return (
28
+ <Select
29
+ value={value}
30
+ onValueChange={(v) => onChange(Number(v))}
31
+ disabled={disabled || checkpoints.length === 0}
32
+ >
33
+ <SelectTrigger
34
+ className="bg-slate-800 border-slate-700 text-white h-8 text-xs px-2 w-auto min-w-[110px]"
35
+ onClick={(e) => e.stopPropagation()}
36
+ >
37
+ <SelectValue placeholder={placeholder} />
38
+ </SelectTrigger>
39
+ <SelectContent className="bg-slate-900 border-slate-700 text-white">
40
+ {checkpoints.map((c) => (
41
+ <SelectItem
42
+ key={c.step}
43
+ value={String(c.step)}
44
+ onClick={(e) => e.stopPropagation()}
45
+ >
46
+ step {c.step}
47
+ </SelectItem>
48
+ ))}
49
+ </SelectContent>
50
+ </Select>
51
+ );
52
+ };
53
+
54
+ export default CheckpointDropdown;
src/components/jobs/HubJobCard.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Card, CardContent } from "@/components/ui/card";
3
+ import { Button } from "@/components/ui/button";
4
+ import { HubJob } from "@/lib/jobsApi";
5
+ import {
6
+ ExternalLink,
7
+ AlertTriangle,
8
+ CheckCircle2,
9
+ Loader2,
10
+ XCircle,
11
+ Clock,
12
+ HelpCircle,
13
+ } from "lucide-react";
14
+
15
+ interface Props {
16
+ job: HubJob;
17
+ }
18
+
19
+ function relativeTime(iso: string | null): string {
20
+ if (!iso) return "—";
21
+ const t = Date.parse(iso);
22
+ if (Number.isNaN(t)) return "—";
23
+ const diff = Math.max(0, (Date.now() - t) / 1000);
24
+ if (diff < 60) return `${Math.floor(diff)}s ago`;
25
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
26
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
27
+ return `${Math.floor(diff / 86400)}d ago`;
28
+ }
29
+
30
+ interface StagePresentation {
31
+ label: string;
32
+ color: string;
33
+ Icon: React.ComponentType<{ className?: string }>;
34
+ spin?: boolean;
35
+ }
36
+
37
+ const stagePresentation: Record<string, StagePresentation> = {
38
+ RUNNING: { label: "Running", color: "text-green-400", Icon: Loader2, spin: true },
39
+ QUEUED: { label: "Queued", color: "text-amber-400", Icon: Clock },
40
+ SCHEDULING: { label: "Scheduling", color: "text-amber-400", Icon: Clock },
41
+ COMPLETED: { label: "Done", color: "text-slate-400", Icon: CheckCircle2 },
42
+ FAILED: { label: "Failed", color: "text-red-400", Icon: XCircle },
43
+ // HF API uses "CANCELED" (single L); accept both spellings.
44
+ CANCELED: { label: "Cancelled", color: "text-amber-400", Icon: AlertTriangle },
45
+ CANCELLED: { label: "Cancelled", color: "text-amber-400", Icon: AlertTriangle },
46
+ };
47
+
48
+ const HubJobCard: React.FC<Props> = ({ job }) => {
49
+ const stage = job.status?.stage?.toUpperCase() ?? "";
50
+ const present: StagePresentation = stagePresentation[stage] ?? {
51
+ label: stage || "Unknown",
52
+ color: "text-slate-400",
53
+ Icon: HelpCircle,
54
+ };
55
+ const Icon = present.Icon;
56
+ const title =
57
+ job.docker_image ?? job.space_id ?? `Job ${job.id.slice(0, 12)}…`;
58
+
59
+ return (
60
+ <Card
61
+ onClick={() => window.open(job.url, "_blank", "noopener,noreferrer")}
62
+ className="bg-slate-800/50 border-slate-700 rounded-xl cursor-pointer hover:border-slate-500 transition-colors"
63
+ >
64
+ <CardContent className="p-4 space-y-3">
65
+ <div className="flex items-start justify-between gap-2">
66
+ <div className={`flex items-center gap-1.5 text-xs font-semibold ${present.color}`}>
67
+ <Icon className={`w-3.5 h-3.5 ${present.spin ? "animate-spin" : ""}`} />
68
+ {present.label}
69
+ </div>
70
+ <Button
71
+ variant="ghost"
72
+ size="icon"
73
+ asChild
74
+ className="h-7 w-7 text-slate-400 hover:text-white"
75
+ aria-label="View on Hub"
76
+ >
77
+ <a
78
+ href={job.url}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ onClick={(e) => e.stopPropagation()}
82
+ >
83
+ <ExternalLink className="w-3.5 h-3.5" />
84
+ </a>
85
+ </Button>
86
+ </div>
87
+ <div>
88
+ <div className="text-white font-semibold truncate" title={title}>
89
+ {title}
90
+ </div>
91
+ <div className="text-xs text-slate-400 truncate">
92
+ {job.flavor ?? "—"} · {relativeTime(job.created_at)}
93
+ {job.owner ? ` · ${job.owner}` : ""}
94
+ </div>
95
+ </div>
96
+ {job.status?.message ? (
97
+ <div className="text-xs text-slate-500 truncate" title={job.status.message}>
98
+ {job.status.message}
99
+ </div>
100
+ ) : null}
101
+ </CardContent>
102
+ </Card>
103
+ );
104
+ };
105
+
106
+ export default HubJobCard;
src/components/jobs/HubModelCard.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Card, CardContent } from "@/components/ui/card";
3
+ import { Button } from "@/components/ui/button";
4
+ import { HubModel } from "@/lib/jobsApi";
5
+ import { ExternalLink, Lock, Upload } from "lucide-react";
6
+
7
+ interface Props {
8
+ model: HubModel;
9
+ }
10
+
11
+ function relativeTime(iso: string | null): string {
12
+ if (!iso) return "—";
13
+ const t = Date.parse(iso);
14
+ if (Number.isNaN(t)) return "—";
15
+ const diff = Math.max(0, (Date.now() - t) / 1000);
16
+ if (diff < 60) return `${Math.floor(diff)}s ago`;
17
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
18
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
19
+ return `${Math.floor(diff / 86400)}d ago`;
20
+ }
21
+
22
+ const HubModelCard: React.FC<Props> = ({ model }) => {
23
+ const url = `https://huggingface.co/${model.repo_id}`;
24
+ const shortName = model.repo_id.includes("/")
25
+ ? model.repo_id.split("/").slice(1).join("/")
26
+ : model.repo_id;
27
+
28
+ return (
29
+ <Card
30
+ onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
31
+ className="bg-slate-800/50 border-slate-700 rounded-xl cursor-pointer hover:border-slate-500 transition-colors"
32
+ >
33
+ <CardContent className="p-4 space-y-3">
34
+ <div className="flex items-start justify-between gap-2">
35
+ <div className="flex items-center gap-1.5 text-xs font-semibold text-sky-400">
36
+ <Upload className="w-3.5 h-3.5" />
37
+ Uploaded
38
+ </div>
39
+ <Button
40
+ variant="ghost"
41
+ size="icon"
42
+ asChild
43
+ className="h-7 w-7 text-slate-400 hover:text-white"
44
+ aria-label="View on Hub"
45
+ >
46
+ <a
47
+ href={url}
48
+ target="_blank"
49
+ rel="noopener noreferrer"
50
+ onClick={(e) => e.stopPropagation()}
51
+ >
52
+ <ExternalLink className="w-3.5 h-3.5" />
53
+ </a>
54
+ </Button>
55
+ </div>
56
+ <div>
57
+ <div
58
+ className="text-white font-semibold truncate flex items-center gap-1.5"
59
+ title={model.repo_id}
60
+ >
61
+ {model.private ? (
62
+ <Lock className="w-3.5 h-3.5 text-slate-400 shrink-0" />
63
+ ) : null}
64
+ <span className="truncate">{shortName}</span>
65
+ </div>
66
+ <div className="text-xs text-slate-400 truncate" title={model.repo_id}>
67
+ {model.repo_id} · updated {relativeTime(model.last_modified)}
68
+ </div>
69
+ </div>
70
+ </CardContent>
71
+ </Card>
72
+ );
73
+ };
74
+
75
+ export default HubModelCard;
src/components/jobs/JobCard.tsx ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Card, CardContent } from "@/components/ui/card";
4
+ import { Button } from "@/components/ui/button";
5
+ import { JobRecord } from "@/lib/jobsApi";
6
+ import {
7
+ Square,
8
+ X,
9
+ AlertTriangle,
10
+ CheckCircle2,
11
+ Loader2,
12
+ XCircle,
13
+ ExternalLink,
14
+ Play,
15
+ } from "lucide-react";
16
+ import { useApi } from "@/contexts/ApiContext";
17
+ import {
18
+ JobCheckpoint,
19
+ listJobCheckpoints,
20
+ } from "@/lib/checkpointsApi";
21
+ import CheckpointDropdown from "@/components/jobs/CheckpointDropdown";
22
+
23
+ interface Props {
24
+ job: JobRecord;
25
+ onStop: (id: string) => void;
26
+ onDelete: (id: string) => void;
27
+ onPlay: (job: JobRecord, step: number) => void;
28
+ }
29
+
30
+ function relativeTime(epochSec: number): string {
31
+ const diff = Math.max(0, Date.now() / 1000 - epochSec);
32
+ if (diff < 60) return `${Math.floor(diff)}s ago`;
33
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
34
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
35
+ return `${Math.floor(diff / 86400)}d ago`;
36
+ }
37
+
38
+ const statePresentation: Record<
39
+ JobRecord["state"],
40
+ { label: string; color: string; Icon: React.ComponentType<{ className?: string }> }
41
+ > = {
42
+ running: { label: "Running", color: "text-green-400", Icon: Loader2 },
43
+ done: { label: "Done", color: "text-slate-400", Icon: CheckCircle2 },
44
+ failed: { label: "Failed", color: "text-red-400", Icon: XCircle },
45
+ interrupted: { label: "Interrupted", color: "text-amber-400", Icon: AlertTriangle },
46
+ };
47
+
48
+ const JobCard: React.FC<Props> = ({ job, onStop, onDelete, onPlay }) => {
49
+ const navigate = useNavigate();
50
+ const { baseUrl, fetchWithHeaders } = useApi();
51
+ const present = statePresentation[job.state];
52
+ const Icon = present.Icon;
53
+ const isRunning = job.state === "running";
54
+ const isStarting = isRunning && job.metrics.total_steps === 0;
55
+ const progressPct =
56
+ job.metrics.total_steps > 0
57
+ ? Math.min(100, (job.metrics.current_step / job.metrics.total_steps) * 100)
58
+ : 0;
59
+
60
+ const subtitle = isStarting
61
+ ? "starting…"
62
+ : isRunning
63
+ ? `started ${relativeTime(job.started_at)}`
64
+ : job.ended_at != null
65
+ ? `ended ${relativeTime(job.ended_at)}`
66
+ : present.label.toLowerCase();
67
+
68
+ const [checkpoints, setCheckpoints] = useState<JobCheckpoint[]>([]);
69
+ const [selectedStep, setSelectedStep] = useState<number | null>(null);
70
+
71
+ useEffect(() => {
72
+ if (job.checkpoint_count <= 0) {
73
+ setCheckpoints([]);
74
+ setSelectedStep(null);
75
+ return;
76
+ }
77
+ let cancelled = false;
78
+ listJobCheckpoints(baseUrl, fetchWithHeaders, job.id)
79
+ .then((cks) => {
80
+ if (cancelled) return;
81
+ setCheckpoints(cks);
82
+ if (cks.length > 0) {
83
+ const latest = cks[cks.length - 1].step;
84
+ setSelectedStep((prev) =>
85
+ prev != null && cks.some((c) => c.step === prev) ? prev : latest,
86
+ );
87
+ } else {
88
+ setSelectedStep(null);
89
+ }
90
+ })
91
+ .catch(() => {
92
+ if (!cancelled) {
93
+ setCheckpoints([]);
94
+ setSelectedStep(null);
95
+ }
96
+ });
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, [baseUrl, fetchWithHeaders, job.id, job.checkpoint_count]);
101
+
102
+ const handleAction = (e: React.MouseEvent) => {
103
+ e.stopPropagation();
104
+ if (isRunning) {
105
+ if (window.confirm("Stop this run?")) onStop(job.id);
106
+ } else {
107
+ if (window.confirm("Delete this run? This wipes the output directory.")) onDelete(job.id);
108
+ }
109
+ };
110
+
111
+ const handlePlay = (e: React.MouseEvent) => {
112
+ e.stopPropagation();
113
+ if (selectedStep == null) return;
114
+ onPlay(job, selectedStep);
115
+ };
116
+
117
+ const showProgressBar = isRunning;
118
+ const showInferenceRow = checkpoints.length > 0 && selectedStep != null;
119
+
120
+ return (
121
+ <Card
122
+ onClick={() => navigate(`/training/${job.id}`)}
123
+ className="bg-slate-800/50 border-slate-700 rounded-xl cursor-pointer hover:border-slate-500 transition-colors"
124
+ >
125
+ <CardContent className="p-4 space-y-3">
126
+ <div className="flex items-start justify-between gap-2">
127
+ <div className={`flex items-center gap-1.5 text-xs font-semibold ${present.color}`}>
128
+ <Icon className={`w-3.5 h-3.5 ${isRunning ? "animate-spin" : ""}`} />
129
+ {present.label}
130
+ </div>
131
+ {job.runner === "hf_cloud" && job.hf_job_url ? (
132
+ <Button
133
+ variant="ghost"
134
+ size="icon"
135
+ asChild
136
+ className="h-7 w-7 text-slate-400 hover:text-white"
137
+ aria-label="Open Hub job page"
138
+ >
139
+ <a
140
+ href={job.hf_job_url}
141
+ target="_blank"
142
+ rel="noopener noreferrer"
143
+ onClick={(e) => e.stopPropagation()}
144
+ >
145
+ <ExternalLink className="w-3.5 h-3.5" />
146
+ </a>
147
+ </Button>
148
+ ) : (
149
+ <Button
150
+ variant="ghost"
151
+ size="icon"
152
+ onClick={handleAction}
153
+ className="h-7 w-7 text-slate-400 hover:text-white"
154
+ aria-label={isRunning ? "Stop job" : "Delete job"}
155
+ >
156
+ {isRunning ? <Square className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
157
+ </Button>
158
+ )}
159
+ </div>
160
+ <div>
161
+ <div className="text-white font-semibold truncate" title={job.name}>
162
+ {job.name}
163
+ </div>
164
+ <div className="text-xs text-slate-400">{subtitle}</div>
165
+ </div>
166
+ {showProgressBar ? (
167
+ <div className="relative h-5 w-full overflow-hidden rounded-md bg-slate-900 border border-slate-700">
168
+ <div
169
+ className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-[width] duration-500"
170
+ style={{ width: `${progressPct}%` }}
171
+ />
172
+ <div className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white tabular-nums drop-shadow">
173
+ {isStarting ? "Training starting…" : `${progressPct.toFixed(1)}%`}
174
+ </div>
175
+ </div>
176
+ ) : null}
177
+ {showInferenceRow ? (
178
+ <div className="flex items-center gap-2">
179
+ <CheckpointDropdown
180
+ checkpoints={checkpoints}
181
+ selectedStep={selectedStep}
182
+ onChange={setSelectedStep}
183
+ />
184
+ <Button
185
+ size="icon"
186
+ onClick={handlePlay}
187
+ className="h-8 w-8 bg-green-500 hover:bg-green-600 text-white"
188
+ aria-label="Run inference with this checkpoint"
189
+ >
190
+ <Play className="w-4 h-4" />
191
+ </Button>
192
+ </div>
193
+ ) : null}
194
+ </CardContent>
195
+ </Card>
196
+ );
197
+ };
198
+
199
+ export default JobCard;
src/components/jobs/JobsSection.tsx ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { useApi } from "@/contexts/ApiContext";
5
+ import { useToast } from "@/hooks/use-toast";
6
+ import { useJobsChangedSignal } from "@/hooks/useJobsChangedSignal";
7
+ import {
8
+ HubJob,
9
+ HubModel,
10
+ JobProgressSnapshot,
11
+ JobRecord,
12
+ deleteJob,
13
+ listHubJobs,
14
+ listJobs,
15
+ stopJob,
16
+ } from "@/lib/jobsApi";
17
+ import JobCard from "./JobCard";
18
+ import HubJobCard from "./HubJobCard";
19
+ import HubModelCard from "./HubModelCard";
20
+ import InferenceModal from "@/components/landing/InferenceModal";
21
+ import { useRobots } from "@/hooks/useRobots";
22
+ import {
23
+ Collapsible,
24
+ CollapsibleContent,
25
+ CollapsibleTrigger,
26
+ } from "@/components/ui/collapsible";
27
+ import { ChevronRight, RefreshCw, Search } from "lucide-react";
28
+
29
+ const LIMIT = 10;
30
+
31
+ // Hub stages still doing work. Anything outside this set (COMPLETED, FAILED,
32
+ // CANCELED, …) gets demoted to UNTRACKED.
33
+ const HUB_ACTIVE_STAGES = new Set(["RUNNING", "QUEUED", "SCHEDULING"]);
34
+
35
+ const isJobActive = (j: JobRecord) =>
36
+ j.state === "running" || j.checkpoint_count > 0;
37
+
38
+ const isHubJobActive = (h: HubJob) =>
39
+ HUB_ACTIVE_STAGES.has((h.status?.stage ?? "").toUpperCase());
40
+
41
+ const JobsSection: React.FC = () => {
42
+ const { baseUrl, fetchWithHeaders } = useApi();
43
+ const { toast } = useToast();
44
+
45
+ const [jobs, setJobs] = useState<JobRecord[]>([]);
46
+ const [hubJobs, setHubJobs] = useState<HubJob[]>([]);
47
+ const [hubModels, setHubModels] = useState<HubModel[]>([]);
48
+ const [hubAuthenticated, setHubAuthenticated] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const [search, setSearch] = useState("");
51
+
52
+ const { selectedRecord } = useRobots();
53
+ const [inferenceModalOpen, setInferenceModalOpen] = useState(false);
54
+ const [inferenceJob, setInferenceJob] = useState<JobRecord | null>(null);
55
+ const [inferenceStep, setInferenceStep] = useState<number | null>(null);
56
+
57
+ const refresh = useCallback(async () => {
58
+ try {
59
+ const [next, hub] = await Promise.all([
60
+ listJobs(baseUrl, fetchWithHeaders, LIMIT),
61
+ listHubJobs(baseUrl, fetchWithHeaders),
62
+ ]);
63
+ setJobs(next);
64
+ setHubJobs(hub.jobs);
65
+ setHubModels(hub.models);
66
+ setHubAuthenticated(hub.authenticated);
67
+ setError(null);
68
+ } catch (e) {
69
+ setError(e instanceof Error ? e.message : String(e));
70
+ }
71
+ }, [baseUrl, fetchWithHeaders]);
72
+
73
+ // Initial fetch on mount + refetch when the tab regains focus. Backend
74
+ // pushes a `jobs_changed` WS event on every registry mutation, which
75
+ // covers any change originating on this machine. The focus refresh
76
+ // catches changes originating elsewhere (e.g. a job submitted from
77
+ // another tab or the HF dashboard) without burning the rate limit.
78
+ useEffect(() => {
79
+ refresh();
80
+ const onVisible = () => {
81
+ if (document.visibilityState === "visible") refresh();
82
+ };
83
+ document.addEventListener("visibilitychange", onVisible);
84
+ window.addEventListener("focus", refresh);
85
+ return () => {
86
+ document.removeEventListener("visibilitychange", onVisible);
87
+ window.removeEventListener("focus", refresh);
88
+ };
89
+ }, [refresh]);
90
+
91
+ const applyProgress = useCallback((snapshots: JobProgressSnapshot[]) => {
92
+ if (snapshots.length === 0) return;
93
+ setJobs((prev) => {
94
+ if (prev.length === 0) return prev;
95
+ const byId = new Map(snapshots.map((s) => [s.id, s]));
96
+ let mutated = false;
97
+ const next = prev.map((j) => {
98
+ const s = byId.get(j.id);
99
+ if (!s) return j;
100
+ mutated = true;
101
+ return {
102
+ ...j,
103
+ state: s.state,
104
+ metrics: s.metrics,
105
+ wandb_run_url: s.wandb_run_url,
106
+ checkpoint_count: s.checkpoint_count,
107
+ };
108
+ });
109
+ return mutated ? next : prev;
110
+ });
111
+ }, []);
112
+
113
+ useJobsChangedSignal(refresh, applyProgress);
114
+
115
+ const handleStop = async (id: string) => {
116
+ try {
117
+ await stopJob(baseUrl, fetchWithHeaders, id);
118
+ toast({ title: "Job stopping" });
119
+ refresh();
120
+ } catch (e) {
121
+ toast({
122
+ title: "Stop failed",
123
+ description: e instanceof Error ? e.message : String(e),
124
+ variant: "destructive",
125
+ });
126
+ }
127
+ };
128
+
129
+ const handlePlay = (job: JobRecord, step: number) => {
130
+ setInferenceJob(job);
131
+ setInferenceStep(step);
132
+ setInferenceModalOpen(true);
133
+ };
134
+
135
+ const handleDelete = async (id: string) => {
136
+ try {
137
+ await deleteJob(baseUrl, fetchWithHeaders, id);
138
+ toast({ title: "Job removed" });
139
+ refresh();
140
+ } catch (e) {
141
+ toast({
142
+ title: "Delete failed",
143
+ description: e instanceof Error ? e.message : String(e),
144
+ variant: "destructive",
145
+ });
146
+ }
147
+ };
148
+
149
+ const query = search.trim().toLowerCase();
150
+ const matchesQuery = useCallback(
151
+ (text: string | null | undefined) =>
152
+ !query || (text ?? "").toLowerCase().includes(query),
153
+ [query],
154
+ );
155
+
156
+ const filteredJobs = useMemo(
157
+ () => jobs.filter((j) => matchesQuery(j.name)),
158
+ [jobs, matchesQuery],
159
+ );
160
+ const filteredHubJobs = useMemo(
161
+ () =>
162
+ hubJobs.filter((h) =>
163
+ matchesQuery(h.docker_image ?? h.space_id ?? h.id),
164
+ ),
165
+ [hubJobs, matchesQuery],
166
+ );
167
+ const filteredHubModels = useMemo(
168
+ () => hubModels.filter((m) => matchesQuery(m.repo_id)),
169
+ [hubModels, matchesQuery],
170
+ );
171
+
172
+ const localJobs = useMemo(
173
+ () => filteredJobs.filter((j) => j.runner === "local"),
174
+ [filteredJobs],
175
+ );
176
+ const trackedCloudJobs = useMemo(
177
+ () => filteredJobs.filter((j) => j.runner === "hf_cloud"),
178
+ [filteredJobs],
179
+ );
180
+ // Hub jobs already mirrored by a local JobRecord get their richer card via
181
+ // trackedCloudJobs; everything else from the hub gets a plain HubJobCard.
182
+ const trackedHfJobIds = useMemo(
183
+ () =>
184
+ new Set(
185
+ trackedCloudJobs
186
+ .map((j) => j.hf_job_id)
187
+ .filter((id): id is string => !!id),
188
+ ),
189
+ [trackedCloudJobs],
190
+ );
191
+ const untrackedHubJobs = useMemo(
192
+ () => filteredHubJobs.filter((h) => !trackedHfJobIds.has(h.id)),
193
+ [filteredHubJobs, trackedHfJobIds],
194
+ );
195
+ // Hide model repos that map 1-to-1 to a tracked cloud job (those already
196
+ // appear via JobCard); the remainder are past trainings the registry no
197
+ // longer remembers.
198
+ const trackedRepoIds = useMemo(
199
+ () =>
200
+ new Set(
201
+ trackedCloudJobs
202
+ .map((j) => j.hf_repo_id)
203
+ .filter((id): id is string => !!id),
204
+ ),
205
+ [trackedCloudJobs],
206
+ );
207
+ const untrackedHubModels = useMemo(
208
+ () => filteredHubModels.filter((m) => !trackedRepoIds.has(m.repo_id)),
209
+ [filteredHubModels, trackedRepoIds],
210
+ );
211
+
212
+ // Active = running or has runnable checkpoints. Everything else collapses
213
+ // under UNTRACKED so the eye lands on what's still relevant.
214
+ const localActive = useMemo(() => localJobs.filter(isJobActive), [localJobs]);
215
+ const localUntracked = useMemo(
216
+ () => localJobs.filter((j) => !isJobActive(j)),
217
+ [localJobs],
218
+ );
219
+ const trackedCloudActive = useMemo(
220
+ () => trackedCloudJobs.filter(isJobActive),
221
+ [trackedCloudJobs],
222
+ );
223
+ const trackedCloudUntracked = useMemo(
224
+ () => trackedCloudJobs.filter((j) => !isJobActive(j)),
225
+ [trackedCloudJobs],
226
+ );
227
+ const untrackedHubActive = useMemo(
228
+ () => untrackedHubJobs.filter(isHubJobActive),
229
+ [untrackedHubJobs],
230
+ );
231
+ const untrackedHubInactive = useMemo(
232
+ () => untrackedHubJobs.filter((h) => !isHubJobActive(h)),
233
+ [untrackedHubJobs],
234
+ );
235
+
236
+ const untrackedCount =
237
+ localUntracked.length +
238
+ trackedCloudUntracked.length +
239
+ untrackedHubInactive.length;
240
+
241
+ return (
242
+ <section className="space-y-6">
243
+ <div className="flex items-center justify-between gap-3">
244
+ <h2 className="text-lg font-semibold text-white">Jobs</h2>
245
+ <div className="flex items-center gap-2">
246
+ <div className="relative">
247
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400 pointer-events-none" />
248
+ <Input
249
+ value={search}
250
+ onChange={(e) => setSearch(e.target.value)}
251
+ placeholder="Search jobs"
252
+ className="h-8 w-48 sm:w-60 pl-8 bg-slate-800/50 border-slate-700 text-sm text-white placeholder:text-slate-500"
253
+ aria-label="Search jobs"
254
+ />
255
+ </div>
256
+ <Button
257
+ variant="ghost"
258
+ size="icon"
259
+ onClick={refresh}
260
+ className="h-7 w-7 text-slate-400 hover:text-white"
261
+ aria-label="Refresh jobs"
262
+ >
263
+ <RefreshCw className="w-4 h-4" />
264
+ </Button>
265
+ </div>
266
+ </div>
267
+
268
+ {error ? <p className="text-sm text-red-300">Couldn't load jobs: {error}</p> : null}
269
+
270
+ <div className="space-y-3">
271
+ <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">
272
+ Local jobs
273
+ </h3>
274
+ {localActive.length === 0 ? (
275
+ <p className="text-sm text-slate-500">
276
+ {query
277
+ ? "No local jobs match your search."
278
+ : "No active local jobs. Start one from the Training page."}
279
+ </p>
280
+ ) : (
281
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
282
+ {localActive.map((job) => (
283
+ <JobCard
284
+ key={job.id}
285
+ job={job}
286
+ onStop={handleStop}
287
+ onDelete={handleDelete}
288
+ onPlay={handlePlay}
289
+ />
290
+ ))}
291
+ </div>
292
+ )}
293
+ </div>
294
+
295
+ <div className="border-t border-slate-700" />
296
+
297
+ <div className="space-y-3">
298
+ <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">
299
+ Online jobs
300
+ </h3>
301
+ {!hubAuthenticated && trackedCloudJobs.length === 0 ? (
302
+ <p className="text-sm text-slate-500">
303
+ Sign in with Hugging Face to see your cloud jobs.
304
+ </p>
305
+ ) : trackedCloudActive.length === 0 &&
306
+ untrackedHubActive.length === 0 &&
307
+ untrackedHubModels.length === 0 ? (
308
+ <p className="text-sm text-slate-500">
309
+ {query ? "No online jobs match your search." : "No active cloud jobs."}
310
+ </p>
311
+ ) : (
312
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
313
+ {trackedCloudActive.map((job) => (
314
+ <JobCard
315
+ key={job.id}
316
+ job={job}
317
+ onStop={handleStop}
318
+ onDelete={handleDelete}
319
+ onPlay={handlePlay}
320
+ />
321
+ ))}
322
+ {untrackedHubActive.map((job) => (
323
+ <HubJobCard key={job.id} job={job} />
324
+ ))}
325
+ {untrackedHubModels.map((model) => (
326
+ <HubModelCard key={model.repo_id} model={model} />
327
+ ))}
328
+ </div>
329
+ )}
330
+ </div>
331
+
332
+ {untrackedCount > 0 ? (
333
+ <Collapsible>
334
+ <CollapsibleTrigger className="group flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-slate-400 hover:text-white transition-colors">
335
+ <ChevronRight className="w-3.5 h-3.5 transition-transform group-data-[state=open]:rotate-90" />
336
+ Untracked ({untrackedCount})
337
+ </CollapsibleTrigger>
338
+ <CollapsibleContent className="pt-3">
339
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
340
+ {localUntracked.map((job) => (
341
+ <JobCard
342
+ key={job.id}
343
+ job={job}
344
+ onStop={handleStop}
345
+ onDelete={handleDelete}
346
+ onPlay={handlePlay}
347
+ />
348
+ ))}
349
+ {trackedCloudUntracked.map((job) => (
350
+ <JobCard
351
+ key={job.id}
352
+ job={job}
353
+ onStop={handleStop}
354
+ onDelete={handleDelete}
355
+ onPlay={handlePlay}
356
+ />
357
+ ))}
358
+ {untrackedHubInactive.map((job) => (
359
+ <HubJobCard key={job.id} job={job} />
360
+ ))}
361
+ </div>
362
+ </CollapsibleContent>
363
+ </Collapsible>
364
+ ) : null}
365
+
366
+ {inferenceJob ? (
367
+ <InferenceModal
368
+ open={inferenceModalOpen}
369
+ onOpenChange={setInferenceModalOpen}
370
+ robot={selectedRecord}
371
+ jobId={inferenceJob.id}
372
+ initialStep={inferenceStep}
373
+ />
374
+ ) : null}
375
+ </section>
376
+ );
377
+ };
378
+
379
+ export default JobsSection;