embedded-lab-control 0.2.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
labctl/web/api.py ADDED
@@ -0,0 +1,1003 @@
1
+ """
2
+ REST API endpoints for lab controller.
3
+ """
4
+
5
+ import threading
6
+ import time
7
+
8
+ from flask import Blueprint, g, jsonify, request
9
+
10
+ from labctl import __version__
11
+ from labctl.actuators.runtime import (
12
+ ActuationError,
13
+ PrePowerCheckError,
14
+ board_cycle_delay,
15
+ power_cycle_with_bindings,
16
+ power_on_with_bindings,
17
+ )
18
+ from labctl.core import audit
19
+ from labctl.core.manager import outlets_overlap, plug_outlet_key
20
+ from labctl.core.models import PortType, Status
21
+ from labctl.power import PowerController
22
+ from labctl.web import claims
23
+
24
+ api_bp = Blueprint("api", __name__)
25
+
26
+ # Rate limiting and overlap: keyed by plug outlet (type, address, index),
27
+ # not board name, since a cycle switches every board on the outlet. A
28
+ # cycle blocks its request for the whole delay, so the in-progress marker
29
+ # (not the time of the last cycle) is what refuses an overlapping one.
30
+ _power_cycle_times: dict[tuple, float] = {}
31
+ POWER_CYCLE_MIN_INTERVAL = 5.0 # seconds
32
+ _power_cycles_in_progress: set[tuple] = set()
33
+ _power_cycle_lock = threading.Lock()
34
+
35
+
36
+ def _outlet_key(sbc) -> tuple:
37
+ return plug_outlet_key(sbc.power_plug)
38
+
39
+
40
+ def outlet_cycle_in_progress(sbc) -> bool:
41
+ """Whether a REST/web power cycle of ``sbc``'s outlet is running in
42
+ this server (``on``/``off`` are refused meanwhile)."""
43
+ key = _outlet_key(sbc)
44
+ with _power_cycle_lock:
45
+ return any(outlets_overlap(key, k) for k in _power_cycles_in_progress)
46
+
47
+
48
+ def begin_power_cycle(sbc) -> str | None:
49
+ """Mark a REST/web power cycle of ``sbc``'s outlet as started, before
50
+ anything is switched; return why it must not start instead (a cycle
51
+ of that outlet is running, or the last one ended under
52
+ POWER_CYCLE_MIN_INTERVAL ago). Pair with :func:`end_power_cycle`."""
53
+ key = _outlet_key(sbc)
54
+ now = time.monotonic()
55
+ with _power_cycle_lock:
56
+ if any(outlets_overlap(key, k) for k in _power_cycles_in_progress):
57
+ return (
58
+ f"a power cycle of {sbc.name!r}'s power outlet is already in "
59
+ "progress"
60
+ )
61
+ last = max(
62
+ (t for k, t in _power_cycle_times.items() if outlets_overlap(key, k)),
63
+ default=float("-inf"),
64
+ )
65
+ elapsed = now - last
66
+ if elapsed < POWER_CYCLE_MIN_INTERVAL:
67
+ wait = POWER_CYCLE_MIN_INTERVAL - elapsed
68
+ return f"Rate limited: wait {wait:.1f}s before next power cycle"
69
+ _power_cycles_in_progress.add(key)
70
+ return None
71
+
72
+
73
+ def end_power_cycle(sbc, success: bool) -> None:
74
+ """The cycle begun with :func:`begin_power_cycle` is over; a
75
+ successful one starts the rate-limit interval."""
76
+ key = _outlet_key(sbc)
77
+ with _power_cycle_lock:
78
+ _power_cycles_in_progress.discard(key)
79
+ if success:
80
+ _power_cycle_times[key] = time.monotonic()
81
+
82
+
83
+ def sbc_to_dict(sbc) -> dict:
84
+ """Convert SBC model to JSON-serializable dict."""
85
+ return sbc.to_dict(include_ids=True)
86
+
87
+
88
+ def _refused(refusal):
89
+ """JSON response for a claim refusal (see labctl.web.claims.check)."""
90
+ body, status = refusal
91
+ return jsonify(body), status
92
+
93
+
94
+ # --- SBC Endpoints ---
95
+
96
+
97
+ @api_bp.route("/sbcs", methods=["GET"])
98
+ def list_sbcs():
99
+ """List all SBCs."""
100
+ project = request.args.get("project")
101
+ status = request.args.get("status")
102
+
103
+ status_filter = Status(status) if status else None
104
+ sbcs = g.manager.list_sbcs(project=project, status=status_filter)
105
+
106
+ return jsonify(
107
+ {
108
+ "sbcs": [sbc_to_dict(sbc) for sbc in sbcs],
109
+ "count": len(sbcs),
110
+ }
111
+ )
112
+
113
+
114
+ @api_bp.route("/sbcs/<name>", methods=["GET"])
115
+ def get_sbc(name: str):
116
+ """Get SBC details."""
117
+ sbc = g.manager.get_sbc_by_name(name)
118
+ if not sbc:
119
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
120
+
121
+ return jsonify(sbc_to_dict(sbc))
122
+
123
+
124
+ @api_bp.route("/sbcs", methods=["POST"])
125
+ def create_sbc():
126
+ """Create new SBC."""
127
+ data = request.get_json()
128
+ if not data or "name" not in data:
129
+ return jsonify({"error": "name is required"}), 400
130
+
131
+ try:
132
+ sbc = g.manager.create_sbc(
133
+ name=data["name"],
134
+ project=data.get("project"),
135
+ description=data.get("description"),
136
+ ssh_user=data.get("ssh_user", "root"),
137
+ )
138
+ return jsonify(sbc_to_dict(sbc)), 201
139
+ except ValueError as e:
140
+ return jsonify({"error": str(e)}), 400
141
+
142
+
143
+ @api_bp.route("/sbcs/<name>", methods=["PUT"])
144
+ def update_sbc(name: str):
145
+ """Update SBC."""
146
+ sbc = g.manager.get_sbc_by_name(name)
147
+ if not sbc:
148
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
149
+
150
+ data = request.get_json() or {}
151
+
152
+ status = None
153
+ if "status" in data:
154
+ try:
155
+ status = Status(data["status"])
156
+ except ValueError:
157
+ return jsonify({"error": f"Invalid status: {data['status']}"}), 400
158
+
159
+ refusal = claims.check(sbc.name, shared=False)
160
+ if refusal:
161
+ return _refused(refusal)
162
+
163
+ updated = g.manager.update_sbc(
164
+ sbc.id,
165
+ name=data.get("name"),
166
+ project=data.get("project"),
167
+ description=data.get("description"),
168
+ ssh_user=data.get("ssh_user"),
169
+ status=status,
170
+ )
171
+
172
+ return jsonify(sbc_to_dict(updated))
173
+
174
+
175
+ @api_bp.route("/sbcs/<name>", methods=["DELETE"])
176
+ def delete_sbc(name: str):
177
+ """Delete SBC."""
178
+ sbc = g.manager.get_sbc_by_name(name)
179
+ if not sbc:
180
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
181
+
182
+ from labctl.core.models import ClaimConflict, UnknownSBCError
183
+
184
+ # Force only past the claim there before the check: the caller's own
185
+ # (it goes with the board; nobody else can claim it meanwhile) or, for
186
+ # an operator's override, the one check() logs (also with claims
187
+ # disabled, when nothing is checked). A claim made after it isn't
188
+ # forced: delete_sbc refuses it below.
189
+ actor = claims.web_actor()
190
+ try:
191
+ current = g.manager.get_active_claim(sbc.name)
192
+ except UnknownSBCError:
193
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
194
+ force = current is not None and (
195
+ actor.holds(current) or (claims.override_requested() and actor.operator)
196
+ )
197
+
198
+ refusal = claims.check(sbc.name, shared=False)
199
+ if refusal:
200
+ return _refused(refusal)
201
+ try:
202
+ deleted = g.manager.delete_sbc(sbc.id, force=force)
203
+ except ClaimConflict as exc:
204
+ # Claimed meanwhile, or claims are disabled (nothing is checked,
205
+ # but a claimed board is only deleted with override_claim).
206
+ return jsonify(claims.claimed_error(sbc.name, exc.claim)), 409
207
+ if deleted:
208
+ return jsonify({"message": f"SBC '{name}' deleted"}), 200
209
+ return jsonify({"error": "Failed to delete SBC"}), 500
210
+
211
+
212
+ # --- Power Endpoints ---
213
+
214
+
215
+ @api_bp.route("/sbcs/<name>/power", methods=["GET"])
216
+ def get_power_status(name: str):
217
+ """Get power status for SBC."""
218
+ sbc = g.manager.get_sbc_by_name(name)
219
+ if not sbc:
220
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
221
+
222
+ if not sbc.power_plug:
223
+ return jsonify({"error": "No power plug assigned"}), 400
224
+
225
+ try:
226
+ controller = PowerController.from_plug(sbc.power_plug)
227
+ state = controller.get_state()
228
+ return jsonify(
229
+ {
230
+ "name": name,
231
+ "state": state.value,
232
+ "plug_type": sbc.power_plug.plug_type.value,
233
+ "plug_address": sbc.power_plug.address,
234
+ }
235
+ )
236
+ except Exception as e:
237
+ return jsonify({"error": str(e)}), 500
238
+
239
+
240
+ @api_bp.route("/sbcs/<name>/power", methods=["POST"])
241
+ def control_power(name: str):
242
+ """Control power for SBC."""
243
+ sbc = g.manager.get_sbc_by_name(name)
244
+ if not sbc:
245
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
246
+
247
+ if not sbc.power_plug:
248
+ return jsonify({"error": "No power plug assigned"}), 400
249
+
250
+ data = _json_body()
251
+ action = data.get("action")
252
+ if not isinstance(action, str):
253
+ return jsonify({"error": "action is required (on, off, cycle)"}), 400
254
+
255
+ action = action.lower()
256
+ if action not in ["on", "off", "cycle"]:
257
+ return jsonify({"error": "action must be on, off, or cycle"}), 400
258
+
259
+ delay = None
260
+ if action == "cycle":
261
+ # Omitted: the outlet's minimum (runtime.board_cycle_delay: the
262
+ # largest of the boards' stored delays, 3 s for one with none),
263
+ # which is also the minimum of a given delay. An out-of-range
264
+ # value, or an out-of-range stored one, is refused before anything
265
+ # is switched.
266
+ if "delay" in data and data["delay"] is None:
267
+ return (
268
+ jsonify({"error": "delay must be a number of seconds, not null"}),
269
+ 400,
270
+ )
271
+ try:
272
+ delay = board_cycle_delay(g.manager, sbc, data.get("delay"))
273
+ except ValueError as e:
274
+ return jsonify({"error": str(e)}), 400
275
+
276
+ refusal = claims.check(sbc.name)
277
+ if refusal:
278
+ return _refused(refusal)
279
+
280
+ # Rate limit power cycles to prevent hardware damage. Marked before
281
+ # the cycle starts: it blocks for the whole delay, and an overlapping
282
+ # request must not start a second one.
283
+ if action == "cycle":
284
+ busy = begin_power_cycle(sbc)
285
+ if busy:
286
+ return jsonify({"error": busy}), 429
287
+ elif outlet_cycle_in_progress(sbc):
288
+ # Switching the outlet mid-cycle would undo or race the cycle.
289
+ return (
290
+ jsonify(
291
+ {
292
+ "error": (
293
+ f"a power cycle of {sbc.name!r}'s power outlet is in "
294
+ "progress; retry when it has finished"
295
+ ),
296
+ "switched": False,
297
+ }
298
+ ),
299
+ 409,
300
+ )
301
+
302
+ success = False
303
+ try:
304
+ controller = PowerController.from_plug(sbc.power_plug)
305
+
306
+ if action == "on":
307
+ success = power_on_with_bindings(g.manager, sbc, controller)
308
+ elif action == "off":
309
+ success = controller.power_off()
310
+ else:
311
+ success = power_cycle_with_bindings(g.manager, sbc, controller, delay)
312
+
313
+ if success:
314
+ state = controller.get_state()
315
+ body = {
316
+ "name": name,
317
+ "action": action,
318
+ "success": True,
319
+ "state": state.value,
320
+ }
321
+ if action == "cycle":
322
+ body["delay_seconds"] = delay.seconds
323
+ body["delay_source"] = delay.source
324
+ return jsonify(body)
325
+ else:
326
+ return (
327
+ jsonify(
328
+ {
329
+ "name": name,
330
+ "action": action,
331
+ "success": False,
332
+ "error": "Power operation failed",
333
+ }
334
+ ),
335
+ 500,
336
+ )
337
+ except ActuationError as e:
338
+ # Refused before anything was switched (the pre-check, or a strap
339
+ # that failed before power-on): the board is as it was. A strap
340
+ # failure after a cycle's power-off is a 500.
341
+ if isinstance(e, PrePowerCheckError) or action == "on":
342
+ return jsonify({"error": str(e), "switched": False}), 409
343
+ return jsonify({"error": str(e)}), 500
344
+ except Exception as e:
345
+ return jsonify({"error": str(e)}), 500
346
+ finally:
347
+ if action == "cycle":
348
+ end_power_cycle(sbc, success)
349
+
350
+
351
+ # --- Port Endpoints ---
352
+
353
+
354
+ @api_bp.route("/sbcs/<name>/ports", methods=["POST"])
355
+ def assign_port(name: str):
356
+ """Assign serial port to SBC."""
357
+ sbc = g.manager.get_sbc_by_name(name)
358
+ if not sbc:
359
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
360
+
361
+ data = request.get_json()
362
+ if not data or "type" not in data or "device" not in data:
363
+ return jsonify({"error": "type and device are required"}), 400
364
+ if not isinstance(data["device"], str) or not data["device"].strip():
365
+ return jsonify({"error": "device must be a device path"}), 400
366
+
367
+ try:
368
+ port_type = PortType(data["type"])
369
+ except ValueError:
370
+ return jsonify({"error": f"Invalid port type: {data['type']}"}), 400
371
+
372
+ # Resolve serial device name to ID
373
+ serial_device_id = None
374
+ sd_name = data.get("serial_device")
375
+ if sd_name:
376
+ sd = g.manager.get_serial_device_by_name(sd_name)
377
+ if sd:
378
+ serial_device_id = sd.id
379
+
380
+ # The boards already wired to this console are checked too: otherwise
381
+ # a claimed board's console could be typed into through this one.
382
+ refusal = claims.check(
383
+ sbc.name,
384
+ shared=False,
385
+ sharers=dict(
386
+ serial_device_path=data["device"],
387
+ serial_device_id=serial_device_id,
388
+ # Anything else is left to assign_serial_port to refuse.
389
+ tcp_port=claims.port_number(data.get("tcp_port")),
390
+ ),
391
+ )
392
+ if refusal:
393
+ return _refused(refusal)
394
+
395
+ try:
396
+ port = g.manager.assign_serial_port(
397
+ sbc_id=sbc.id,
398
+ port_type=port_type,
399
+ device_path=data["device"],
400
+ tcp_port=data.get("tcp_port"),
401
+ baud_rate=data.get("baud_rate", 115200),
402
+ alias=data.get("alias"),
403
+ serial_device_id=serial_device_id,
404
+ )
405
+ except ValueError as e:
406
+ return jsonify({"error": str(e)}), 400
407
+
408
+ return (
409
+ jsonify(
410
+ {
411
+ "id": port.id,
412
+ "type": port.port_type.value,
413
+ "device": port.device_path,
414
+ "tcp_port": port.tcp_port,
415
+ "baud_rate": port.baud_rate,
416
+ "alias": port.alias,
417
+ }
418
+ ),
419
+ 201,
420
+ )
421
+
422
+
423
+ @api_bp.route("/sbcs/<name>/console/info", methods=["GET"])
424
+ def get_console_info(name: str):
425
+ """Get console connection info for an SBC.
426
+
427
+ Returns the information needed to connect to the SBC's serial console,
428
+ including TCP port, baud rate, and proxy port if available.
429
+ """
430
+ from flask import current_app
431
+
432
+ sbc = g.manager.get_sbc_by_name(name)
433
+ if not sbc:
434
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
435
+
436
+ console_port = sbc.console_port
437
+ if not console_port:
438
+ return jsonify({"error": "No console port assigned"}), 400
439
+
440
+ config = current_app.config.get("LABCTL_CONFIG")
441
+
442
+ response = {
443
+ "name": name,
444
+ "device_path": console_port.device_path,
445
+ "baud_rate": console_port.baud_rate,
446
+ "tcp_port": console_port.tcp_port,
447
+ "tcp_host": "localhost",
448
+ }
449
+
450
+ # Add proxy info if available
451
+ if config and config.proxy:
452
+ response["proxy"] = {
453
+ "port_base": config.proxy.port_base,
454
+ "write_policy": config.proxy.write_policy,
455
+ "max_clients": config.proxy.max_clients,
456
+ }
457
+
458
+ # Add connection commands
459
+ if console_port.tcp_port:
460
+ response["connect_commands"] = {
461
+ "netcat": f"nc localhost {console_port.tcp_port}",
462
+ "telnet": f"telnet localhost {console_port.tcp_port}",
463
+ }
464
+ else:
465
+ baud = console_port.baud_rate
466
+ dev = console_port.device_path
467
+ response["connect_commands"] = {
468
+ "picocom": f"picocom -b {baud} {dev}",
469
+ }
470
+
471
+ return jsonify(response)
472
+
473
+
474
+ @api_bp.route("/ports", methods=["GET"])
475
+ def list_ports():
476
+ """List all serial port assignments."""
477
+ ports = g.manager.list_serial_ports()
478
+
479
+ # Get SBC names
480
+ sbc_names = {}
481
+ for sbc in g.manager.list_sbcs():
482
+ sbc_names[sbc.id] = sbc.name
483
+
484
+ return jsonify(
485
+ {
486
+ "ports": [
487
+ {
488
+ "id": p.id,
489
+ "sbc_id": p.sbc_id,
490
+ "sbc_name": sbc_names.get(p.sbc_id),
491
+ "type": p.port_type.value,
492
+ "device": p.device_path,
493
+ "tcp_port": p.tcp_port,
494
+ "baud_rate": p.baud_rate,
495
+ }
496
+ for p in ports
497
+ ],
498
+ "count": len(ports),
499
+ }
500
+ )
501
+
502
+
503
+ # --- Status Endpoints ---
504
+
505
+
506
+ @api_bp.route("/health", methods=["GET"])
507
+ def health_check():
508
+ """System health check."""
509
+ return jsonify(
510
+ {
511
+ "status": "healthy",
512
+ "version": __version__,
513
+ }
514
+ )
515
+
516
+
517
+ @api_bp.route("/status", methods=["GET"])
518
+ def get_status():
519
+ """Get status overview of all SBCs."""
520
+ sbcs = g.manager.list_sbcs()
521
+
522
+ status_list = []
523
+ for sbc in sbcs:
524
+ status_data = {
525
+ "name": sbc.name,
526
+ "project": sbc.project,
527
+ "status": sbc.status.value,
528
+ "ip": sbc.primary_ip,
529
+ "power": None,
530
+ }
531
+
532
+ # Get power state if plug assigned
533
+ if sbc.power_plug:
534
+ try:
535
+ controller = PowerController.from_plug(sbc.power_plug)
536
+ state = controller.get_state()
537
+ status_data["power"] = state.value
538
+ except Exception:
539
+ status_data["power"] = "error"
540
+
541
+ status_list.append(status_data)
542
+
543
+ return jsonify(
544
+ {
545
+ "sbcs": status_list,
546
+ "count": len(status_list),
547
+ }
548
+ )
549
+
550
+
551
+ @api_bp.route("/sbcs/<name>/history", methods=["GET"])
552
+ def get_sbc_history(name: str):
553
+ """Get status history for an SBC."""
554
+ sbc = g.manager.get_sbc_by_name(name)
555
+ if not sbc:
556
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
557
+
558
+ limit = request.args.get("limit", 100, type=int)
559
+ history = g.manager.get_status_history(sbc_id=sbc.id, limit=limit)
560
+
561
+ return jsonify(
562
+ {
563
+ "sbc_name": name,
564
+ "history": history,
565
+ "count": len(history),
566
+ }
567
+ )
568
+
569
+
570
+ @api_bp.route("/sbcs/<name>/uptime", methods=["GET"])
571
+ def get_sbc_uptime(name: str):
572
+ """Get uptime statistics for an SBC."""
573
+ sbc = g.manager.get_sbc_by_name(name)
574
+ if not sbc:
575
+ return jsonify({"error": f"SBC '{name}' not found"}), 404
576
+
577
+ uptime = g.manager.get_uptime(sbc.id)
578
+ if uptime is None:
579
+ return jsonify({"error": "No uptime data available"}), 404
580
+
581
+ return jsonify(uptime)
582
+
583
+
584
+ @api_bp.route("/health/check", methods=["GET", "POST"])
585
+ def run_health_check():
586
+ """Run health checks on SBCs.
587
+
588
+ GET: Returns cached/last check results
589
+ POST: Runs a new health check and returns results
590
+ """
591
+ from flask import current_app
592
+
593
+ from labctl.health import CheckType, HealthChecker
594
+
595
+ # Get config from app
596
+ config = current_app.config.get("LABCTL_CONFIG")
597
+
598
+ # Create checker
599
+ checker = HealthChecker(
600
+ ping_timeout=config.health.ping_timeout if config else 2.0,
601
+ serial_timeout=config.health.serial_timeout if config else 2.0,
602
+ )
603
+
604
+ # Get optional SBC filter
605
+ sbc_name = request.args.get("sbc")
606
+
607
+ # Get check types
608
+ check_type = request.args.get("type", "all")
609
+ if check_type == "all":
610
+ types = [CheckType.PING, CheckType.SERIAL, CheckType.POWER]
611
+ else:
612
+ try:
613
+ types = [CheckType(check_type)]
614
+ except ValueError:
615
+ return jsonify({"error": f"Invalid check type: {check_type}"}), 400
616
+
617
+ # Get SBCs to check
618
+ if sbc_name:
619
+ sbc = g.manager.get_sbc_by_name(sbc_name)
620
+ if not sbc:
621
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
622
+ sbcs = [sbc]
623
+ else:
624
+ sbcs = g.manager.list_sbcs()
625
+
626
+ # Run checks
627
+ results = checker.check_all(sbcs, types)
628
+
629
+ # Format results for JSON
630
+ output = {}
631
+ for name, summary in results.items():
632
+ sbc_result = {
633
+ "recommended_status": (
634
+ summary.recommended_status.value if summary.recommended_status else None
635
+ ),
636
+ }
637
+
638
+ if summary.ping_result:
639
+ sbc_result["ping"] = {
640
+ "success": summary.ping_result.success,
641
+ "message": summary.ping_result.message,
642
+ "duration_ms": summary.ping_result.duration_ms,
643
+ }
644
+
645
+ if summary.serial_result:
646
+ sbc_result["serial"] = {
647
+ "success": summary.serial_result.success,
648
+ "message": summary.serial_result.message,
649
+ "duration_ms": summary.serial_result.duration_ms,
650
+ }
651
+
652
+ if summary.power_result:
653
+ sbc_result["power"] = {
654
+ "success": summary.power_result.success,
655
+ "message": summary.power_result.message,
656
+ "duration_ms": summary.power_result.duration_ms,
657
+ "state": summary.power_state.value if summary.power_state else None,
658
+ }
659
+
660
+ output[name] = sbc_result
661
+
662
+ return jsonify(
663
+ {
664
+ "results": output,
665
+ "count": len(output),
666
+ }
667
+ )
668
+
669
+
670
+ # --- Claim Endpoints ---
671
+
672
+
673
+ @api_bp.route("/claims", methods=["GET"])
674
+ def list_claims():
675
+ """List all active claims."""
676
+ claims = g.manager.list_active_claims()
677
+ return jsonify({"claims": [c.to_dict() for c in claims], "count": len(claims)})
678
+
679
+
680
+ @api_bp.route("/claims/<sbc_name>", methods=["GET"])
681
+ def get_claim(sbc_name: str):
682
+ """Get the current claim on an SBC (or 'claimed: false')."""
683
+ from labctl.core.models import UnknownSBCError
684
+
685
+ try:
686
+ claim = g.manager.get_active_claim(sbc_name)
687
+ except UnknownSBCError:
688
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
689
+
690
+ if claim is None:
691
+ return jsonify({"sbc_name": sbc_name, "claimed": False})
692
+ return jsonify({"sbc_name": sbc_name, "claimed": True, "claim": claim.to_dict()})
693
+
694
+
695
+ @api_bp.route("/claims/<sbc_name>/history", methods=["GET"])
696
+ def get_claim_history(sbc_name: str):
697
+ """Past claims for an SBC."""
698
+ from labctl.core.models import UnknownSBCError
699
+
700
+ limit = request.args.get("limit", 20, type=int)
701
+ try:
702
+ history = g.manager.list_claim_history(sbc_name, limit=limit)
703
+ except UnknownSBCError:
704
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
705
+ return jsonify({"history": [c.to_dict() for c in history]})
706
+
707
+
708
+ def _duration_seconds(value):
709
+ """Minutes from a request body as seconds within the configured bounds,
710
+ or an error response."""
711
+ claims_config = g.config.claims
712
+ if isinstance(value, float) and value.is_integer():
713
+ value = int(value)
714
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
715
+ value = None
716
+ try:
717
+ duration_s = int(value) * 60
718
+ except (TypeError, ValueError):
719
+ return None, (
720
+ jsonify(
721
+ {
722
+ "error": "invalid_duration",
723
+ "message": "duration_minutes must be a whole number",
724
+ }
725
+ ),
726
+ 400,
727
+ )
728
+ min_s = claims_config.min_duration_minutes * 60
729
+ max_s = claims_config.max_duration_minutes * 60
730
+ if duration_s < min_s or duration_s > max_s:
731
+ return None, (
732
+ jsonify(
733
+ {
734
+ "error": "duration_out_of_bounds",
735
+ "message": (
736
+ f"Duration {duration_s // 60}m is outside "
737
+ f"[{claims_config.min_duration_minutes}m, "
738
+ f"{claims_config.max_duration_minutes}m]"
739
+ ),
740
+ }
741
+ ),
742
+ 400,
743
+ )
744
+ return duration_s, None
745
+
746
+
747
+ def _json_body() -> dict:
748
+ data = request.get_json(silent=True)
749
+ return data if isinstance(data, dict) else {}
750
+
751
+
752
+ @api_bp.route("/claims/<sbc_name>", methods=["POST"])
753
+ def create_claim(sbc_name: str):
754
+ """Claim exclusive access to an SBC.
755
+
756
+ The claim belongs to the caller's user (with auth), so their other
757
+ sessions (the web UI, MCP over HTTP) count as holding it too.
758
+ """
759
+ from labctl.actuators.runtime import ChannelBusyError
760
+ from labctl.core.models import ClaimConflict, UnknownSBCError
761
+
762
+ data = _json_body()
763
+ config = g.config
764
+ duration_s, error = _duration_seconds(
765
+ data.get("duration_minutes", config.claims.default_duration_minutes)
766
+ )
767
+ if error:
768
+ return error
769
+ reason = data.get("reason") or "web claim"
770
+ agent_name = data.get("agent_name")
771
+ if agent_name is None:
772
+ agent_name = ""
773
+ if not isinstance(reason, str) or not isinstance(agent_name, str):
774
+ return jsonify({"error": "reason and agent_name must be strings"}), 400
775
+ agent_name = agent_name.strip()
776
+ if not agent_name:
777
+ # Without auth there is no user to name the claim after.
778
+ if config.claims.require_agent_name and not claims.web_actor().owner:
779
+ return (
780
+ jsonify(
781
+ {
782
+ "error": "agent_name_required",
783
+ "message": (
784
+ "This lab requires claims to name the agent "
785
+ "(claims.require_agent_name): send agent_name"
786
+ ),
787
+ }
788
+ ),
789
+ 400,
790
+ )
791
+ agent_name = claims.web_user_name()
792
+
793
+ manager = g.manager
794
+ actor = claims.web_actor()
795
+ grace = config.claims.grace_period_seconds
796
+
797
+ def already_claimed(claim):
798
+ return (
799
+ jsonify(
800
+ {
801
+ "error": "already_claimed",
802
+ "sbc_name": sbc_name,
803
+ "message": (
804
+ f"You already hold the claim on '{sbc_name}'; extend it "
805
+ f"with POST /api/claims/{sbc_name}/renew"
806
+ ),
807
+ "claim": claim.to_dict(),
808
+ }
809
+ ),
810
+ 409,
811
+ )
812
+
813
+ try:
814
+ if config.claims.enabled:
815
+ own = manager.get_open_claim(sbc_name)
816
+ if own is not None and own.is_active and actor.holds(own):
817
+ return already_claimed(own)
818
+ # The board's own holder, or another user's claim on a board
819
+ # sharing its hardware (the new claim would let the caller
820
+ # drive that board).
821
+ blocking = manager.new_claim_conflict(sbc_name, actor, grace)
822
+ if blocking is not None:
823
+ return (
824
+ jsonify(
825
+ claims.claimed_error(sbc_name, blocking, override_hint=False)
826
+ ),
827
+ 409,
828
+ )
829
+ claim = manager.claim_sbc(
830
+ sbc_name=sbc_name,
831
+ agent_name=agent_name,
832
+ session_id=actor.session_id,
833
+ session_kind="web",
834
+ duration_seconds=duration_s,
835
+ reason=reason,
836
+ grace_seconds=grace,
837
+ owner=actor.owner,
838
+ )
839
+ except UnknownSBCError:
840
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
841
+ except ClaimConflict as exc:
842
+ if actor.holds(exc.claim):
843
+ return already_claimed(exc.claim)
844
+ return (
845
+ jsonify(claims.claimed_error(sbc_name, exc.claim, override_hint=False)),
846
+ 409,
847
+ )
848
+ except ChannelBusyError as exc:
849
+ return jsonify({"error": "channel_busy", "message": str(exc)}), 409
850
+
851
+ return jsonify({"status": "claimed", "claim": claim.to_dict()}), 201
852
+
853
+
854
+ @api_bp.route("/claims/<sbc_name>/release", methods=["POST"])
855
+ def release_claim(sbc_name: str):
856
+ """Release a claim (the caller's user must hold it)."""
857
+ from labctl.core.models import (
858
+ ClaimNotFoundError,
859
+ NotClaimantError,
860
+ UnknownSBCError,
861
+ )
862
+
863
+ actor = claims.web_actor()
864
+ try:
865
+ g.manager.release_claim(
866
+ sbc_name,
867
+ actor.session_id,
868
+ owner=actor.owner,
869
+ released_by=claims.web_user_name(),
870
+ )
871
+ except UnknownSBCError:
872
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
873
+ except ClaimNotFoundError:
874
+ return jsonify({"error": "claim_not_found"}), 404
875
+ except NotClaimantError:
876
+ return jsonify({"error": "not_claimant"}), 403
877
+ return jsonify({"status": "released"})
878
+
879
+
880
+ @api_bp.route("/claims/<sbc_name>/renew", methods=["POST"])
881
+ def renew_claim(sbc_name: str):
882
+ """Renew / extend a claim (the caller's user must hold it)."""
883
+ from labctl.core.models import (
884
+ ClaimNotFoundError,
885
+ NotClaimantError,
886
+ UnknownSBCError,
887
+ )
888
+
889
+ data = _json_body()
890
+ duration_s = None
891
+ if data.get("duration_minutes") is not None:
892
+ duration_s, error = _duration_seconds(data["duration_minutes"])
893
+ if error:
894
+ return error
895
+ actor = claims.web_actor()
896
+ try:
897
+ claim = g.manager.renew_claim(
898
+ sbc_name, actor.session_id, duration_seconds=duration_s, owner=actor.owner
899
+ )
900
+ except UnknownSBCError:
901
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
902
+ except ClaimNotFoundError:
903
+ return jsonify({"error": "claim_not_found"}), 404
904
+ except NotClaimantError:
905
+ return jsonify({"error": "not_claimant"}), 403
906
+ return jsonify({"status": "renewed", "claim": claim.to_dict()})
907
+
908
+
909
+ @api_bp.route("/claims/<sbc_name>/force-release", methods=["POST"])
910
+ def force_release_claim(sbc_name: str):
911
+ """Operator override — forcibly release a claim (operators only)."""
912
+ from labctl.core.models import ClaimNotFoundError, UnknownSBCError
913
+
914
+ if not claims.web_actor().operator:
915
+ return (
916
+ jsonify(
917
+ {
918
+ "error": "not_operator",
919
+ "sbc_name": sbc_name,
920
+ "message": (
921
+ "Only operators may force-release a claim; your user "
922
+ "has role 'agent'."
923
+ ),
924
+ "hints": [
925
+ f"Ask for it: POST /api/claims/{sbc_name}/request-release"
926
+ ],
927
+ }
928
+ ),
929
+ 403,
930
+ )
931
+ data = _json_body()
932
+ reason = data.get("reason") or "operator force-release via web"
933
+ try:
934
+ released = g.manager.force_release_claim(
935
+ sbc_name, str(reason), released_by=claims.web_user_name()
936
+ )
937
+ except UnknownSBCError:
938
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
939
+ except ClaimNotFoundError:
940
+ return jsonify({"error": "claim_not_found"}), 404
941
+ return jsonify({"status": "force_released", "was_held_by": released.agent_name})
942
+
943
+
944
+ @api_bp.route("/claims/<sbc_name>/request-release", methods=["POST"])
945
+ def request_release(sbc_name: str):
946
+ """Politely ask the claimant to release."""
947
+ from labctl.core.models import ClaimNotFoundError, UnknownSBCError
948
+
949
+ data = _json_body()
950
+ reason = data.get("reason") or "web request"
951
+ try:
952
+ g.manager.record_release_request(
953
+ sbc_name, requested_by=claims.web_user_name(), reason=str(reason)
954
+ )
955
+ except UnknownSBCError:
956
+ return jsonify({"error": f"SBC '{sbc_name}' not found"}), 404
957
+ except ClaimNotFoundError:
958
+ return jsonify({"error": "claim_not_found"}), 404
959
+ return jsonify({"status": "request_recorded"})
960
+
961
+
962
+ # --- Activity Stream Endpoints ---
963
+
964
+
965
+ @api_bp.route("/activity", methods=["GET"])
966
+ def list_activity():
967
+ """List recent activity events, newest first.
968
+
969
+ Query params:
970
+ limit=N Max events (default 50, max 1000).
971
+ sbc=X Filter by entity_name.
972
+ actor=X Filter by actor (exact match).
973
+ source=X cli | mcp | api | web | daemon | internal.
974
+ result=X ok | error | forbidden.
975
+ since=TS ISO timestamp lower bound.
976
+ after_id=N Only events with id > N (for long polling).
977
+ """
978
+ try:
979
+ limit = min(int(request.args.get("limit", 50)), 1000)
980
+ except ValueError:
981
+ return jsonify({"error": "limit must be an integer"}), 400
982
+
983
+ since = request.args.get("since")
984
+ after_id = request.args.get("after_id")
985
+ if after_id:
986
+ try:
987
+ after_id_int = int(after_id)
988
+ except ValueError:
989
+ return jsonify({"error": "after_id must be an integer"}), 400
990
+ else:
991
+ after_id_int = None
992
+
993
+ events = audit.query_events(
994
+ g.manager.db,
995
+ limit=limit,
996
+ sbc=request.args.get("sbc"),
997
+ actor=request.args.get("actor"),
998
+ source=request.args.get("source"),
999
+ result=request.args.get("result"),
1000
+ since=since,
1001
+ after_id=after_id_int,
1002
+ )
1003
+ return jsonify({"events": events, "count": len(events)})