Skip to content

Commit 4312bf3

Browse files
committed
feat: replace device setup instructions with Launch Satellite button
Replace the verbose collapsible setup guide on device registration with a single 'Launch Satellite' button. Clicking the button calls the new POST /api/devices/{device_id}/launch-satellite endpoint which spawns the satellite daemon as a background subprocess. - Add POST /api/devices/{device_id}/launch-satellite endpoint (local-dev-only) - Endpoint verifies device ownership, launches daemon, tracks PID/log - Button shows spinner while launching, then shows PID and log path - Handles 'already_running' state gracefully - All 307 tests pass, ruff clean
1 parent 820d126 commit 4312bf3

File tree

2 files changed

+158
-55
lines changed

2 files changed

+158
-55
lines changed

functions/hub_api/app.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,108 @@ def api_delete_device(request: Request, device_id: str) -> dict:
10071007
return {"message": "Device deleted", "device_id": device_id}
10081008

10091009

1010+
class DeviceLaunchSatellite(BaseModel):
1011+
"""Request body for launching a satellite daemon for a device."""
1012+
1013+
device_token: str = Field(..., description="Device authentication token")
1014+
1015+
1016+
@app.post("/api/devices/{device_id}/launch-satellite", name="api_launch_satellite")
1017+
def api_launch_satellite(request: Request, device_id: str, body: DeviceLaunchSatellite) -> dict:
1018+
"""Launch the remote satellite daemon for a device.
1019+
1020+
This is a LOCAL-DEV-ONLY endpoint. It spawns the satellite daemon as a
1021+
background subprocess on the machine running the GUI server.
1022+
"""
1023+
import subprocess as _sp
1024+
1025+
user = _gui_get_user(request)
1026+
if not user:
1027+
raise HTTPException(status_code=401, detail="Not authenticated")
1028+
1029+
db = _get_db()
1030+
1031+
# Verify device exists and user has permission
1032+
rows = db.query(
1033+
"""
1034+
SELECT d.device_id, d.agent_id, d.name
1035+
FROM devices d
1036+
INNER JOIN agent_memberships m ON d.agent_id = m.agent_id
1037+
WHERE d.device_id = :device_id::uuid
1038+
AND m.user_id = :user_id::uuid
1039+
AND m.revoked_at IS NULL
1040+
""",
1041+
{"device_id": device_id, "user_id": str(user.user_id)},
1042+
)
1043+
if not rows:
1044+
raise HTTPException(status_code=404, detail="Device not found or permission denied")
1045+
1046+
ws_url = _cfg.ws_api_url
1047+
if not ws_url:
1048+
raise HTTPException(status_code=500, detail="WebSocket URL not configured (WS_API_URL)")
1049+
1050+
# Resolve the daemon script path relative to the repo root
1051+
repo_root = Path(__file__).parent.parent.parent
1052+
daemon_script = repo_root / "apps" / "remote_satellite" / "daemon.py"
1053+
if not daemon_script.exists():
1054+
raise HTTPException(status_code=500, detail="Satellite daemon script not found")
1055+
1056+
# Build the command
1057+
import sys
1058+
1059+
cmd = [
1060+
sys.executable,
1061+
str(daemon_script),
1062+
"--hub-ws-url",
1063+
ws_url,
1064+
"--device-token",
1065+
body.device_token,
1066+
]
1067+
1068+
# Launch as a background process
1069+
pid_file = repo_root / f".marvain-satellite-{device_id}.pid"
1070+
log_file = repo_root / f".marvain-satellite-{device_id}.log"
1071+
1072+
# Check if already running
1073+
if pid_file.exists():
1074+
try:
1075+
old_pid = int(pid_file.read_text().strip())
1076+
# Check if process is still alive
1077+
os.kill(old_pid, 0)
1078+
return {
1079+
"status": "already_running",
1080+
"pid": old_pid,
1081+
"device_id": device_id,
1082+
"message": f"Satellite daemon already running (PID {old_pid})",
1083+
}
1084+
except (ProcessLookupError, ValueError, OSError):
1085+
pid_file.unlink(missing_ok=True)
1086+
1087+
try:
1088+
with open(log_file, "w") as lf:
1089+
proc = _sp.Popen(
1090+
cmd,
1091+
stdin=_sp.DEVNULL,
1092+
stdout=lf,
1093+
stderr=_sp.STDOUT,
1094+
start_new_session=True,
1095+
)
1096+
pid_file.write_text(str(proc.pid))
1097+
logger.info(f"Launched satellite daemon for device {device_id} (PID {proc.pid})")
1098+
except Exception as e:
1099+
logger.error(f"Failed to launch satellite daemon: {e}")
1100+
raise HTTPException(status_code=500, detail=f"Failed to launch satellite: {e}")
1101+
1102+
return {
1103+
"status": "launched",
1104+
"pid": proc.pid,
1105+
"device_id": device_id,
1106+
"device_name": rows[0].get("name", ""),
1107+
"log_file": str(log_file),
1108+
"message": f"Satellite daemon started (PID {proc.pid})",
1109+
}
1110+
1111+
10101112
# ---------------------------------------------------------------------------
10111113
# Agents API endpoints (session-based auth for GUI)
10121114
# ---------------------------------------------------------------------------

