infinicode 2.8.37 → 2.8.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +1041 -28
- package/dist/robopark/add-robot.d.ts +2 -0
- package/dist/robopark/add-robot.js +9 -0
- package/dist/robopark/secrets.d.ts +8 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +22 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +770 -114
- package/packages/robopark/scheduler/robot_supervisor.py +424 -15
|
@@ -5,6 +5,7 @@ Manages robot fleet, LiveKit servers, and session orchestration
|
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
7
|
import asyncio
|
|
8
|
+
import contextvars
|
|
8
9
|
import json
|
|
9
10
|
import logging
|
|
10
11
|
import re
|
|
@@ -33,9 +34,15 @@ DB_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "scheduler.
|
|
|
33
34
|
# Path to a ROBOVOICE-style settings file the scheduler reads for characters.
|
|
34
35
|
# In production this should be a bind-mount to ROBOVOICE-main/settings.json (or .default.json).
|
|
35
36
|
ROBOVOICE_SETTINGS_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "robovoice_settings.json")
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
37
|
+
# Shared credential used only by the ROBOVOICE worker to refresh an active
|
|
38
|
+
# RoboPark session. Device endpoints continue to require device tokens. If the
|
|
39
|
+
# environment does not provide it, lifespan() creates/loads this local secret
|
|
40
|
+
# file so a bare `robopark serve` is safe to run without manual secret wiring.
|
|
41
|
+
ROBOPARK_AGENT_TOKEN = os.getenv("ROBOPARK_AGENT_TOKEN", "").strip()
|
|
42
|
+
ROBOPARK_AGENT_TOKEN_FILE = os.getenv(
|
|
43
|
+
"ROBOPARK_AGENT_TOKEN_FILE",
|
|
44
|
+
os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), ".robopark-agent-token"),
|
|
45
|
+
)
|
|
39
46
|
|
|
40
47
|
# ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
|
|
41
48
|
# both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
|
|
@@ -387,6 +394,31 @@ async def init_db():
|
|
|
387
394
|
PRIMARY KEY (device_id, service_name)
|
|
388
395
|
);
|
|
389
396
|
|
|
397
|
+
-- ── Operator shell (C1 gap-fill) ──
|
|
398
|
+
-- When the operator clicks "Tail logs" or runs a diagnostic
|
|
399
|
+
-- command in the dashboard, we insert a row here. The robot
|
|
400
|
+
-- supervisor's next status-report poll receives the row in
|
|
401
|
+
-- its `commands` response, runs the request on a background
|
|
402
|
+
-- thread, and POSTs the result back to /api/devices/{id}/
|
|
403
|
+
-- supervisor-output which updates the `result` column. The
|
|
404
|
+
-- dashboard polls GET /api/robots/{id}/shell/result/{req_id}
|
|
405
|
+
-- for the response, with an 8s budget.
|
|
406
|
+
--
|
|
407
|
+
-- `kind` discriminates: tail_logs (read log file) vs
|
|
408
|
+
-- shell_run (execute an allowlisted diagnostic command).
|
|
409
|
+
CREATE TABLE IF NOT EXISTS shell_requests (
|
|
410
|
+
id TEXT PRIMARY KEY,
|
|
411
|
+
device_id TEXT NOT NULL,
|
|
412
|
+
service_name TEXT,
|
|
413
|
+
kind TEXT NOT NULL,
|
|
414
|
+
params TEXT NOT NULL DEFAULT '{}',
|
|
415
|
+
requested_at TEXT NOT NULL,
|
|
416
|
+
completed_at TEXT,
|
|
417
|
+
result_ok INTEGER,
|
|
418
|
+
result_payload TEXT
|
|
419
|
+
);
|
|
420
|
+
CREATE INDEX IF NOT EXISTS idx_shell_requests_device ON shell_requests(device_id, requested_at);
|
|
421
|
+
|
|
390
422
|
CREATE TABLE IF NOT EXISTS history_log (
|
|
391
423
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
392
424
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -561,18 +593,30 @@ async def init_db():
|
|
|
561
593
|
# =============================================================================
|
|
562
594
|
|
|
563
595
|
@asynccontextmanager
|
|
564
|
-
async def lifespan(app: FastAPI):
|
|
565
|
-
await init_db()
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
596
|
+
async def lifespan(app: FastAPI):
|
|
597
|
+
await init_db()
|
|
598
|
+
global ROBOPARK_AGENT_TOKEN
|
|
599
|
+
if not ROBOPARK_AGENT_TOKEN:
|
|
600
|
+
try:
|
|
601
|
+
if os.path.isfile(ROBOPARK_AGENT_TOKEN_FILE):
|
|
602
|
+
with open(ROBOPARK_AGENT_TOKEN_FILE, "r", encoding="utf-8") as f:
|
|
603
|
+
ROBOPARK_AGENT_TOKEN = f.read().strip()
|
|
604
|
+
if not ROBOPARK_AGENT_TOKEN:
|
|
605
|
+
ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
|
|
606
|
+
os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
|
|
607
|
+
with open(ROBOPARK_AGENT_TOKEN_FILE, "w", encoding="utf-8") as f:
|
|
608
|
+
f.write(ROBOPARK_AGENT_TOKEN + "\n")
|
|
609
|
+
try:
|
|
610
|
+
os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
logger.info("Generated RoboPark agent secret in the scheduler data directory")
|
|
614
|
+
except OSError as exc:
|
|
615
|
+
logger.error("Could not load/create RoboPark agent secret: %s", exc)
|
|
616
|
+
if not ROBOPARK_AGENT_TOKEN:
|
|
617
|
+
logger.warning(
|
|
618
|
+
"RoboPark agent secret is unavailable; voice-agent keepalive calls will 401."
|
|
619
|
+
)
|
|
576
620
|
asyncio.create_task(metrics_collector())
|
|
577
621
|
asyncio.create_task(health_checker())
|
|
578
622
|
yield
|
|
@@ -597,8 +641,60 @@ app.add_middleware(
|
|
|
597
641
|
allow_credentials=_allow_credentials,
|
|
598
642
|
allow_methods=["*"],
|
|
599
643
|
allow_headers=["*"],
|
|
644
|
+
# X-Operator carries the per-action attribution. CORS preflight
|
|
645
|
+
# must allow it or the dashboard's rpAction() fetches will fail.
|
|
646
|
+
expose_headers=["X-Operator"],
|
|
600
647
|
)
|
|
601
648
|
|
|
649
|
+
|
|
650
|
+
# ── Operator-actor middleware (C3) ──
|
|
651
|
+
# Reads the X-Operator header on every request and stores a sanitized
|
|
652
|
+
# version in a contextvar. The header is informational only — the
|
|
653
|
+
# scheduler is a single-tenant surface; the actor is recorded in the
|
|
654
|
+
# audit log so "who clicked End-session?" is answerable. The header
|
|
655
|
+
# value is sanitized at this boundary (trimmed, control chars stripped,
|
|
656
|
+
# capped at 64 chars) so the value reaching the database is always a
|
|
657
|
+
# safe printable string. An absent or invalid header leaves the
|
|
658
|
+
# contextvar unset; _log_history then uses the call-site default
|
|
659
|
+
# (usually "operator").
|
|
660
|
+
class _OperatorActorMiddleware:
|
|
661
|
+
def __init__(self, app):
|
|
662
|
+
self.app = app
|
|
663
|
+
async def __call__(self, scope, receive, send):
|
|
664
|
+
if scope.get("type") == "http":
|
|
665
|
+
raw = None
|
|
666
|
+
# ASGI scope headers is a list of (name, value) tuples where
|
|
667
|
+
# value is bytes — except when a header has been repeated, in
|
|
668
|
+
# which case some servers emit (name, [b"a", b"b"]). Build a
|
|
669
|
+
# case-insensitive map that flattens both shapes.
|
|
670
|
+
for k, v in (scope.get("headers") or []):
|
|
671
|
+
if not isinstance(k, (bytes, bytearray)):
|
|
672
|
+
continue
|
|
673
|
+
if k.lower() != b"x-operator":
|
|
674
|
+
continue
|
|
675
|
+
if isinstance(v, (list, tuple)):
|
|
676
|
+
v = b", ".join(bytes(x) for x in v if isinstance(x, (bytes, bytearray)))
|
|
677
|
+
if not isinstance(v, (bytes, bytearray)):
|
|
678
|
+
continue
|
|
679
|
+
raw = bytes(v)
|
|
680
|
+
break
|
|
681
|
+
actor = None
|
|
682
|
+
if raw is not None:
|
|
683
|
+
try:
|
|
684
|
+
actor = _sanitize_actor_name(raw.decode("utf-8", errors="replace"))
|
|
685
|
+
except Exception:
|
|
686
|
+
actor = None
|
|
687
|
+
token = _current_actor_override.set(actor)
|
|
688
|
+
try:
|
|
689
|
+
await self.app(scope, receive, send)
|
|
690
|
+
finally:
|
|
691
|
+
_current_actor_override.reset(token)
|
|
692
|
+
else:
|
|
693
|
+
await self.app(scope, receive, send)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
app.add_middleware(_OperatorActorMiddleware)
|
|
697
|
+
|
|
602
698
|
# WebSocket clients for real-time updates
|
|
603
699
|
ws_clients: List[WebSocket] = []
|
|
604
700
|
|
|
@@ -693,15 +789,17 @@ async def robot_heartbeat(robot_id: str, ip_address: Optional[str] = None):
|
|
|
693
789
|
return {"status": "ok"}
|
|
694
790
|
|
|
695
791
|
@app.post("/api/robots/{robot_id}/request-session")
|
|
696
|
-
async def request_session(robot_id: str,
|
|
697
|
-
authorization: Optional[str] = Header(default=None)):
|
|
792
|
+
async def request_session(robot_id: str,
|
|
793
|
+
authorization: Optional[str] = Header(default=None)):
|
|
698
794
|
"""Robot requests a session after scene detection.
|
|
699
795
|
|
|
700
796
|
Requires a valid enrolled-device Bearer token. The response never contains
|
|
701
797
|
the LiveKit api_key/api_secret; instead the scheduler mints a short-lived
|
|
702
798
|
JWT the robot uses to join the room."""
|
|
703
|
-
if not await _authorize_fleet(authorization):
|
|
704
|
-
raise HTTPException(401, "Invalid or missing device token")
|
|
799
|
+
if not await _authorize_fleet(authorization):
|
|
800
|
+
raise HTTPException(401, "Invalid or missing device token")
|
|
801
|
+
if not await get_production_mode():
|
|
802
|
+
raise HTTPException(403, "production_mode_off")
|
|
705
803
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
706
804
|
db.row_factory = aiosqlite.Row
|
|
707
805
|
|
|
@@ -860,13 +958,24 @@ async def simulate_trigger(robot_id: str):
|
|
|
860
958
|
return {"queued": False, "reason": "production_mode_off"}
|
|
861
959
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
862
960
|
db.row_factory = aiosqlite.Row
|
|
863
|
-
async with db.execute(
|
|
864
|
-
"SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
|
|
865
|
-
) as c:
|
|
866
|
-
robot = await c.fetchone()
|
|
867
|
-
if not robot:
|
|
868
|
-
|
|
869
|
-
|
|
961
|
+
async with db.execute(
|
|
962
|
+
"SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
|
|
963
|
+
) as c:
|
|
964
|
+
robot = await c.fetchone()
|
|
965
|
+
if not robot:
|
|
966
|
+
async with db.execute("SELECT id, name, character_id FROM devices WHERE id = ?", (robot_id,)) as c:
|
|
967
|
+
device = await c.fetchone()
|
|
968
|
+
if not device:
|
|
969
|
+
raise HTTPException(404, "Robot not found")
|
|
970
|
+
await db.execute(
|
|
971
|
+
"INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
|
|
972
|
+
(device["id"], device["name"], device["character_id"]),
|
|
973
|
+
)
|
|
974
|
+
await db.commit()
|
|
975
|
+
robot = {"id": device["id"], "current_session_id": None}
|
|
976
|
+
if not await _effective_device_production_mode(db, robot_id):
|
|
977
|
+
return {"queued": False, "reason": "device_production_mode_off"}
|
|
978
|
+
if robot["current_session_id"]:
|
|
870
979
|
return {"queued": False, "reason": "session_active"}
|
|
871
980
|
await db.execute(
|
|
872
981
|
"INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
|
|
@@ -1542,14 +1651,16 @@ async def health_checker():
|
|
|
1542
1651
|
# =============================================================================
|
|
1543
1652
|
|
|
1544
1653
|
@app.get("/api/settings")
|
|
1545
|
-
async def get_settings():
|
|
1546
|
-
"""Public settings
|
|
1547
|
-
pm = await get_production_mode()
|
|
1548
|
-
has_default_token = bool(await get_setting("default_enrollment_token"))
|
|
1549
|
-
return {
|
|
1550
|
-
"production_mode": pm,
|
|
1551
|
-
"default_enrollment_token_set": has_default_token,
|
|
1552
|
-
|
|
1654
|
+
async def get_settings():
|
|
1655
|
+
"""Public settings with secret values reduced to status only."""
|
|
1656
|
+
pm = await get_production_mode()
|
|
1657
|
+
has_default_token = bool(await get_setting("default_enrollment_token"))
|
|
1658
|
+
return {
|
|
1659
|
+
"production_mode": pm,
|
|
1660
|
+
"default_enrollment_token_set": has_default_token,
|
|
1661
|
+
"agent_token_configured": bool(ROBOPARK_AGENT_TOKEN),
|
|
1662
|
+
"agent_token_source": "environment" if os.getenv("ROBOPARK_AGENT_TOKEN", "").strip() else "scheduler_secret_file",
|
|
1663
|
+
}
|
|
1553
1664
|
|
|
1554
1665
|
@app.put("/api/settings")
|
|
1555
1666
|
async def update_settings(payload: SettingsPayload):
|
|
@@ -1573,14 +1684,40 @@ async def update_settings(payload: SettingsPayload):
|
|
|
1573
1684
|
await _log_history("settings", "settings", "production_mode", "updated", "operator", f"value={payload.production_mode}")
|
|
1574
1685
|
return {"status": "ok", "production_mode": payload.production_mode}
|
|
1575
1686
|
|
|
1576
|
-
@app.post("/api/settings/enrollment-token/rotate")
|
|
1577
|
-
async def rotate_enrollment_token():
|
|
1687
|
+
@app.post("/api/settings/enrollment-token/rotate")
|
|
1688
|
+
async def rotate_enrollment_token():
|
|
1578
1689
|
"""Generate a fresh default enrollment token (rotates the secret)."""
|
|
1579
1690
|
token = secrets.token_urlsafe(24)
|
|
1580
1691
|
await set_setting("default_enrollment_token", _hash(token))
|
|
1581
1692
|
logger.info("Default enrollment token rotated")
|
|
1582
1693
|
await _log_history("settings", "settings", "default_enrollment_token", "rotated", "operator")
|
|
1583
|
-
return {"enrollment_token": token}
|
|
1694
|
+
return {"enrollment_token": token}
|
|
1695
|
+
|
|
1696
|
+
@app.post("/api/settings/agent-token/provision")
|
|
1697
|
+
async def provision_agent_token(payload: Optional[dict] = None):
|
|
1698
|
+
"""Return the worker secret once so the UI/CLI can provision ROBOVOICE.
|
|
1699
|
+
|
|
1700
|
+
The value is never included in normal settings responses or logs. Rotation
|
|
1701
|
+
is explicit because existing workers would otherwise lose connectivity.
|
|
1702
|
+
"""
|
|
1703
|
+
global ROBOPARK_AGENT_TOKEN
|
|
1704
|
+
rotate = bool((payload or {}).get("rotate"))
|
|
1705
|
+
if rotate or not ROBOPARK_AGENT_TOKEN:
|
|
1706
|
+
ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
|
|
1707
|
+
os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
|
|
1708
|
+
temp_path = ROBOPARK_AGENT_TOKEN_FILE + ".tmp"
|
|
1709
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
1710
|
+
f.write(ROBOPARK_AGENT_TOKEN + "\n")
|
|
1711
|
+
os.replace(temp_path, ROBOPARK_AGENT_TOKEN_FILE)
|
|
1712
|
+
try:
|
|
1713
|
+
os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
|
|
1714
|
+
except OSError:
|
|
1715
|
+
pass
|
|
1716
|
+
await _log_history("settings", "settings", "agent_token", "rotated", "operator")
|
|
1717
|
+
return {
|
|
1718
|
+
"agent_token": ROBOPARK_AGENT_TOKEN,
|
|
1719
|
+
"warning": "Shown once by this response. Store it only in the ROBOVOICE worker environment.",
|
|
1720
|
+
}
|
|
1584
1721
|
|
|
1585
1722
|
@app.get("/api/history")
|
|
1586
1723
|
async def get_history(category: Optional[str] = None, limit: int = 200):
|
|
@@ -2181,18 +2318,63 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
|
|
|
2181
2318
|
|
|
2182
2319
|
async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
|
|
2183
2320
|
action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
|
|
2184
|
-
"""Append an immutable audit/history record.
|
|
2321
|
+
"""Append an immutable audit/history record.
|
|
2322
|
+
|
|
2323
|
+
The actor argument is overridden by `_current_actor_override` (a
|
|
2324
|
+
contextvar set by the FastAPI middleware from the X-Operator
|
|
2325
|
+
header) when present. The middleware is the only writer to the
|
|
2326
|
+
contextvar, so an untrusted caller cannot impersonate another
|
|
2327
|
+
operator by setting it directly. Falls back to the call-site
|
|
2328
|
+
`actor` argument when no override is set (e.g. system events)."""
|
|
2329
|
+
final_actor = _current_actor_override.get() or actor
|
|
2185
2330
|
try:
|
|
2186
2331
|
async with aiosqlite.connect(DB_PATH) as db:
|
|
2187
2332
|
await db.execute(
|
|
2188
2333
|
"""INSERT INTO history_log (timestamp, category, entity_type, entity_id, action, actor, details)
|
|
2189
2334
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
2190
|
-
(datetime.utcnow().isoformat(), category, entity_type, entity_id, action,
|
|
2335
|
+
(datetime.utcnow().isoformat(), category, entity_type, entity_id, action, final_actor, details),
|
|
2191
2336
|
)
|
|
2192
2337
|
await db.commit()
|
|
2193
2338
|
except Exception as e:
|
|
2194
2339
|
logger.warning(f"history log failed: {e}")
|
|
2195
2340
|
|
|
2341
|
+
|
|
2342
|
+
# Per-request operator name. Set by the operator_actor_middleware (which
|
|
2343
|
+
# reads the X-Operator header) and read by _log_history. Sanitized at
|
|
2344
|
+
# the middleware boundary so the value reaching the database is always
|
|
2345
|
+
# a safe printable string.
|
|
2346
|
+
_current_actor_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("current_actor", default=None)
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _sanitize_actor_name(name: Optional[str]) -> Optional[str]:
|
|
2350
|
+
"""Trim, strip control chars, cap at 64 chars. Returns None if the
|
|
2351
|
+
result is empty so the caller can fall back to the default actor."""
|
|
2352
|
+
if not name:
|
|
2353
|
+
return None
|
|
2354
|
+
s = str(name).strip()
|
|
2355
|
+
if not s:
|
|
2356
|
+
return None
|
|
2357
|
+
# Allow printable ASCII + tab; strip everything else. Newlines and
|
|
2358
|
+
# CR would split a single history row into two display lines, so
|
|
2359
|
+
# we collapse them to a single space.
|
|
2360
|
+
out = []
|
|
2361
|
+
for c in s:
|
|
2362
|
+
if c == "\t" or c == " ":
|
|
2363
|
+
out.append(c)
|
|
2364
|
+
elif 0x20 <= ord(c) < 0x7f:
|
|
2365
|
+
out.append(c)
|
|
2366
|
+
else:
|
|
2367
|
+
# collapse any other whitespace-ish char to a space
|
|
2368
|
+
if c in ("\n", "\r", "\v", "\f"):
|
|
2369
|
+
out.append(" ")
|
|
2370
|
+
# drop anything else silently
|
|
2371
|
+
cleaned = "".join(out).strip()
|
|
2372
|
+
if not cleaned:
|
|
2373
|
+
return None
|
|
2374
|
+
if len(cleaned) > 64:
|
|
2375
|
+
cleaned = cleaned[:64]
|
|
2376
|
+
return cleaned
|
|
2377
|
+
|
|
2196
2378
|
async def _authorize_fleet(authorization: Optional[str]) -> bool:
|
|
2197
2379
|
"""Verify a Bearer token matches ANY enrolled device token (fleet auth).
|
|
2198
2380
|
|
|
@@ -2414,12 +2596,55 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
|
|
|
2414
2596
|
# "restart" on a service) onto this same request/response cycle --
|
|
2415
2597
|
# the robot already calls this every 10s, no separate poll needed.
|
|
2416
2598
|
# Consumed immediately: read then delete, so a command fires once.
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2599
|
+
# Backward-compat: rows may or may not have a `kind` column on
|
|
2600
|
+
# older DBs. Default to the original "supervisor_action" kind
|
|
2601
|
+
# (treated as restart / start / stop on the service) when missing.
|
|
2602
|
+
pending = []
|
|
2603
|
+
try:
|
|
2604
|
+
async with db.execute(
|
|
2605
|
+
"SELECT service_name, action, kind, params, id FROM shell_requests "
|
|
2606
|
+
"WHERE device_id = ? AND completed_at IS NULL ORDER BY requested_at",
|
|
2607
|
+
(device_id,),
|
|
2608
|
+
) as c:
|
|
2609
|
+
shell_rows = [dict(r) for r in await c.fetchall()]
|
|
2610
|
+
for r in shell_rows:
|
|
2611
|
+
pending.append({
|
|
2612
|
+
"service_name": r.get("service_name") or "",
|
|
2613
|
+
"action": "run",
|
|
2614
|
+
"kind": r.get("kind") or "shell_run",
|
|
2615
|
+
"params": json.loads(r["params"]) if r.get("params") else {},
|
|
2616
|
+
"id": r["id"],
|
|
2617
|
+
})
|
|
2618
|
+
except Exception:
|
|
2619
|
+
shell_rows = []
|
|
2620
|
+
try:
|
|
2621
|
+
async with db.execute(
|
|
2622
|
+
"SELECT service_name, action FROM supervisor_commands WHERE device_id = ?", (device_id,)
|
|
2623
|
+
) as c:
|
|
2624
|
+
for r in await c.fetchall():
|
|
2625
|
+
pending.append({"service_name": r["service_name"], "action": r["action"], "kind": "supervisor_action"})
|
|
2626
|
+
except Exception:
|
|
2627
|
+
pass
|
|
2421
2628
|
if pending:
|
|
2422
|
-
|
|
2629
|
+
# Mark shell requests as "in flight" so the dashboard can
|
|
2630
|
+
# distinguish "queued" from "still queued" (the row is read
|
|
2631
|
+
# but not yet completed). supervisor_commands rows are
|
|
2632
|
+
# one-shot and are deleted to mirror the previous behavior.
|
|
2633
|
+
try:
|
|
2634
|
+
ids = [p["id"] for p in pending if p.get("id")]
|
|
2635
|
+
if ids:
|
|
2636
|
+
placeholders = ",".join("?" for _ in ids)
|
|
2637
|
+
await db.execute(
|
|
2638
|
+
f"UPDATE shell_requests SET requested_at = requested_at "
|
|
2639
|
+
f"WHERE id IN ({placeholders})",
|
|
2640
|
+
ids,
|
|
2641
|
+
)
|
|
2642
|
+
except Exception:
|
|
2643
|
+
pass
|
|
2644
|
+
try:
|
|
2645
|
+
await db.execute("DELETE FROM supervisor_commands WHERE device_id = ?", (device_id,))
|
|
2646
|
+
except Exception:
|
|
2647
|
+
pass
|
|
2423
2648
|
await db.commit()
|
|
2424
2649
|
await broadcast("supervisor_status", {"device_id": device_id, "services": blob})
|
|
2425
2650
|
return {"status": "ok", "commands": pending}
|
|
@@ -2446,6 +2671,244 @@ async def restart_device_service(device_id: str, service_name: str):
|
|
|
2446
2671
|
await _log_history("device", "device", device_id, "service_restart_queued", "operator", f"service={service_name}")
|
|
2447
2672
|
return {"status": "queued", "service": service_name}
|
|
2448
2673
|
|
|
2674
|
+
|
|
2675
|
+
# ── Operator shell (C1) ──
|
|
2676
|
+
# Endpoints the dashboard calls to tail logs and run a small allowlisted
|
|
2677
|
+
# set of diagnostic commands on a specific robot. Commands are queued in
|
|
2678
|
+
# the `shell_requests` table; the robot's supervisor picks them up on its
|
|
2679
|
+
# next status-report poll, runs them, and POSTs results back to
|
|
2680
|
+
# /api/devices/{id}/supervisor-output. The dashboard polls
|
|
2681
|
+
# /api/robots/{id}/shell/result/{request_id} with an 8s budget.
|
|
2682
|
+
|
|
2683
|
+
class ShellRequest(BaseModel):
|
|
2684
|
+
kind: str # "tail_logs" | "shell_run"
|
|
2685
|
+
service: Optional[str] = None
|
|
2686
|
+
params: Optional[dict] = None
|
|
2687
|
+
|
|
2688
|
+
|
|
2689
|
+
async def _queue_shell_request_async(device_id: str, kind: str, service: Optional[str], params: dict) -> str:
|
|
2690
|
+
"""Insert a shell request row and return its id. Picked up by the
|
|
2691
|
+
robot's supervisor on its next status-report poll."""
|
|
2692
|
+
request_id = secrets.token_urlsafe(12)
|
|
2693
|
+
now = datetime.utcnow().isoformat()
|
|
2694
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2695
|
+
await db.execute(
|
|
2696
|
+
"""INSERT INTO shell_requests
|
|
2697
|
+
(id, device_id, service_name, kind, params, requested_at)
|
|
2698
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
2699
|
+
(request_id, device_id, service, kind, json.dumps(params or {}), now),
|
|
2700
|
+
)
|
|
2701
|
+
await db.commit()
|
|
2702
|
+
return request_id
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
@app.post("/api/robots/{robot_id}/shell/tail")
|
|
2706
|
+
async def robot_shell_tail(robot_id: str, payload: ShellRequest):
|
|
2707
|
+
"""Queue a tail-logs request for a robot. Returns a request_id the
|
|
2708
|
+
dashboard polls to get the result.
|
|
2709
|
+
|
|
2710
|
+
robot_id == device_id in this codebase (the dashboard's "robot" id is
|
|
2711
|
+
the same value as /api/devices rows)."""
|
|
2712
|
+
device_id = robot_id
|
|
2713
|
+
params = dict(payload.params or {})
|
|
2714
|
+
if "lines" not in params:
|
|
2715
|
+
params["lines"] = 200
|
|
2716
|
+
request_id = await _queue_shell_request_async(device_id, "tail_logs", payload.service, params)
|
|
2717
|
+
return {"status": "queued", "request_id": request_id, "device_id": device_id}
|
|
2718
|
+
|
|
2719
|
+
|
|
2720
|
+
@app.post("/api/robots/{robot_id}/shell/exec")
|
|
2721
|
+
async def robot_shell_exec(robot_id: str, payload: ShellRequest):
|
|
2722
|
+
"""Queue a shell-run request for a robot. Returns a request_id the
|
|
2723
|
+
dashboard polls to get the result."""
|
|
2724
|
+
device_id = robot_id
|
|
2725
|
+
params = dict(payload.params or {})
|
|
2726
|
+
if not params.get("name"):
|
|
2727
|
+
raise HTTPException(400, "params.name is required (e.g. 'uptime', 'free', 'ps', 'tail')")
|
|
2728
|
+
request_id = await _queue_shell_request_async(device_id, "shell_run", payload.service, params)
|
|
2729
|
+
return {"status": "queued", "request_id": request_id, "device_id": device_id}
|
|
2730
|
+
|
|
2731
|
+
|
|
2732
|
+
@app.get("/api/robots/{robot_id}/shell/result/{request_id}")
|
|
2733
|
+
async def robot_shell_result(robot_id: str, request_id: str):
|
|
2734
|
+
"""Return the result of a previously-queued shell request. The
|
|
2735
|
+
dashboard polls this with an 8s budget; we reply as soon as the
|
|
2736
|
+
robot has reported the result back."""
|
|
2737
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2738
|
+
db.row_factory = aiosqlite.Row
|
|
2739
|
+
async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
|
|
2740
|
+
r = await c.fetchone()
|
|
2741
|
+
if not r or r["device_id"] != robot_id:
|
|
2742
|
+
raise HTTPException(404, "No such shell request for this robot")
|
|
2743
|
+
out = {
|
|
2744
|
+
"request_id": r["id"],
|
|
2745
|
+
"device_id": r["device_id"],
|
|
2746
|
+
"service": r["service_name"],
|
|
2747
|
+
"kind": r["kind"],
|
|
2748
|
+
"params": json.loads(r["params"]) if r["params"] else {},
|
|
2749
|
+
"requested_at": r["requested_at"],
|
|
2750
|
+
"completed": r["completed_at"] is not None,
|
|
2751
|
+
"completed_at": r["completed_at"],
|
|
2752
|
+
"result": None,
|
|
2753
|
+
}
|
|
2754
|
+
if r["completed_at"] is not None:
|
|
2755
|
+
try:
|
|
2756
|
+
out["result"] = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
|
|
2757
|
+
except Exception:
|
|
2758
|
+
out["result"] = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
|
|
2759
|
+
return out
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
# Long-poll variant: holds the connection open until the result arrives
|
|
2763
|
+
# (or the 8s budget elapses), so the dashboard can wait without polling
|
|
2764
|
+
# in a tight loop.
|
|
2765
|
+
@app.get("/api/robots/{robot_id}/shell/wait/{request_id}")
|
|
2766
|
+
async def robot_shell_wait(robot_id: str, request_id: str, timeout: float = 8.0):
|
|
2767
|
+
deadline = asyncio.get_event_loop().time() + max(0.5, min(15.0, timeout))
|
|
2768
|
+
while True:
|
|
2769
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2770
|
+
db.row_factory = aiosqlite.Row
|
|
2771
|
+
async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
|
|
2772
|
+
r = await c.fetchone()
|
|
2773
|
+
if not r or r["device_id"] != robot_id:
|
|
2774
|
+
raise HTTPException(404, "No such shell request for this robot")
|
|
2775
|
+
if r["completed_at"] is not None:
|
|
2776
|
+
try:
|
|
2777
|
+
payload = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
|
|
2778
|
+
except Exception:
|
|
2779
|
+
payload = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
|
|
2780
|
+
return {"request_id": request_id, "completed": True, "result": payload}
|
|
2781
|
+
if asyncio.get_event_loop().time() >= deadline:
|
|
2782
|
+
return {"request_id": request_id, "completed": False, "timed_out": True}
|
|
2783
|
+
await asyncio.sleep(0.4)
|
|
2784
|
+
|
|
2785
|
+
|
|
2786
|
+
@app.post("/api/devices/{device_id}/supervisor-output")
|
|
2787
|
+
async def device_supervisor_output(device_id: str, payload: dict, authorization: Optional[str] = Header(default=None)):
|
|
2788
|
+
"""Robot supervisor posts back the result of a tail_logs or shell_run
|
|
2789
|
+
request. We just record it on the shell_requests row; the dashboard
|
|
2790
|
+
picks it up via /api/robots/{id}/shell/result/{request_id}."""
|
|
2791
|
+
if not await _authorize_device(device_id, authorization):
|
|
2792
|
+
raise HTTPException(401, "Invalid device token")
|
|
2793
|
+
request_id = payload.get("request_id")
|
|
2794
|
+
if not request_id:
|
|
2795
|
+
raise HTTPException(400, "request_id is required")
|
|
2796
|
+
now = datetime.utcnow().isoformat()
|
|
2797
|
+
result_payload = payload.get("payload") or {}
|
|
2798
|
+
ok = 1 if result_payload.get("ok") else 0
|
|
2799
|
+
async with aiosqlite.connect(DB_PATH) as db:
|
|
2800
|
+
db.row_factory = aiosqlite.Row
|
|
2801
|
+
async with db.execute("SELECT device_id FROM shell_requests WHERE id = ?", (request_id,)) as c:
|
|
2802
|
+
r = await c.fetchone()
|
|
2803
|
+
if not r:
|
|
2804
|
+
raise HTTPException(404, "No such shell request")
|
|
2805
|
+
if r["device_id"] != device_id:
|
|
2806
|
+
raise HTTPException(403, "request_id does not belong to this device")
|
|
2807
|
+
await db.execute(
|
|
2808
|
+
"""UPDATE shell_requests SET completed_at = ?, result_ok = ?, result_payload = ?
|
|
2809
|
+
WHERE id = ?""",
|
|
2810
|
+
(now, ok, json.dumps(result_payload), request_id),
|
|
2811
|
+
)
|
|
2812
|
+
await db.commit()
|
|
2813
|
+
kind = payload.get("kind") or "shell"
|
|
2814
|
+
service = payload.get("service") or ""
|
|
2815
|
+
await _log_history("device", "device", device_id, f"shell_{kind}_completed", "operator",
|
|
2816
|
+
f"service={service}, request_id={request_id}, ok={bool(ok)}")
|
|
2817
|
+
return {"status": "ok"}
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
# Endpoint the dashboard uses to list the available shell commands
|
|
2821
|
+
# (allowlist) so the UI can populate a picker instead of typing names.
|
|
2822
|
+
# The list mirrors the SHELL_ALLOWLIST in robot_supervisor.py — that
|
|
2823
|
+
# file is the authoritative source for what's actually allowed at
|
|
2824
|
+
# run time. Keep these two lists in sync when adding/removing commands.
|
|
2825
|
+
SHELL_DASHBOARD_ALLOWLIST = [
|
|
2826
|
+
{"name": "uptime", "argv": ["uptime"]},
|
|
2827
|
+
{"name": "free", "argv": ["free", "-m"]},
|
|
2828
|
+
{"name": "df", "argv": ["df", "-h"]},
|
|
2829
|
+
{"name": "ps", "argv": ["ps", "aux"]},
|
|
2830
|
+
{"name": "uname", "argv": ["uname", "-a"]},
|
|
2831
|
+
{"name": "date", "argv": ["date"]},
|
|
2832
|
+
{"name": "whoami", "argv": ["whoami"]},
|
|
2833
|
+
{"name": "hostname", "argv": ["hostname"]},
|
|
2834
|
+
{"name": "ip", "argv": ["ip", "addr"]},
|
|
2835
|
+
{"name": "ss", "argv": ["ss", "-tlnp"]},
|
|
2836
|
+
{"name": "os-release", "argv": ["cat", "/etc/os-release"]},
|
|
2837
|
+
{"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
|
|
2838
|
+
{"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
|
|
2839
|
+
{"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
|
|
2840
|
+
{"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
|
|
2841
|
+
]
|
|
2842
|
+
SHELL_DASHBOARD_OUTPUT_MAX = 64 * 1024
|
|
2843
|
+
SHELL_DASHBOARD_TIMEOUT = 8.0
|
|
2844
|
+
|
|
2845
|
+
@app.get("/api/shell/allowlist")
|
|
2846
|
+
async def shell_allowlist():
|
|
2847
|
+
"""Return the list of shell command names the robot's supervisor
|
|
2848
|
+
supports. The dashboard uses this to populate the picker; the
|
|
2849
|
+
robot side does the actual allowlist enforcement at run time."""
|
|
2850
|
+
return {
|
|
2851
|
+
"commands": [
|
|
2852
|
+
{"name": s["name"], "argv": s["argv"]}
|
|
2853
|
+
for s in SHELL_DASHBOARD_ALLOWLIST
|
|
2854
|
+
],
|
|
2855
|
+
"max_output_bytes": SHELL_DASHBOARD_OUTPUT_MAX,
|
|
2856
|
+
"timeout_seconds": SHELL_DASHBOARD_TIMEOUT,
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
# ── Speaker roundtrip test (C2) ──
|
|
2861
|
+
# The dashboard's "Test speaker" button hits this. We queue a
|
|
2862
|
+
# speaker_test request on the robot's shell_requests table; the
|
|
2863
|
+
# robot's next status-report poll runs the round-trip (play tone,
|
|
2864
|
+
# record mic) and POSTs the result back via /supervisor-output.
|
|
2865
|
+
# The dashboard polls the same /shell/wait endpoint used for the
|
|
2866
|
+
# other shell commands (the supervisor treats speaker_test as
|
|
2867
|
+
# just another request kind).
|
|
2868
|
+
|
|
2869
|
+
class SpeakerTestRequest(BaseModel):
|
|
2870
|
+
frequency: Optional[float] = None
|
|
2871
|
+
duration: Optional[float] = None
|
|
2872
|
+
amplitude: Optional[float] = None
|
|
2873
|
+
threshold_db: Optional[float] = None
|
|
2874
|
+
output: Optional[str] = None
|
|
2875
|
+
input: Optional[str] = None
|
|
2876
|
+
sample_rate: Optional[int] = None
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
@app.post("/api/robots/{robot_id}/shell/speaker-test")
|
|
2880
|
+
async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
|
|
2881
|
+
"""Queue a speaker + mic round-trip test on the robot. Returns a
|
|
2882
|
+
request_id; poll /api/robots/{id}/shell/wait/{rid} for the result."""
|
|
2883
|
+
device_id = robot_id
|
|
2884
|
+
params = payload.model_dump(exclude_none=True)
|
|
2885
|
+
request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
|
|
2886
|
+
return {"status": "queued", "request_id": request_id, "device_id": device_id}
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
# ── Incident acknowledgement (C4) ──
|
|
2890
|
+
# The dashboard's Incidents sub-tab lets an operator acknowledge an
|
|
2891
|
+
# active incident (a robot stuck in stuck_session, server_unavailable,
|
|
2892
|
+
# etc). The acknowledgement is stored client-side (localStorage) so
|
|
2893
|
+
# each operator on their own browser gets their own list; this
|
|
2894
|
+
# endpoint is the "central log" mirror so the ack also shows up in
|
|
2895
|
+
# the audit history with the operator's name (via X-Operator).
|
|
2896
|
+
class IncidentAck(BaseModel):
|
|
2897
|
+
robot_id: str
|
|
2898
|
+
code: str
|
|
2899
|
+
message: Optional[str] = None
|
|
2900
|
+
|
|
2901
|
+
@app.post("/api/incidents/ack")
|
|
2902
|
+
async def incidents_ack(payload: IncidentAck, x_operator: Optional[str] = Header(default=None, alias="X-Operator")):
|
|
2903
|
+
"""Log an operator ack of an active incident. The dashboard
|
|
2904
|
+
stores its own list; this endpoint mirrors the ack in the audit
|
|
2905
|
+
log so the action is visible centrally with the operator's name."""
|
|
2906
|
+
actor = _sanitize_actor_name(x_operator) or 'operator'
|
|
2907
|
+
await _log_history("incident", "robot", payload.robot_id, "acked", actor,
|
|
2908
|
+
f"code={payload.code}, message={payload.message or ''}")
|
|
2909
|
+
return {"status": "ok", "robot_id": payload.robot_id, "code": payload.code, "by": actor, "at": datetime.utcnow().isoformat()}
|
|
2910
|
+
|
|
2911
|
+
|
|
2449
2912
|
@app.get("/api/devices/{device_id}/config")
|
|
2450
2913
|
async def device_config(device_id: str, authorization: Optional[str] = Header(default=None)):
|
|
2451
2914
|
"""Pi polls this to learn current production_mode + assigned character, etc."""
|
|
@@ -2506,10 +2969,14 @@ async def device_request_session(device_id: str,
|
|
|
2506
2969
|
db.row_factory = aiosqlite.Row
|
|
2507
2970
|
|
|
2508
2971
|
# Resolve the enrolled device.
|
|
2509
|
-
async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
|
|
2510
|
-
device = await c.fetchone()
|
|
2511
|
-
if not device:
|
|
2512
|
-
raise HTTPException(404, "Device not found")
|
|
2972
|
+
async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
|
|
2973
|
+
device = await c.fetchone()
|
|
2974
|
+
if not device:
|
|
2975
|
+
raise HTTPException(404, "Device not found")
|
|
2976
|
+
if not await get_production_mode():
|
|
2977
|
+
raise HTTPException(403, "production_mode_off")
|
|
2978
|
+
if not bool(device["production_mode"]):
|
|
2979
|
+
raise HTTPException(403, "device_production_mode_off")
|
|
2513
2980
|
|
|
2514
2981
|
# Mirror device -> robot identity (id == device_id). Additive + idempotent:
|
|
2515
2982
|
# if the robot row already exists it is left untouched.
|
|
@@ -3693,12 +4160,16 @@ async def dashboard():
|
|
|
3693
4160
|
const connected = ref(false);
|
|
3694
4161
|
const serverMetrics = ref({});
|
|
3695
4162
|
const devices = ref([]);
|
|
3696
|
-
const settings = ref({ production_mode: false, default_enrollment_token_set: false });
|
|
4163
|
+
const settings = ref({ production_mode: false, default_enrollment_token_set: false, agent_token_configured: false, agent_token_source: null });
|
|
3697
4164
|
const livekit = ref({ url: null, api_key: null, has_secret: false });
|
|
3698
|
-
const showAddDevice = ref(false);
|
|
3699
|
-
const showDevices = ref(true);
|
|
4165
|
+
const showAddDevice = ref(false);
|
|
4166
|
+
const showDevices = ref(true);
|
|
4167
|
+
const showCommands = ref(false);
|
|
4168
|
+
const commandNetwork = ref('lan');
|
|
4169
|
+
const commandRobotId = ref('');
|
|
3700
4170
|
const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
|
|
3701
|
-
const rotatedToken = ref(null);
|
|
4171
|
+
const rotatedToken = ref(null);
|
|
4172
|
+
const enrollmentNetwork = ref('lan');
|
|
3702
4173
|
const preview = ref({ robot: null, active: false, url: null, token: null, room: null, keepalive: null });
|
|
3703
4174
|
const simulation = ref({ robotId: null, busy: false });
|
|
3704
4175
|
const deviceInventory = ref({});
|
|
@@ -3756,18 +4227,25 @@ async def dashboard():
|
|
|
3756
4227
|
|
|
3757
4228
|
const buildDeviceInventory = () => {
|
|
3758
4229
|
const map = {};
|
|
3759
|
-
for (const robot of robots.value) {
|
|
3760
|
-
const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
|
|
3761
|
-
const savedVideo = device?.video_device || 'auto';
|
|
3762
|
-
const savedAudio = device?.audio_device || 'default';
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
4230
|
+
for (const robot of robots.value) {
|
|
4231
|
+
const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
|
|
4232
|
+
const savedVideo = device?.video_device || 'auto';
|
|
4233
|
+
const savedAudio = device?.audio_device || 'default';
|
|
4234
|
+
const hardware = device?.device_inventory || {};
|
|
4235
|
+
const videoOptions = (hardware.video || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
|
|
4236
|
+
const audioOptions = (hardware.audio_input || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
|
|
4237
|
+
const audioOutputOptions = (hardware.audio_output || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
|
|
4238
|
+
map[robot.id] = {
|
|
4239
|
+
deviceId: device?.id || null,
|
|
4240
|
+
robotName: robot.name,
|
|
4241
|
+
selectedVideo: savedVideo,
|
|
4242
|
+
selectedAudio: savedAudio,
|
|
4243
|
+
selectedAudioOutput: device?.audio_output_device || 'default',
|
|
4244
|
+
greetingText: (device?.greeting_phrases || []).join('\\n'),
|
|
4245
|
+
videoOptions: [...new Set(['auto', 'none', savedVideo, ...videoOptions])],
|
|
4246
|
+
audioOptions: [...new Set(['default', 'none', savedAudio, ...audioOptions])],
|
|
4247
|
+
audioOutputOptions: [...new Set(['default', 'none', device?.audio_output_device || 'default', ...audioOutputOptions])],
|
|
4248
|
+
};
|
|
3771
4249
|
}
|
|
3772
4250
|
deviceInventory.value = map;
|
|
3773
4251
|
};
|
|
@@ -3837,7 +4315,7 @@ async def dashboard():
|
|
|
3837
4315
|
}
|
|
3838
4316
|
};
|
|
3839
4317
|
|
|
3840
|
-
const rotateEnrollmentToken = async () => {
|
|
4318
|
+
const rotateEnrollmentToken = async () => {
|
|
3841
4319
|
if (!confirm('Rotate the global default enrollment token? Any Pi waiting to enroll with the previous token will be rejected.')) return;
|
|
3842
4320
|
try {
|
|
3843
4321
|
const r = await fetch('/api/settings/enrollment-token/rotate', { method: 'POST' }).then(r => r.json());
|
|
@@ -3846,7 +4324,23 @@ async def dashboard():
|
|
|
3846
4324
|
} catch (e) {
|
|
3847
4325
|
alert('Failed to rotate token: ' + e.message);
|
|
3848
4326
|
}
|
|
3849
|
-
};
|
|
4327
|
+
};
|
|
4328
|
+
|
|
4329
|
+
const provisionAgentToken = async (rotate = false) => {
|
|
4330
|
+
if (rotate && !confirm('Rotate the ROBOVOICE authentication secret? Existing workers must be restarted after synchronization.')) return;
|
|
4331
|
+
try {
|
|
4332
|
+
const r = await fetch('/api/settings/agent-token/provision', {
|
|
4333
|
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
4334
|
+
body: JSON.stringify({ rotate }),
|
|
4335
|
+
}).then(r => r.json());
|
|
4336
|
+
if (!r.agent_token) throw new Error(r.detail || 'No secret returned');
|
|
4337
|
+
alert('ROBOVOICE secret (shown once):\\n\\n' + r.agent_token + '\\n\\nCopy it into the worker environment or run: robopark secrets --scheduler-url ' + window.location.origin + ' --worker-env <ROBOVOICE>/.env');
|
|
4338
|
+
settings.value.agent_token_configured = true;
|
|
4339
|
+
await fetchData();
|
|
4340
|
+
} catch (e) {
|
|
4341
|
+
alert('Failed to provision agent secret: ' + e.message);
|
|
4342
|
+
}
|
|
4343
|
+
};
|
|
3850
4344
|
|
|
3851
4345
|
const submitNewDevice = async () => {
|
|
3852
4346
|
try {
|
|
@@ -3866,7 +4360,7 @@ async def dashboard():
|
|
|
3866
4360
|
}
|
|
3867
4361
|
};
|
|
3868
4362
|
|
|
3869
|
-
const updateDeviceMedia = async (robotId) => {
|
|
4363
|
+
const updateDeviceMedia = async (robotId) => {
|
|
3870
4364
|
const inv = deviceInventory.value[robotId];
|
|
3871
4365
|
if (!inv || !inv.deviceId) return;
|
|
3872
4366
|
try {
|
|
@@ -3874,18 +4368,20 @@ async def dashboard():
|
|
|
3874
4368
|
method: 'PATCH',
|
|
3875
4369
|
headers: {'Content-Type': 'application/json'},
|
|
3876
4370
|
body: JSON.stringify({
|
|
3877
|
-
video_device: inv.selectedVideo,
|
|
3878
|
-
audio_device: inv.selectedAudio,
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
4371
|
+
video_device: inv.selectedVideo,
|
|
4372
|
+
audio_device: inv.selectedAudio,
|
|
4373
|
+
audio_output_device: inv.selectedAudioOutput,
|
|
4374
|
+
}),
|
|
4375
|
+
});
|
|
4376
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4377
|
+
fetchData();
|
|
3882
4378
|
} catch (e) {
|
|
3883
4379
|
alert('Failed to update device media: ' + e.message);
|
|
3884
4380
|
}
|
|
3885
4381
|
};
|
|
3886
4382
|
|
|
3887
|
-
const startPreview = async (robot) => {
|
|
3888
|
-
stopPreview();
|
|
4383
|
+
const startPreview = async (robot) => {
|
|
4384
|
+
await stopPreview();
|
|
3889
4385
|
try {
|
|
3890
4386
|
const r = await fetch(`/api/robots/${robot.id}/preview/start`, { method: 'POST' }).then(r => r.json());
|
|
3891
4387
|
if (!r.active) {
|
|
@@ -3905,13 +4401,19 @@ async def dashboard():
|
|
|
3905
4401
|
}
|
|
3906
4402
|
};
|
|
3907
4403
|
|
|
3908
|
-
const stopPreview = () => {
|
|
3909
|
-
|
|
3910
|
-
if (preview.value.
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
4404
|
+
const stopPreview = async () => {
|
|
4405
|
+
const robotId = preview.value.robot?.id;
|
|
4406
|
+
if (preview.value.keepalive) clearInterval(preview.value.keepalive);
|
|
4407
|
+
if (preview.value.room) {
|
|
4408
|
+
try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
|
|
4409
|
+
}
|
|
4410
|
+
preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
|
|
4411
|
+
if (robotId) {
|
|
4412
|
+
try {
|
|
4413
|
+
await fetch(`/api/robots/${robotId}/preview/stop`, { method: 'POST' });
|
|
4414
|
+
} catch (e) { console.warn('preview stop failed', e); }
|
|
4415
|
+
}
|
|
4416
|
+
};
|
|
3915
4417
|
|
|
3916
4418
|
const simulateTrigger = async (robot) => {
|
|
3917
4419
|
if (!settings.value.production_mode || simulation.value.busy) return;
|
|
@@ -3931,7 +4433,7 @@ async def dashboard():
|
|
|
3931
4433
|
}
|
|
3932
4434
|
};
|
|
3933
4435
|
|
|
3934
|
-
const stopConversation = async (robot) => {
|
|
4436
|
+
const stopConversation = async (robot) => {
|
|
3935
4437
|
try {
|
|
3936
4438
|
const response = await fetch(`/api/robots/${robot.id}/simulate-stop`, { method: 'POST' });
|
|
3937
4439
|
const result = await response.json();
|
|
@@ -3940,7 +4442,52 @@ async def dashboard():
|
|
|
3940
4442
|
} catch (e) {
|
|
3941
4443
|
alert('Failed to stop conversation: ' + e.message);
|
|
3942
4444
|
}
|
|
3943
|
-
};
|
|
4445
|
+
};
|
|
4446
|
+
|
|
4447
|
+
const recoverRobot = async (robot) => {
|
|
4448
|
+
if (!confirm(`Clear stale session state for ${robot.name}?`)) return;
|
|
4449
|
+
try {
|
|
4450
|
+
const response = await fetch(`/api/robots/${robot.id}/recover`, { method: 'POST' });
|
|
4451
|
+
const result = await response.json();
|
|
4452
|
+
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
|
4453
|
+
await fetchData();
|
|
4454
|
+
} catch (e) {
|
|
4455
|
+
alert('Failed to recover robot: ' + e.message);
|
|
4456
|
+
}
|
|
4457
|
+
};
|
|
4458
|
+
|
|
4459
|
+
const toggleDeviceProduction = async (robotId) => {
|
|
4460
|
+
const inv = deviceInventory.value[robotId];
|
|
4461
|
+
if (!inv || !inv.deviceId) return;
|
|
4462
|
+
await setDeviceProduction(inv.deviceId);
|
|
4463
|
+
};
|
|
4464
|
+
|
|
4465
|
+
const setDeviceProduction = async (deviceId) => {
|
|
4466
|
+
const device = devices.value.find(d => d.id === deviceId);
|
|
4467
|
+
if (!device) return;
|
|
4468
|
+
try {
|
|
4469
|
+
const response = await fetch(`/api/devices/${deviceId}`, {
|
|
4470
|
+
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
|
4471
|
+
body: JSON.stringify({ production_mode: !device?.production_mode }),
|
|
4472
|
+
});
|
|
4473
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4474
|
+
await fetchData();
|
|
4475
|
+
} catch (e) { alert('Failed to change robot production mode: ' + e.message); }
|
|
4476
|
+
};
|
|
4477
|
+
|
|
4478
|
+
const saveDeviceGreetings = async (robotId) => {
|
|
4479
|
+
const inv = deviceInventory.value[robotId];
|
|
4480
|
+
if (!inv || !inv.deviceId) return;
|
|
4481
|
+
try {
|
|
4482
|
+
const greeting_phrases = inv.greetingText.split(/\\r?\\n/).map(s => s.trim()).filter(Boolean).slice(0, 20);
|
|
4483
|
+
const response = await fetch(`/api/devices/${inv.deviceId}`, {
|
|
4484
|
+
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
|
4485
|
+
body: JSON.stringify({ greeting_phrases }),
|
|
4486
|
+
});
|
|
4487
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4488
|
+
await fetchData();
|
|
4489
|
+
} catch (e) { alert('Failed to save greetings: ' + e.message); }
|
|
4490
|
+
};
|
|
3944
4491
|
|
|
3945
4492
|
const connectLiveKitPreview = async (url, token) => {
|
|
3946
4493
|
const { Room } = LiveKitClient;
|
|
@@ -3977,7 +4524,15 @@ async def dashboard():
|
|
|
3977
4524
|
}
|
|
3978
4525
|
};
|
|
3979
4526
|
|
|
3980
|
-
const dismissRotatedToken = () => { rotatedToken.value = null; };
|
|
4527
|
+
const dismissRotatedToken = () => { rotatedToken.value = null; };
|
|
4528
|
+
const enrollmentCommand = (token) => {
|
|
4529
|
+
if (!token) return '';
|
|
4530
|
+
const schedulerPort = window.location.port || '8080';
|
|
4531
|
+
const networkFlag = enrollmentNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
|
|
4532
|
+
const hubProtocol = window.location.protocol === 'https:' ? 'https://' : 'http://';
|
|
4533
|
+
const hubUrl = `${hubProtocol}${window.location.hostname}:47913`;
|
|
4534
|
+
return `robopark setup --robot --name "${token.name || token.device_id}" --hub-url ${hubUrl} --scheduler-port ${schedulerPort} ${networkFlag} --enrollment-token ${token.token} --start --auto-start`;
|
|
4535
|
+
};
|
|
3981
4536
|
|
|
3982
4537
|
const joinConversation = async (device) => {
|
|
3983
4538
|
try {
|
|
@@ -4080,9 +4635,10 @@ async def dashboard():
|
|
|
4080
4635
|
setInterval(fetchData, 30000);
|
|
4081
4636
|
});
|
|
4082
4637
|
|
|
4083
|
-
onUnmounted(() => {
|
|
4084
|
-
if (ws) ws.close();
|
|
4085
|
-
|
|
4638
|
+
onUnmounted(() => {
|
|
4639
|
+
if (ws) ws.close();
|
|
4640
|
+
stopPreview();
|
|
4641
|
+
});
|
|
4086
4642
|
|
|
4087
4643
|
const openVoiceStackForm = (stack = null) => {
|
|
4088
4644
|
if (stack) {
|
|
@@ -4176,17 +4732,44 @@ async def dashboard():
|
|
|
4176
4732
|
const stackName = (id) => voiceStacks.value.find(s => s.id === id)?.name || '—';
|
|
4177
4733
|
const characterName = (id) => characterPresets.value.find(c => c.id === id)?.name || '—';
|
|
4178
4734
|
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4735
|
+
const schedulerBase = () => window.location.origin;
|
|
4736
|
+
const selectedCommandRobot = computed(() => devices.value.find(d => d.id === commandRobotId.value) || devices.value[0] || null);
|
|
4737
|
+
const commandRows = computed(() => {
|
|
4738
|
+
const base = schedulerBase();
|
|
4739
|
+
const robot = selectedCommandRobot.value;
|
|
4740
|
+
const robotId = robot?.id || '<DEVICE_ID>';
|
|
4741
|
+
const robotName = robot?.name || '<ROBOT_NAME>';
|
|
4742
|
+
const networkFlag = commandNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
|
|
4743
|
+
const tokenPlaceholder = '<ENROLLMENT_TOKEN_FROM_ADD_DEVICE>';
|
|
4744
|
+
return [
|
|
4745
|
+
{ group: 'Scheduler', name: 'Health check', description: 'Confirm the control center API is reachable.', command: `curl -s ${base}/api/settings` },
|
|
4746
|
+
{ group: 'Scheduler', name: 'Start scheduler', description: 'Start the RoboPark scheduler and dashboard on port 8080.', command: 'robopark serve --port 8080' },
|
|
4747
|
+
{ group: 'Robot', name: 'Enroll robot', description: `Generated for ${robotName} over ${commandNetwork.value}. Paste the one-time token from the Add Device dialog.`, command: `robopark setup --robot --name "${robotName}" --hub-url ${base.replace(/:\\d+$/, '')}:47913 --scheduler-port ${window.location.port || '8080'} ${networkFlag} --enrollment-token ${tokenPlaceholder} --start --auto-start` },
|
|
4748
|
+
{ group: 'Robot', name: 'Simulate motion', description: 'Queue a full production trigger for the selected robot.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-trigger` },
|
|
4749
|
+
{ group: 'Robot', name: 'Stop conversation', description: 'Stop the active conversation and clear queued trigger state.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-stop` },
|
|
4750
|
+
{ group: 'Robot', name: 'Recover stale state', description: 'Release a robot stuck in connecting/running after a failed teardown.', command: `curl -s -X POST ${base}/api/robots/${robotId}/recover` },
|
|
4751
|
+
{ group: 'Worker', name: 'Sync agent secret', description: 'Provision the scheduler-managed secret into ROBOVOICE-main.', command: `robopark secrets --scheduler-url ${base} --worker-env C:\\path\\to\\ROBOVOICE-main\\.env` },
|
|
4752
|
+
{ group: 'Diagnostics', name: 'Tail agent logs', description: 'Inspect the local ROBOVOICE worker.', command: 'docker logs --tail 100 caal-agent' },
|
|
4753
|
+
{ group: 'Diagnostics', name: 'Check Docker', description: 'List containers, health, ports, and networks.', command: 'docker ps -a --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Networks}}"' },
|
|
4754
|
+
{ group: 'Diagnostics', name: 'Check LiveKit port', description: 'Confirm the local LiveKit listener is present.', command: 'Get-NetTCPConnection -LocalPort 7883 -ErrorAction SilentlyContinue' },
|
|
4755
|
+
];
|
|
4756
|
+
});
|
|
4757
|
+
const copyCommand = async (command) => {
|
|
4758
|
+
try { await navigator.clipboard.writeText(command); }
|
|
4759
|
+
catch (e) { window.prompt('Copy command:', command); }
|
|
4760
|
+
};
|
|
4761
|
+
|
|
4762
|
+
return {
|
|
4763
|
+
robots, servers, sessions, stats, connected, serverMetrics,
|
|
4764
|
+
devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
|
|
4182
4765
|
showLiveKitConfig, livekitForm, preview, deviceInventory,
|
|
4183
4766
|
voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
|
|
4184
4767
|
editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
|
|
4185
4768
|
loadModel, unloadModel, statusColor, formatDuration, formatTime,
|
|
4186
|
-
toggleProductionMode, rotateEnrollmentToken,
|
|
4769
|
+
toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
|
|
4187
4770
|
submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
|
|
4188
4771
|
joinConversation, saveLiveKitConfig, openLiveKitConfig,
|
|
4189
|
-
startPreview, stopPreview, simulateTrigger, stopConversation, updateDeviceMedia,
|
|
4772
|
+
startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
|
|
4190
4773
|
openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
|
|
4191
4774
|
openCharacterForm, saveCharacter, deleteCharacter,
|
|
4192
4775
|
assignRobotCharacter, stackName, characterName,
|
|
@@ -4200,20 +4783,30 @@ async def dashboard():
|
|
|
4200
4783
|
<h1 class="text-3xl font-bold">🤖 RoboPark Control Center</h1>
|
|
4201
4784
|
<p class="text-gray-400">Session Scheduler Dashboard — Shenzhen Bay Park (test site)</p>
|
|
4202
4785
|
</div>
|
|
4203
|
-
<div class="flex items-center gap-4">
|
|
4204
|
-
<button @click="
|
|
4786
|
+
<div class="flex items-center gap-4">
|
|
4787
|
+
<button @click="showCommands = !showCommands"
|
|
4788
|
+
:class="showCommands ? 'bg-blue-700 hover:bg-blue-600' : 'bg-gray-700 hover:bg-gray-600'"
|
|
4789
|
+
class="px-3 py-2 rounded-lg text-sm">Commands</button>
|
|
4790
|
+
<button @click="openLiveKitConfig"
|
|
4205
4791
|
:class="livekit.url && livekit.has_secret ? 'bg-cyan-700 hover:bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'"
|
|
4206
4792
|
class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
|
|
4207
4793
|
:title="livekit.url || 'Not configured'">
|
|
4208
4794
|
<span class="w-2 h-2 rounded-full" :class="livekit.url && livekit.has_secret ? 'bg-cyan-200' : 'bg-yellow-400'"></span>
|
|
4209
4795
|
LiveKit: <strong>{{ livekit.url && livekit.has_secret ? (livekit.url.replace(/^wss?:[/][/]/, '')) : 'NOT SET' }}</strong>
|
|
4210
4796
|
</button>
|
|
4211
|
-
<button @click="toggleProductionMode"
|
|
4797
|
+
<button @click="toggleProductionMode"
|
|
4212
4798
|
:class="settings.production_mode ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-700 hover:bg-gray-600'"
|
|
4213
4799
|
class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm">
|
|
4214
4800
|
<span class="w-2 h-2 rounded-full" :class="settings.production_mode ? 'bg-green-200 animate-pulse' : 'bg-gray-400'"></span>
|
|
4215
4801
|
Production Mode: <strong>{{ settings.production_mode ? 'ON' : 'OFF' }}</strong>
|
|
4216
|
-
</button>
|
|
4802
|
+
</button>
|
|
4803
|
+
<button @click="provisionAgentToken(false)"
|
|
4804
|
+
:class="settings.agent_token_configured ? 'bg-emerald-800 hover:bg-emerald-700' : 'bg-red-800 hover:bg-red-700'"
|
|
4805
|
+
class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
|
|
4806
|
+
title="Provision the scheduler-managed ROBOVOICE authentication secret">
|
|
4807
|
+
<span class="w-2 h-2 rounded-full" :class="settings.agent_token_configured ? 'bg-emerald-200' : 'bg-red-300'"></span>
|
|
4808
|
+
Agent Secret: <strong>{{ settings.agent_token_configured ? 'SET' : 'MISSING' }}</strong>
|
|
4809
|
+
</button>
|
|
4217
4810
|
<span :class="connected ? 'text-green-400' : 'text-red-400'" class="flex items-center gap-2">
|
|
4218
4811
|
<span class="w-2 h-2 rounded-full" :class="connected ? 'bg-green-400' : 'bg-red-400'"></span>
|
|
4219
4812
|
{{ connected ? 'Live' : 'Disconnected' }}
|
|
@@ -4221,7 +4814,38 @@ async def dashboard():
|
|
|
4221
4814
|
</div>
|
|
4222
4815
|
</header>
|
|
4223
4816
|
|
|
4224
|
-
<!--
|
|
4817
|
+
<!-- Command Center -->
|
|
4818
|
+
<section v-if="showCommands" class="bg-gray-800 rounded-lg p-5 mb-6">
|
|
4819
|
+
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
4820
|
+
<div>
|
|
4821
|
+
<h2 class="text-xl font-semibold">Command Center</h2>
|
|
4822
|
+
<p class="text-xs text-gray-400">Copy-ready operational commands. Secrets are never included; enrollment uses a one-time token from the Add Device dialog.</p>
|
|
4823
|
+
</div>
|
|
4824
|
+
<div class="flex gap-2 items-center">
|
|
4825
|
+
<select v-model="commandRobotId" class="bg-gray-700 rounded px-3 py-2 text-sm">
|
|
4826
|
+
<option value="">Select robot</option>
|
|
4827
|
+
<option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }} ({{ d.id }})</option>
|
|
4828
|
+
</select>
|
|
4829
|
+
<button @click="commandNetwork = 'lan'" :class="commandNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">LAN</button>
|
|
4830
|
+
<button @click="commandNetwork = 'tailscale'" :class="commandNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">Tailscale</button>
|
|
4831
|
+
</div>
|
|
4832
|
+
</div>
|
|
4833
|
+
<div class="grid md:grid-cols-2 gap-3">
|
|
4834
|
+
<div v-for="cmd in commandRows" :key="cmd.group + cmd.name" class="bg-gray-900 rounded p-3">
|
|
4835
|
+
<div class="flex items-start justify-between gap-2">
|
|
4836
|
+
<div>
|
|
4837
|
+
<div class="text-xs text-cyan-400">{{ cmd.group }}</div>
|
|
4838
|
+
<div class="font-medium text-sm">{{ cmd.name }}</div>
|
|
4839
|
+
<div class="text-xs text-gray-500 mt-1">{{ cmd.description }}</div>
|
|
4840
|
+
</div>
|
|
4841
|
+
<button @click="copyCommand(cmd.command)" class="shrink-0 px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-xs">Copy</button>
|
|
4842
|
+
</div>
|
|
4843
|
+
<code class="block mt-2 text-xs text-green-300 break-all select-all">{{ cmd.command }}</code>
|
|
4844
|
+
</div>
|
|
4845
|
+
</div>
|
|
4846
|
+
</section>
|
|
4847
|
+
|
|
4848
|
+
<!-- Stats Row -->
|
|
4225
4849
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
|
4226
4850
|
<div class="bg-gray-800 rounded-lg p-4">
|
|
4227
4851
|
<div class="text-2xl font-bold text-green-400">{{ stats.active_sessions || 0 }}</div>
|
|
@@ -4298,9 +4922,12 @@ async def dashboard():
|
|
|
4298
4922
|
<td class="py-2 px-2 font-mono text-xs">{{ d.tailscale_ip || '—' }}</td>
|
|
4299
4923
|
<td class="py-2 px-2 font-mono text-xs">{{ d.lan_ip || '—' }}</td>
|
|
4300
4924
|
<td class="py-2 px-2 font-mono text-xs text-gray-400">{{ d.motor_server_url || '—' }}</td>
|
|
4301
|
-
<td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
|
|
4302
|
-
<td class="py-2 px-2 text-xs space-x-2">
|
|
4303
|
-
<button @click="
|
|
4925
|
+
<td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
|
|
4926
|
+
<td class="py-2 px-2 text-xs space-x-2">
|
|
4927
|
+
<button @click="setDeviceProduction(d.id)"
|
|
4928
|
+
:class="d.production_mode ? 'bg-green-700 hover:bg-green-600' : 'bg-gray-700 hover:bg-gray-600'"
|
|
4929
|
+
class="px-2 py-1 rounded">Prod {{ d.production_mode ? 'ON' : 'OFF' }}</button>
|
|
4930
|
+
<button @click="joinConversation(d)"
|
|
4304
4931
|
:disabled="!settings.production_mode || !livekit.url || !livekit.has_secret"
|
|
4305
4932
|
:class="(settings.production_mode && livekit.url && livekit.has_secret) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
|
|
4306
4933
|
class="px-2 py-1 rounded"
|
|
@@ -4381,9 +5008,18 @@ async def dashboard():
|
|
|
4381
5008
|
<div v-if="rotatedToken" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="dismissRotatedToken">
|
|
4382
5009
|
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
|
|
4383
5010
|
<h3 class="text-lg font-semibold mb-2">⚠ Save this token now</h3>
|
|
4384
|
-
<p class="text-xs text-gray-400 mb-4">This token is shown <strong>once</strong>. Copy it to the Pi (e.g. into <code>/etc/robopark/device.json</code>) before dismissing.</p>
|
|
4385
|
-
<div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
|
|
4386
|
-
<div class="
|
|
5011
|
+
<p class="text-xs text-gray-400 mb-4">This token is shown <strong>once</strong>. Copy it to the Pi (e.g. into <code>/etc/robopark/device.json</code>) before dismissing.</p>
|
|
5012
|
+
<div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
|
|
5013
|
+
<div class="mt-4">
|
|
5014
|
+
<div class="text-xs text-gray-400 mb-2">Robot network for generated enrollment command</div>
|
|
5015
|
+
<div class="flex gap-2 mb-2">
|
|
5016
|
+
<button @click="enrollmentNetwork = 'lan'" :class="enrollmentNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">LAN</button>
|
|
5017
|
+
<button @click="enrollmentNetwork = 'tailscale'" :class="enrollmentNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">Tailscale</button>
|
|
5018
|
+
</div>
|
|
5019
|
+
<div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ enrollmentCommand(rotatedToken) }}</div>
|
|
5020
|
+
<p class="text-xs text-gray-500 mt-1">LAN requires the robot and scheduler on the same subnet. Tailscale requires Tailscale on both devices and a Tailscale scheduler URL/host.</p>
|
|
5021
|
+
</div>
|
|
5022
|
+
<div class="flex justify-end mt-4">
|
|
4387
5023
|
<button @click="dismissRotatedToken" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded">I have saved it</button>
|
|
4388
5024
|
</div>
|
|
4389
5025
|
</div>
|
|
@@ -4444,7 +5080,11 @@ async def dashboard():
|
|
|
4444
5080
|
Character: <strong>{{ characterName(robot.character_id) }}</strong> ·
|
|
4445
5081
|
Voice Stack: <strong>{{ stackName(robot.voice_stack_id) }}</strong>
|
|
4446
5082
|
</div>
|
|
4447
|
-
<div class="
|
|
5083
|
+
<div v-if="deviceInventory[robot.id]" class="flex items-center justify-between mb-2 text-xs">
|
|
5084
|
+
<span>Robot production: <strong :class="devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'text-green-400' : 'text-gray-400'">{{ devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'ON' : 'OFF' }}</strong></span>
|
|
5085
|
+
<button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
|
|
5086
|
+
</div>
|
|
5087
|
+
<div class="grid grid-cols-2 gap-2 mb-2 text-sm">
|
|
4448
5088
|
<div>
|
|
4449
5089
|
<label class="text-xs text-gray-500">Character</label>
|
|
4450
5090
|
<select :value="robot.character_id"
|
|
@@ -4460,7 +5100,7 @@ async def dashboard():
|
|
|
4460
5100
|
</div>
|
|
4461
5101
|
</div>
|
|
4462
5102
|
<div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
|
|
4463
|
-
<div class="grid grid-cols-
|
|
5103
|
+
<div class="grid grid-cols-3 gap-2">
|
|
4464
5104
|
<div>
|
|
4465
5105
|
<label class="text-xs text-gray-500">Camera</label>
|
|
4466
5106
|
<select v-model="deviceInventory[robot.id].selectedVideo"
|
|
@@ -4469,15 +5109,28 @@ async def dashboard():
|
|
|
4469
5109
|
<option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
4470
5110
|
</select>
|
|
4471
5111
|
</div>
|
|
4472
|
-
<div>
|
|
4473
|
-
<label class="text-xs text-gray-500">Microphone</label>
|
|
5112
|
+
<div>
|
|
5113
|
+
<label class="text-xs text-gray-500">Microphone</label>
|
|
4474
5114
|
<select v-model="deviceInventory[robot.id].selectedAudio"
|
|
4475
5115
|
@change="updateDeviceMedia(robot.id)"
|
|
4476
5116
|
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
4477
5117
|
<option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
4478
|
-
</select>
|
|
4479
|
-
</div>
|
|
4480
|
-
|
|
5118
|
+
</select>
|
|
5119
|
+
</div>
|
|
5120
|
+
<div>
|
|
5121
|
+
<label class="text-xs text-gray-500">Speaker</label>
|
|
5122
|
+
<select v-model="deviceInventory[robot.id].selectedAudioOutput"
|
|
5123
|
+
@change="updateDeviceMedia(robot.id)"
|
|
5124
|
+
class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
|
|
5125
|
+
<option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
|
|
5126
|
+
</select>
|
|
5127
|
+
</div>
|
|
5128
|
+
</div>
|
|
5129
|
+
<div>
|
|
5130
|
+
<label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
|
|
5131
|
+
<textarea v-model="deviceInventory[robot.id].greetingText" rows="2" class="w-full bg-gray-800 rounded px-2 py-1 text-xs" placeholder="Hello! Want to go for a ride?"></textarea>
|
|
5132
|
+
<button @click="saveDeviceGreetings(robot.id)" class="mt-1 px-2 py-1 rounded bg-gray-600 hover:bg-gray-500 text-xs">Save Greetings</button>
|
|
5133
|
+
</div>
|
|
4481
5134
|
<button @click="startPreview(robot)"
|
|
4482
5135
|
:disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
|
|
4483
5136
|
:class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
|
|
@@ -4491,13 +5144,16 @@ async def dashboard():
|
|
|
4491
5144
|
class="px-2 py-1 rounded text-xs">
|
|
4492
5145
|
{{ simulation.robotId === robot.id ? 'Triggering…' : 'Simulate Motion' }}
|
|
4493
5146
|
</button>
|
|
4494
|
-
<button @click="stopConversation(robot)"
|
|
5147
|
+
<button @click="stopConversation(robot)"
|
|
4495
5148
|
:disabled="robot.status !== 'connecting' && robot.status !== 'running'"
|
|
4496
5149
|
:class="robot.status === 'connecting' || robot.status === 'running' ? 'bg-red-600 hover:bg-red-500' : 'bg-gray-700 cursor-not-allowed'"
|
|
4497
5150
|
class="px-2 py-1 rounded text-xs">
|
|
4498
|
-
Stop Conversation
|
|
4499
|
-
</button>
|
|
4500
|
-
</div>
|
|
5151
|
+
Stop Conversation
|
|
5152
|
+
</button>
|
|
5153
|
+
</div>
|
|
5154
|
+
<button @click="recoverRobot(robot)" class="w-full px-2 py-1 rounded text-xs bg-gray-600 hover:bg-gray-500">
|
|
5155
|
+
Recover Stale State
|
|
5156
|
+
</button>
|
|
4501
5157
|
<button v-if="preview.active && preview.robot?.id === robot.id"
|
|
4502
5158
|
@click="stopPreview"
|
|
4503
5159
|
class="w-full px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">
|