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/views.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web views for lab controller dashboard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from flask import (
|
|
6
|
+
Blueprint,
|
|
7
|
+
Response,
|
|
8
|
+
current_app,
|
|
9
|
+
flash,
|
|
10
|
+
g,
|
|
11
|
+
redirect,
|
|
12
|
+
render_template,
|
|
13
|
+
stream_with_context,
|
|
14
|
+
url_for,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from labctl.actuators.runtime import power_cycle_with_bindings, power_on_with_bindings
|
|
18
|
+
from labctl.core.models import AddressType, PlugType, PortType, Status
|
|
19
|
+
from labctl.power import PowerController, PowerState
|
|
20
|
+
from labctl.web import claims
|
|
21
|
+
from labctl.web.api import (
|
|
22
|
+
begin_power_cycle,
|
|
23
|
+
end_power_cycle,
|
|
24
|
+
outlet_cycle_in_progress,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
views_bp = Blueprint("views", __name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@views_bp.route("/")
|
|
31
|
+
def index():
|
|
32
|
+
"""Dashboard home page."""
|
|
33
|
+
sbcs = g.manager.list_sbcs()
|
|
34
|
+
|
|
35
|
+
# Get power states for SBCs with plugs
|
|
36
|
+
power_states = {}
|
|
37
|
+
for sbc in sbcs:
|
|
38
|
+
if sbc.power_plug:
|
|
39
|
+
try:
|
|
40
|
+
controller = PowerController.from_plug(sbc.power_plug)
|
|
41
|
+
power_states[sbc.name] = controller.get_state()
|
|
42
|
+
except Exception:
|
|
43
|
+
power_states[sbc.name] = PowerState.UNKNOWN
|
|
44
|
+
|
|
45
|
+
# Index active claims by SBC name for dashboard badges
|
|
46
|
+
claims_by_sbc = {c.sbc_name: c for c in g.manager.list_active_claims()}
|
|
47
|
+
|
|
48
|
+
return render_template(
|
|
49
|
+
"dashboard.html",
|
|
50
|
+
sbcs=sbcs,
|
|
51
|
+
power_states=power_states,
|
|
52
|
+
claims_by_sbc=claims_by_sbc,
|
|
53
|
+
PowerState=PowerState,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@views_bp.route("/sbc/<name>")
|
|
58
|
+
def sbc_detail(name: str):
|
|
59
|
+
"""SBC detail page."""
|
|
60
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
61
|
+
if not sbc:
|
|
62
|
+
flash(f"SBC '{name}' not found", "error")
|
|
63
|
+
return redirect(url_for("views.index"))
|
|
64
|
+
|
|
65
|
+
power_state = None
|
|
66
|
+
if sbc.power_plug:
|
|
67
|
+
try:
|
|
68
|
+
controller = PowerController.from_plug(sbc.power_plug)
|
|
69
|
+
power_state = controller.get_state()
|
|
70
|
+
except Exception:
|
|
71
|
+
power_state = PowerState.UNKNOWN
|
|
72
|
+
|
|
73
|
+
# Load active claim for this SBC
|
|
74
|
+
active_claim = g.manager.get_active_claim(sbc.name)
|
|
75
|
+
|
|
76
|
+
return render_template(
|
|
77
|
+
"sbc_detail.html",
|
|
78
|
+
sbc=sbc,
|
|
79
|
+
power_state=power_state,
|
|
80
|
+
active_claim=active_claim,
|
|
81
|
+
PowerState=PowerState,
|
|
82
|
+
Status=Status,
|
|
83
|
+
PortType=PortType,
|
|
84
|
+
AddressType=AddressType,
|
|
85
|
+
PlugType=PlugType,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@views_bp.route("/sbc/<name>/power/<action>", methods=["POST"])
|
|
90
|
+
def sbc_power_action(name: str, action: str):
|
|
91
|
+
"""Handle power control actions."""
|
|
92
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
93
|
+
if not sbc:
|
|
94
|
+
flash(f"SBC '{name}' not found", "error")
|
|
95
|
+
return redirect(url_for("views.index"))
|
|
96
|
+
|
|
97
|
+
if not sbc.power_plug:
|
|
98
|
+
flash("No power plug assigned", "error")
|
|
99
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
100
|
+
|
|
101
|
+
if action not in ("on", "off", "cycle"):
|
|
102
|
+
flash(f"Unknown action: {action}", "error")
|
|
103
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
104
|
+
|
|
105
|
+
refusal = claims.check(name)
|
|
106
|
+
if refusal:
|
|
107
|
+
return claims.refusal_page(refusal, name)
|
|
108
|
+
|
|
109
|
+
# Same guards as REST: one cycle of an outlet at a time, rate-limited,
|
|
110
|
+
# and no on/off of an outlet while it is being cycled.
|
|
111
|
+
if action == "cycle":
|
|
112
|
+
busy = begin_power_cycle(sbc)
|
|
113
|
+
if busy:
|
|
114
|
+
flash(f"Error: {busy}", "error")
|
|
115
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
116
|
+
elif outlet_cycle_in_progress(sbc):
|
|
117
|
+
flash(
|
|
118
|
+
f"Error: a power cycle of {name!r}'s power outlet is in progress; "
|
|
119
|
+
"retry when it has finished",
|
|
120
|
+
"error",
|
|
121
|
+
)
|
|
122
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
123
|
+
|
|
124
|
+
success = False
|
|
125
|
+
try:
|
|
126
|
+
controller = PowerController.from_plug(sbc.power_plug)
|
|
127
|
+
|
|
128
|
+
if action == "on":
|
|
129
|
+
success = power_on_with_bindings(g.manager, sbc, controller)
|
|
130
|
+
msg = "Power ON" if success else "Failed to power on"
|
|
131
|
+
elif action == "off":
|
|
132
|
+
success = controller.power_off()
|
|
133
|
+
msg = "Power OFF" if success else "Failed to power off"
|
|
134
|
+
else:
|
|
135
|
+
success = power_cycle_with_bindings(g.manager, sbc, controller)
|
|
136
|
+
msg = "Power cycled" if success else "Failed to power cycle"
|
|
137
|
+
|
|
138
|
+
flash(msg, "success" if success else "error")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
flash(f"Error: {e}", "error")
|
|
141
|
+
finally:
|
|
142
|
+
if action == "cycle":
|
|
143
|
+
end_power_cycle(sbc, success)
|
|
144
|
+
|
|
145
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@views_bp.route("/sbc/<name>/console")
|
|
149
|
+
def sbc_console(name: str):
|
|
150
|
+
"""SBC console page with xterm.js terminal."""
|
|
151
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
152
|
+
if not sbc:
|
|
153
|
+
flash(f"SBC '{name}' not found", "error")
|
|
154
|
+
return redirect(url_for("views.index"))
|
|
155
|
+
|
|
156
|
+
# Find console port
|
|
157
|
+
port = sbc.console_port
|
|
158
|
+
|
|
159
|
+
return render_template(
|
|
160
|
+
"console.html",
|
|
161
|
+
sbc=sbc,
|
|
162
|
+
port=port,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@views_bp.route("/sbc/<name>/history")
|
|
167
|
+
def sbc_history(name: str):
|
|
168
|
+
"""SBC status history page."""
|
|
169
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
170
|
+
if not sbc:
|
|
171
|
+
flash(f"SBC '{name}' not found", "error")
|
|
172
|
+
return redirect(url_for("views.index"))
|
|
173
|
+
|
|
174
|
+
# Get status history
|
|
175
|
+
history = g.manager.get_status_history(sbc_id=sbc.id, limit=100)
|
|
176
|
+
|
|
177
|
+
return render_template(
|
|
178
|
+
"status_history.html",
|
|
179
|
+
sbc=sbc,
|
|
180
|
+
history=history,
|
|
181
|
+
Status=Status,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@views_bp.route("/sbc/<name>/edit", methods=["POST"])
|
|
186
|
+
def sbc_edit(name: str):
|
|
187
|
+
"""Handle SBC edit form."""
|
|
188
|
+
from flask import request
|
|
189
|
+
|
|
190
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
191
|
+
if not sbc:
|
|
192
|
+
flash(f"SBC '{name}' not found", "error")
|
|
193
|
+
return redirect(url_for("views.index"))
|
|
194
|
+
|
|
195
|
+
new_name = request.form.get("name", "").strip() or None
|
|
196
|
+
project = request.form.get("project", "").strip() or None
|
|
197
|
+
description = request.form.get("description", "").strip() or None
|
|
198
|
+
ssh_user = request.form.get("ssh_user", "").strip() or "root"
|
|
199
|
+
status = request.form.get("status", "").strip()
|
|
200
|
+
|
|
201
|
+
status_enum = Status(status) if status else None
|
|
202
|
+
|
|
203
|
+
refusal = claims.check(name, shared=False)
|
|
204
|
+
if refusal:
|
|
205
|
+
return claims.refusal_page(refusal, name)
|
|
206
|
+
|
|
207
|
+
# Only pass new_name if it actually changed
|
|
208
|
+
rename = new_name if new_name and new_name != name else None
|
|
209
|
+
|
|
210
|
+
g.manager.update_sbc(
|
|
211
|
+
sbc.id,
|
|
212
|
+
name=rename,
|
|
213
|
+
project=project,
|
|
214
|
+
description=description,
|
|
215
|
+
ssh_user=ssh_user,
|
|
216
|
+
status=status_enum,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
display_name = rename or name
|
|
220
|
+
flash(f"Updated SBC '{display_name}'", "success")
|
|
221
|
+
return redirect(url_for("views.sbc_detail", name=display_name))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@views_bp.route("/sbc/<name>/port/assign", methods=["POST"])
|
|
225
|
+
def sbc_port_assign(name: str):
|
|
226
|
+
"""Handle port assignment form."""
|
|
227
|
+
from flask import request
|
|
228
|
+
|
|
229
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
230
|
+
if not sbc:
|
|
231
|
+
flash(f"SBC '{name}' not found", "error")
|
|
232
|
+
return redirect(url_for("views.index"))
|
|
233
|
+
|
|
234
|
+
port_type = request.form.get("port_type", "").strip()
|
|
235
|
+
alias = request.form.get("alias", "").strip() or None
|
|
236
|
+
device = request.form.get("device", "").strip()
|
|
237
|
+
tcp_port = request.form.get("tcp_port", "").strip()
|
|
238
|
+
baud_rate = request.form.get("baud_rate", "115200").strip()
|
|
239
|
+
|
|
240
|
+
if not port_type or not device:
|
|
241
|
+
flash("Port type and device are required", "error")
|
|
242
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
243
|
+
|
|
244
|
+
# The boards already wired to this console are checked too: otherwise
|
|
245
|
+
# a claimed board's console could be typed into through this one.
|
|
246
|
+
refusal = claims.check(
|
|
247
|
+
name,
|
|
248
|
+
shared=False,
|
|
249
|
+
sharers=dict(
|
|
250
|
+
serial_device_path=device,
|
|
251
|
+
tcp_port=claims.port_number(tcp_port),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
if refusal:
|
|
255
|
+
return claims.refusal_page(refusal, name)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
g.manager.assign_serial_port(
|
|
259
|
+
sbc_id=sbc.id,
|
|
260
|
+
port_type=PortType(port_type),
|
|
261
|
+
device_path=device,
|
|
262
|
+
tcp_port=int(tcp_port) if tcp_port else None,
|
|
263
|
+
baud_rate=int(baud_rate),
|
|
264
|
+
alias=alias,
|
|
265
|
+
)
|
|
266
|
+
flash(f"Assigned {port_type} port", "success")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
flash(f"Error: {e}", "error")
|
|
269
|
+
|
|
270
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@views_bp.route("/sbc/<name>/port/remove/<port_type>", methods=["POST"])
|
|
274
|
+
def sbc_port_remove(name: str, port_type: str):
|
|
275
|
+
"""Remove port assignment."""
|
|
276
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
277
|
+
if not sbc:
|
|
278
|
+
flash(f"SBC '{name}' not found", "error")
|
|
279
|
+
return redirect(url_for("views.index"))
|
|
280
|
+
|
|
281
|
+
refusal = claims.check(name, shared=False)
|
|
282
|
+
if refusal:
|
|
283
|
+
return claims.refusal_page(refusal, name)
|
|
284
|
+
|
|
285
|
+
if g.manager.remove_serial_port(sbc.id, PortType(port_type)):
|
|
286
|
+
flash(f"Removed {port_type} port", "success")
|
|
287
|
+
else:
|
|
288
|
+
flash(f"No {port_type} port to remove", "error")
|
|
289
|
+
|
|
290
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@views_bp.route("/sbc/<name>/network/set", methods=["POST"])
|
|
294
|
+
def sbc_network_set(name: str):
|
|
295
|
+
"""Handle network address form."""
|
|
296
|
+
from flask import request
|
|
297
|
+
|
|
298
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
299
|
+
if not sbc:
|
|
300
|
+
flash(f"SBC '{name}' not found", "error")
|
|
301
|
+
return redirect(url_for("views.index"))
|
|
302
|
+
|
|
303
|
+
address_type = request.form.get("address_type", "").strip()
|
|
304
|
+
ip_address = request.form.get("ip_address", "").strip()
|
|
305
|
+
mac_address = request.form.get("mac_address", "").strip() or None
|
|
306
|
+
hostname = request.form.get("hostname", "").strip() or None
|
|
307
|
+
|
|
308
|
+
if not address_type or not ip_address:
|
|
309
|
+
flash("Address type and IP are required", "error")
|
|
310
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
311
|
+
|
|
312
|
+
refusal = claims.check(name, shared=False)
|
|
313
|
+
if refusal:
|
|
314
|
+
return claims.refusal_page(refusal, name)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
g.manager.set_network_address(
|
|
318
|
+
sbc_id=sbc.id,
|
|
319
|
+
address_type=AddressType(address_type),
|
|
320
|
+
ip_address=ip_address,
|
|
321
|
+
mac_address=mac_address,
|
|
322
|
+
hostname=hostname,
|
|
323
|
+
)
|
|
324
|
+
flash(f"Set {address_type} address", "success")
|
|
325
|
+
except Exception as e:
|
|
326
|
+
flash(f"Error: {e}", "error")
|
|
327
|
+
|
|
328
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@views_bp.route("/sbc/<name>/network/remove/<address_type>", methods=["POST"])
|
|
332
|
+
def sbc_network_remove(name: str, address_type: str):
|
|
333
|
+
"""Remove network address."""
|
|
334
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
335
|
+
if not sbc:
|
|
336
|
+
flash(f"SBC '{name}' not found", "error")
|
|
337
|
+
return redirect(url_for("views.index"))
|
|
338
|
+
|
|
339
|
+
refusal = claims.check(name, shared=False)
|
|
340
|
+
if refusal:
|
|
341
|
+
return claims.refusal_page(refusal, name)
|
|
342
|
+
|
|
343
|
+
if g.manager.remove_network_address(sbc.id, AddressType(address_type)):
|
|
344
|
+
flash(f"Removed {address_type} address", "success")
|
|
345
|
+
else:
|
|
346
|
+
flash(f"No {address_type} address to remove", "error")
|
|
347
|
+
|
|
348
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@views_bp.route("/sbc/<name>/plug/assign", methods=["POST"])
|
|
352
|
+
def sbc_plug_assign(name: str):
|
|
353
|
+
"""Handle power plug assignment form."""
|
|
354
|
+
from flask import request
|
|
355
|
+
|
|
356
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
357
|
+
if not sbc:
|
|
358
|
+
flash(f"SBC '{name}' not found", "error")
|
|
359
|
+
return redirect(url_for("views.index"))
|
|
360
|
+
|
|
361
|
+
plug_type = request.form.get("plug_type", "").strip()
|
|
362
|
+
address = request.form.get("address", "").strip()
|
|
363
|
+
plug_index = request.form.get("plug_index", "1").strip()
|
|
364
|
+
|
|
365
|
+
if not plug_type or not address:
|
|
366
|
+
flash("Plug type and address are required", "error")
|
|
367
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
368
|
+
|
|
369
|
+
# Outlets are numbered from 1; index 0 addresses every outlet on a
|
|
370
|
+
# Tasmota device at once.
|
|
371
|
+
if claims.port_number(plug_index) is None:
|
|
372
|
+
flash("Plug index must be a whole number from 1 to 65535", "error")
|
|
373
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
374
|
+
try:
|
|
375
|
+
plug_type_enum = PlugType(plug_type)
|
|
376
|
+
except ValueError:
|
|
377
|
+
flash(f"Unknown plug type: {plug_type}", "error")
|
|
378
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
379
|
+
|
|
380
|
+
# The boards already on this outlet are checked too: otherwise a
|
|
381
|
+
# claimed board could be powered through this one.
|
|
382
|
+
refusal = claims.check(
|
|
383
|
+
name,
|
|
384
|
+
shared=False,
|
|
385
|
+
sharers=dict(plug=(plug_type_enum.value, address, int(plug_index))),
|
|
386
|
+
)
|
|
387
|
+
if refusal:
|
|
388
|
+
return claims.refusal_page(refusal, name)
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
g.manager.assign_power_plug(
|
|
392
|
+
sbc_id=sbc.id,
|
|
393
|
+
plug_type=plug_type_enum,
|
|
394
|
+
address=address,
|
|
395
|
+
plug_index=int(plug_index),
|
|
396
|
+
)
|
|
397
|
+
flash(f"Assigned {plug_type} plug", "success")
|
|
398
|
+
except Exception as e:
|
|
399
|
+
flash(f"Error: {e}", "error")
|
|
400
|
+
|
|
401
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@views_bp.route("/sbc/<name>/plug/remove", methods=["POST"])
|
|
405
|
+
def sbc_plug_remove(name: str):
|
|
406
|
+
"""Remove power plug assignment."""
|
|
407
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
408
|
+
if not sbc:
|
|
409
|
+
flash(f"SBC '{name}' not found", "error")
|
|
410
|
+
return redirect(url_for("views.index"))
|
|
411
|
+
|
|
412
|
+
refusal = claims.check(name, shared=False)
|
|
413
|
+
if refusal:
|
|
414
|
+
return claims.refusal_page(refusal, name)
|
|
415
|
+
|
|
416
|
+
if g.manager.remove_power_plug(sbc.id):
|
|
417
|
+
flash("Removed power plug", "success")
|
|
418
|
+
else:
|
|
419
|
+
flash("No power plug to remove", "error")
|
|
420
|
+
|
|
421
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@views_bp.route("/sbc/<name>/claim/force-release", methods=["POST"])
|
|
425
|
+
def sbc_force_release(name: str):
|
|
426
|
+
"""Force-release a claim from the web dashboard."""
|
|
427
|
+
from flask import request as flask_request
|
|
428
|
+
|
|
429
|
+
from labctl.core.models import ClaimNotFoundError
|
|
430
|
+
|
|
431
|
+
sbc = g.manager.get_sbc_by_name(name)
|
|
432
|
+
if not sbc:
|
|
433
|
+
flash(f"SBC '{name}' not found", "error")
|
|
434
|
+
return redirect(url_for("views.index"))
|
|
435
|
+
|
|
436
|
+
if not claims.web_actor().operator:
|
|
437
|
+
flash(
|
|
438
|
+
"Only operators may force-release a claim; your user has role 'agent'.",
|
|
439
|
+
"error",
|
|
440
|
+
)
|
|
441
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
442
|
+
|
|
443
|
+
reason = flask_request.form.get("reason", "force-released via web dashboard")
|
|
444
|
+
try:
|
|
445
|
+
g.manager.force_release_claim(name, reason, released_by=claims.web_user_name())
|
|
446
|
+
flash(f"Force-released claim on '{name}'", "success")
|
|
447
|
+
except ClaimNotFoundError:
|
|
448
|
+
flash(f"No active claim on '{name}'", "error")
|
|
449
|
+
|
|
450
|
+
return redirect(url_for("views.sbc_detail", name=name))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@views_bp.route("/settings")
|
|
454
|
+
def settings():
|
|
455
|
+
"""Settings page."""
|
|
456
|
+
from flask import current_app
|
|
457
|
+
|
|
458
|
+
config = current_app.config.get("LABCTL_CONFIG")
|
|
459
|
+
|
|
460
|
+
return render_template(
|
|
461
|
+
"settings.html",
|
|
462
|
+
config=config,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@views_bp.route("/activity")
|
|
467
|
+
def activity():
|
|
468
|
+
"""Live activity-stream page."""
|
|
469
|
+
broadcaster = current_app.config.get("ACTIVITY_BROADCASTER")
|
|
470
|
+
initial = broadcaster.recent() if broadcaster else []
|
|
471
|
+
return render_template(
|
|
472
|
+
"activity.html",
|
|
473
|
+
initial_events=[e.to_dict() for e in initial],
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@views_bp.route("/activity/stream")
|
|
478
|
+
def activity_stream():
|
|
479
|
+
"""Server-Sent Events feed of live activity events.
|
|
480
|
+
|
|
481
|
+
Sends a one-shot hydration of the last N events, then live events
|
|
482
|
+
as they arrive. Keeps alive with a comment-only frame every 15s.
|
|
483
|
+
"""
|
|
484
|
+
from labctl.web.activity_broadcaster import iter_sse_frames
|
|
485
|
+
|
|
486
|
+
broadcaster = current_app.config.get("ACTIVITY_BROADCASTER")
|
|
487
|
+
if broadcaster is None:
|
|
488
|
+
return Response("Activity broadcaster not initialized", status=503)
|
|
489
|
+
|
|
490
|
+
response = Response(
|
|
491
|
+
stream_with_context(iter_sse_frames(broadcaster)),
|
|
492
|
+
mimetype="text/event-stream",
|
|
493
|
+
)
|
|
494
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
495
|
+
response.headers["X-Accel-Buffering"] = "no" # disable nginx buffering
|
|
496
|
+
return response
|
labctl/web/websocket.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket bridge for serial console in web interface.
|
|
3
|
+
|
|
4
|
+
Bridges between browser WebSocket and serial proxy TCP connection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from flask import g, request
|
|
12
|
+
from flask_socketio import SocketIO, emit, join_room, leave_room
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# SocketIO instance - initialized in init_socketio()
|
|
17
|
+
socketio: Optional[SocketIO] = None
|
|
18
|
+
|
|
19
|
+
# Active console connections: {sid: {sbc_name, reader_task, writer}}
|
|
20
|
+
active_connections: dict = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init_socketio(app, **kwargs):
|
|
24
|
+
"""Initialize SocketIO with Flask app."""
|
|
25
|
+
global socketio
|
|
26
|
+
socketio = SocketIO(app, **kwargs)
|
|
27
|
+
register_handlers(socketio)
|
|
28
|
+
return socketio
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register_handlers(sio: SocketIO):
|
|
32
|
+
"""Register SocketIO event handlers."""
|
|
33
|
+
|
|
34
|
+
@sio.on("connect", namespace="/console")
|
|
35
|
+
def handle_connect():
|
|
36
|
+
"""Handle new WebSocket connection."""
|
|
37
|
+
logger.info(f"WebSocket client connected: {request.sid}")
|
|
38
|
+
|
|
39
|
+
@sio.on("disconnect", namespace="/console")
|
|
40
|
+
def handle_disconnect():
|
|
41
|
+
"""Handle WebSocket disconnection."""
|
|
42
|
+
sid = request.sid
|
|
43
|
+
logger.info(f"WebSocket client disconnected: {sid}")
|
|
44
|
+
|
|
45
|
+
# Clean up any active console connection
|
|
46
|
+
if sid in active_connections:
|
|
47
|
+
conn = active_connections.pop(sid)
|
|
48
|
+
if "reader_task" in conn and conn["reader_task"]:
|
|
49
|
+
conn["reader_task"].cancel()
|
|
50
|
+
if "writer" in conn and conn["writer"]:
|
|
51
|
+
try:
|
|
52
|
+
conn["writer"].close()
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@sio.on("open_console", namespace="/console")
|
|
57
|
+
def handle_open_console(data):
|
|
58
|
+
"""Open console connection to SBC.
|
|
59
|
+
|
|
60
|
+
Expected data: {"sbc_name": "sbc1"}
|
|
61
|
+
"""
|
|
62
|
+
sid = request.sid
|
|
63
|
+
sbc_name = data.get("sbc_name")
|
|
64
|
+
|
|
65
|
+
if not sbc_name:
|
|
66
|
+
emit("error", {"message": "sbc_name required"})
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Get SBC info
|
|
70
|
+
manager = g.manager
|
|
71
|
+
sbc = manager.get_sbc_by_name(sbc_name)
|
|
72
|
+
|
|
73
|
+
if not sbc:
|
|
74
|
+
emit("error", {"message": f"SBC '{sbc_name}' not found"})
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
console_port = sbc.console_port
|
|
78
|
+
if not console_port or not console_port.tcp_port:
|
|
79
|
+
emit("error", {"message": "No console port configured"})
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Join room for this SBC
|
|
83
|
+
join_room(sbc_name)
|
|
84
|
+
|
|
85
|
+
# Store connection info
|
|
86
|
+
active_connections[sid] = {
|
|
87
|
+
"sbc_name": sbc_name,
|
|
88
|
+
"tcp_port": console_port.tcp_port,
|
|
89
|
+
"reader_task": None,
|
|
90
|
+
"writer": None,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
emit(
|
|
94
|
+
"console_opened",
|
|
95
|
+
{
|
|
96
|
+
"sbc_name": sbc_name,
|
|
97
|
+
"message": f"Connected to {sbc_name} console",
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Start async reader in background
|
|
102
|
+
# Note: This is simplified - in production, use a proper async bridge
|
|
103
|
+
logger.info(f"Console opened for {sbc_name} by {sid}")
|
|
104
|
+
|
|
105
|
+
@sio.on("console_input", namespace="/console")
|
|
106
|
+
def handle_console_input(data):
|
|
107
|
+
"""Handle input from browser to send to console.
|
|
108
|
+
|
|
109
|
+
Expected data: {"text": "ls -la\n"}
|
|
110
|
+
"""
|
|
111
|
+
sid = request.sid
|
|
112
|
+
|
|
113
|
+
if sid not in active_connections:
|
|
114
|
+
emit("error", {"message": "Console not opened"})
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
conn = active_connections[sid]
|
|
118
|
+
text = data.get("text", "")
|
|
119
|
+
|
|
120
|
+
if not text:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Send to console via proxy
|
|
124
|
+
# Note: Actual implementation would write to TCP socket
|
|
125
|
+
logger.debug(f"Console input from {sid}: {repr(text)}")
|
|
126
|
+
|
|
127
|
+
# Echo back for now (in production, this comes from the serial device)
|
|
128
|
+
emit("console_output", {"text": text}, room=conn["sbc_name"])
|
|
129
|
+
|
|
130
|
+
@sio.on("close_console", namespace="/console")
|
|
131
|
+
def handle_close_console():
|
|
132
|
+
"""Close console connection."""
|
|
133
|
+
sid = request.sid
|
|
134
|
+
|
|
135
|
+
if sid in active_connections:
|
|
136
|
+
conn = active_connections.pop(sid)
|
|
137
|
+
leave_room(conn["sbc_name"])
|
|
138
|
+
emit("console_closed", {"message": "Console closed"})
|
|
139
|
+
logger.info(f"Console closed for {conn['sbc_name']} by {sid}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConsoleWebSocketBridge:
|
|
143
|
+
"""
|
|
144
|
+
Bridge between WebSocket and TCP proxy.
|
|
145
|
+
|
|
146
|
+
This class handles the async bridging between Flask-SocketIO
|
|
147
|
+
and the asyncio-based serial proxy.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, sio: SocketIO, sid: str, sbc_name: str, proxy_port: int):
|
|
151
|
+
self.sio = sio
|
|
152
|
+
self.sid = sid
|
|
153
|
+
self.sbc_name = sbc_name
|
|
154
|
+
self.proxy_port = proxy_port
|
|
155
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
156
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
157
|
+
self._running = False
|
|
158
|
+
|
|
159
|
+
async def connect(self) -> bool:
|
|
160
|
+
"""Connect to proxy TCP port."""
|
|
161
|
+
try:
|
|
162
|
+
self._reader, self._writer = await asyncio.open_connection(
|
|
163
|
+
"localhost", self.proxy_port
|
|
164
|
+
)
|
|
165
|
+
self._running = True
|
|
166
|
+
return True
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to connect to proxy: {e}")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
async def disconnect(self):
|
|
172
|
+
"""Disconnect from proxy."""
|
|
173
|
+
self._running = False
|
|
174
|
+
if self._writer:
|
|
175
|
+
self._writer.close()
|
|
176
|
+
try:
|
|
177
|
+
await self._writer.wait_closed()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
async def send_to_console(self, data: str):
|
|
182
|
+
"""Send data to console."""
|
|
183
|
+
if self._writer and self._running:
|
|
184
|
+
self._writer.write(data.encode())
|
|
185
|
+
await self._writer.drain()
|
|
186
|
+
|
|
187
|
+
async def read_loop(self):
|
|
188
|
+
"""Read from console and emit to WebSocket."""
|
|
189
|
+
while self._running and self._reader:
|
|
190
|
+
try:
|
|
191
|
+
data = await asyncio.wait_for(self._reader.read(4096), timeout=1.0)
|
|
192
|
+
if not data:
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
# Emit to WebSocket
|
|
196
|
+
self.sio.emit(
|
|
197
|
+
"console_output",
|
|
198
|
+
{"text": data.decode("utf-8", errors="replace")},
|
|
199
|
+
namespace="/console",
|
|
200
|
+
room=self.sbc_name,
|
|
201
|
+
)
|
|
202
|
+
except asyncio.TimeoutError:
|
|
203
|
+
continue
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error(f"Console read error: {e}")
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
self._running = False
|