functions/hub_api/templates/devices.html

Lines changed: 56 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -321,64 +321,20 @@ <h3 class="card-title" style="color: white;"><i class="fas fa-check-circle"></i>
321321
</div>
322322
</div>
323323
324-
<div class="form-group" style="margin-top: 1.5rem;">
325-
<label>Quick Start Command</label>
326-
<p class="text-muted" style="margin-bottom: 0.5rem;">Run this on the device to connect it to the hub:</p>
327-
<div class="input-group">
328-
<textarea class="form-control" readonly id="device-command-input" rows="2"
329-
style="font-family: monospace; font-size: 0.85rem; resize: none;">python apps/remote_satellite/daemon.py --hub-ws-url {{ ws_url }} --device-token ${result.token}</textarea>
330-
<button type="button" class="btn btn-outline" onclick="copyToClipboard('device-command-input', 'Command')">
331-
<i class="fas fa-copy"></i> Copy
332-
</button>
333-
</div>
324+
<div style="margin-top: 1.5rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;">
325+
<button type="button" class="btn btn-primary" id="launch-satellite-btn"
326+
onclick="launchSatellite('${result.device_id}', '${result.token}')"
327+
style="font-size: 1rem; padding: 0.75rem 1.5rem;">
328+
<i class="fas fa-rocket"></i> Launch Satellite
329+
</button>
330+
<span id="launch-satellite-status" style="color: var(--color-gray-300); font-size: 0.9rem;"></span>
331+
</div>
332+
<div id="launch-satellite-info" style="display: none; margin-top: 1rem; background: var(--color-gray-700); border-radius: var(--radius-md); padding: var(--spacing-md);">
334333
</div>
335-
336-
<details style="margin-top: 1.5rem; background: var(--color-gray-700); border-radius: var(--radius-md); padding: 0;">
337-
<summary style="cursor: pointer; color: var(--color-highlight); font-weight: 600; padding: var(--spacing-md); list-style: none; display: flex; align-items: center; gap: 0.5rem;">
338-
<i class="fas fa-rocket"></i> Launch Agent Worker — Full Setup Guide
339-
<i class="fas fa-chevron-down" style="margin-left: auto; font-size: 0.75rem;"></i>
340-
</summary>
341-
<div style="padding: 0 var(--spacing-md) var(--spacing-md);">
342-
<h4 style="margin-bottom: var(--spacing-sm); margin-top: var(--spacing-sm);">1. Prerequisites</h4>
343-
<ul style="margin-left: var(--spacing-lg); margin-bottom: var(--spacing-md); color: var(--color-gray-300);">
344-
<li>Python 3.11+ with the <code>marvain</code> conda environment</li>
345-
<li>LiveKit and OpenAI credentials configured in AWS Secrets Manager</li>
346-
<li>Network access to the Hub WebSocket endpoint</li>
347-
</ul>
348-
349-
<h4 style="margin-bottom: var(--spacing-sm);">2. Activate Environment</h4>
350-
<div class="input-group" style="margin-bottom: var(--spacing-md);">
351-
<textarea class="form-control" readonly id="device-env-cmd" rows="2"
352-
style="font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; resize: none; background: var(--color-gray-900);">cd /path/to/marvain
353-
source marvain_activate</textarea>
354-
<button type="button" class="btn btn-outline" onclick="copyToClipboard('device-env-cmd', 'Env command')">
355-
<i class="fas fa-copy"></i>
356-
</button>
357-
</div>
358-
359-
<h4 style="margin-bottom: var(--spacing-sm);">3. Start the Satellite Daemon</h4>
360-
<div class="input-group" style="margin-bottom: var(--spacing-md);">
361-
<textarea class="form-control" readonly id="device-daemon-cmd" rows="3"
362-
style="font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; resize: none; background: var(--color-gray-900);">python apps/remote_satellite/daemon.py \\
363-
--hub-ws-url {{ ws_url }} \\
364-
--device-token ${result.token}</textarea>
365-
<button type="button" class="btn btn-outline" onclick="copyToClipboard('device-daemon-cmd', 'Daemon command')">
366-
<i class="fas fa-copy"></i>
367-
</button>
368-
</div>
369-
370-
<h4 style="margin-bottom: var(--spacing-sm);">4. Verify Connection</h4>
371-
<p style="color: var(--color-gray-300); margin-bottom: var(--spacing-sm);">
372-
Once the daemon connects, the device status will change to
373-
<span class="badge badge-success" style="font-size: 0.75rem;"><i class="fas fa-circle"></i> Online</span>
374-
on this page. You can also use the <strong>Ping Device</strong> button on the device detail page to confirm connectivity.
375-
</p>
376-
</div>
377-
</details>
378334
379335
<div style="margin-top: 1.5rem; text-align: right;">
380-
<button type="button" class="btn btn-primary" onclick="dismissTokenResult()">
381-
<i class="fas fa-check"></i> Done - I've Copied the Token
336+
<button type="button" class="btn btn-outline" onclick="dismissTokenResult()">
337+
<i class="fas fa-check"></i> Done
382338
</button>
383339
</div>
384340
</div>
@@ -396,6 +352,51 @@ <h4 style="margin-bottom: var(--spacing-sm);">4. Verify Connection</h4>
396352
location.reload();
397353
}
398354

