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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- 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)})
|