GitHub CI commited on
Commit ·
fc9bd9f
0
Parent(s):
Sync from leLab @ 7317f7103e3a9d7f45fe4c0d6e4660a8f9d295e3
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +40 -0
- .gitignore +48 -0
- Dockerfile +35 -0
- HTTPS_SETUP.md +219 -0
- LICENSE +202 -0
- README.md +20 -0
- __init__.py +0 -0
- components.json +20 -0
- eslint.config.js +29 -0
- index.html +19 -0
- package-lock.json +0 -0
- package.json +88 -0
- postcss.config.js +6 -0
- public/favicon.ico +0 -0
- public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png +0 -0
- public/placeholder.svg +1 -0
- public/robots.txt +14 -0
- public/so-101-urdf/CMakeLists.txt +37 -0
- public/so-101-urdf/README.md +45 -0
- public/so-101-urdf/config/joint_names_so_arm_urdf.yaml +1 -0
- public/so-101-urdf/joints_properties.xml +12 -0
- public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/base_so101_v2.stl +3 -0
- public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl +3 -0
- public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl +3 -0
- public/so-101-urdf/meshes/moving_jaw_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl +3 -0
- public/so-101-urdf/meshes/sts3215_03a_v1.stl +3 -0
- public/so-101-urdf/meshes/under_arm_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/upper_arm_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl +3 -0
- public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl +3 -0
- public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl +3 -0
- public/so-101-urdf/package.xml +26 -0
- public/so-101-urdf/urdf/so101_new_calib.urdf +435 -0
- src/.gitignore +1 -0
- src/App.tsx +61 -0
- src/components/Footer.tsx +68 -0
- src/components/Logo.tsx +19 -0
- src/components/SingleTabGuard.tsx +136 -0
- src/components/UrdfViewer.tsx +304 -0
- src/components/control/CommandBar.tsx +81 -0
- src/components/control/MetricsPanel.tsx +190 -0
- src/components/control/VisualizerPanel.tsx +46 -0
- src/components/jobs/CheckpointDropdown.tsx +54 -0
- src/components/jobs/HubJobCard.tsx +106 -0
- src/components/jobs/HubModelCard.tsx +75 -0
- src/components/jobs/JobCard.tsx +199 -0
- 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;
|