355+
async function launchSatellite(deviceId, deviceToken) {
356+
const btn = document.getElementById('launch-satellite-btn');
357+
const status = document.getElementById('launch-satellite-status');
358+
const info = document.getElementById('launch-satellite-info');
359+
360+
btn.disabled = true;
361+
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Launching…';
362+
status.textContent = '';
363+
364+
try {
365+
const resp = await fetch(`/api/devices/${deviceId}/launch-satellite`, {
366+
method: 'POST',
367+
headers: {'Content-Type': 'application/json'},
368+
body: JSON.stringify({device_token: deviceToken}),
369+
});
370+
const data = await resp.json();
371+
372+
if (!resp.ok) {
373+
throw new Error(data.detail || 'Launch failed');
374+
}
375+
376+
if (data.status === 'already_running') {
377+
btn.innerHTML = '<i class="fas fa-circle" style="color: var(--color-success);"></i> Already Running';
378+
btn.classList.remove('btn-primary');
379+
btn.classList.add('btn-outline');
380+
status.innerHTML = `PID <strong>${data.pid}</strong>`;
381+
Marvain.showToast('info', 'Already Running', data.message);
382+
} else {
383+
btn.innerHTML = '<i class="fas fa-check-circle"></i> Satellite Running';
384+
btn.classList.remove('btn-primary');
385+
btn.classList.add('btn-outline');
386+
btn.style.borderColor = 'var(--color-success)';
387+
btn.style.color = 'var(--color-success)';
388+
status.innerHTML = `PID <strong>${data.pid}</strong>`;
389+
info.style.display = 'block';
390+
info.innerHTML = `<p style="margin:0; color: var(--color-gray-300); font-size: 0.85rem;"><i class="fas fa-file-alt"></i> Log: <code>${data.log_file}</code></p>`;
391+
Marvain.showToast('success', 'Launched', data.message);
392+
}
393+
} catch (err) {
394+
btn.disabled = false;
395+
btn.innerHTML = '<i class="fas fa-rocket"></i> Launch Satellite';
396+
Marvain.showToast('error', 'Launch Failed', err.message);
397+
}
398+
}
399+
399400
function copyToClipboard(inputId, label) {
400401
const input = document.getElementById(inputId);
401402
input.select();

0 commit comments

Comments
 (0)