ser2netweb 3.0.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 (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,470 @@
1
+ """The dashboard and the mapping endpoints behind it: the live table, the add/edit
2
+ form, save/delete/start/stop/restart/duplicate, and the per-mapping log and console
3
+ panels."""
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import time
11
+ import urllib.parse
12
+
13
+ from starlette.responses import PlainTextResponse, Response, StreamingResponse
14
+ from starlette.routing import Route
15
+
16
+ from ... import clock
17
+ from ...config import (ConfigError, MappingConfig, ModbusPoll, console_charset, console_encoding,
18
+ mapping_revision)
19
+ from ...engine import netinfo, portlist
20
+ from ...i18n import _, ngettext
21
+ from ...notify import no_data_for
22
+ from . import ops
23
+ from .common import (
24
+ TRIGGER, Ctx, LiveTable, action_done, bytesize, console_interactive, notice, read_form,
25
+ reason, sees_to_device, start_failed,
26
+ )
27
+ from .forms import _form_error, _parse_modbus_points, form_context
28
+
29
+
30
+ def _port_taken(error: str) -> bool:
31
+ """Does a mapping's start error say that its port is in use (EADDRINUSE: errno 98 on
32
+ Linux, 48 on macOS, 10048 on Windows)?"""
33
+ text = error.lower()
34
+ return "address already in use" in text or any(f"[errno {n}]" in text
35
+ for n in (98, 48, 10048))
36
+
37
+
38
+ def _start_failure(error: str, retrying: bool) -> tuple[str, str]:
39
+ """Why a mapping that was just saved or started did not start, for its notice:
40
+ reason() (what it means in the page's language, the error for the tooltip), plus what
41
+ to do when another program holds its port, and, while the supervisor tries the start
42
+ again (a listener that could not bind), that it starts once it can listen there."""
43
+ why, raw = reason(error)
44
+ if _port_taken(error):
45
+ why += " " + _("Another program — another ser2netweb instance? — listens on that port: "
46
+ "give the mapping a port of its own.")
47
+ if retrying:
48
+ why += " " + _("It is started as soon as it can listen there (its row shows when it "
49
+ "is tried again).")
50
+ return why, raw
51
+
52
+
53
+ # what _mappings_body.html shows of a mapping's status (st.*), apart from the counters and
54
+ # the clients (tests/test_live_table.py checks the template uses no other field)
55
+ _SHOWN = ("state", "device", "last_error", "client_count", "dropped_clients", "bytes_dropped",
56
+ # a connect-out link's state, a serial port's hint and warnings, the bind retry
57
+ "link", "link_error", "hint", "warnings", "retrying", "next_retry_s",
58
+ "trace_error", # a trace that is not written now
59
+ # a Modbus gateway's polled units, its MQTT link (only what the row shows of them:
60
+ # _shown_status)
61
+ "units", "mqtt")
62
+
63
+
64
+ def _shown_status(st, key):
65
+ """What the row shows of a status field: of a gateway's units, which ones stopped
66
+ answering (their read counts change at every poll); of the MQTT link, its state, broker
67
+ and error (not its count of dropped messages)."""
68
+ value = st.get(key)
69
+ if key == "units":
70
+ return tuple(u.get("unit") for u in value or () if u.get("state") == "offline")
71
+ if key == "mqtt":
72
+ return tuple((value or {}).get(k) for k in ("state", "broker", "error"))
73
+ return value
74
+
75
+
76
+ def _steady(st):
77
+ """What the table shows of a mapping's status, minus what changes all the time: the
78
+ In / Out counters (patched on their own) and the clients' connection times (the
79
+ browser counts them on from connected_at). Not what it doesn't show (the reconnect
80
+ count of a mapping whose device is missing goes up every few seconds)."""
81
+ if not st:
82
+ return None
83
+ return (tuple(_shown_status(st, k) for k in _SHOWN),
84
+ [(c.get("peer"), c.get("priority"), c.get("connected_at")) for c in st.get("clients") or ()])
85
+
86
+
87
+ def _row_shape(m, st):
88
+ """What a mapping's row shows, for LiveTable: its settings, its status (_steady) and how
89
+ long its device has been silent past its no-data alarm (notify.no_data_for: 0 while not;
90
+ the seconds change at every look while it is)."""
91
+ return repr(m), _steady(st), no_data_for(m, st)
92
+
93
+
94
+ def _in_out(st) -> str:
95
+ """The text of a row's In / Out cell, as _mappings_body.html renders it."""
96
+ return f"{bytesize(st.get('bytes_in', 0) if st else 0)} / {bytesize(st.get('bytes_out', 0) if st else 0)}"
97
+
98
+
99
+ # what a download reads at a time (as Starlette's FileResponse)
100
+ DOWNLOAD_CHUNK = 64 * 1024
101
+
102
+
103
+ def snapshot_response(fh, filename: str, media_type: str, head: bool = False,
104
+ chunk_size: int = DOWNLOAD_CHUNK) -> StreamingResponse:
105
+ """A download of the open binary file `fh` as it is now: exactly the size it has
106
+ (taken on the open handle, so a rotation can't put another file in its place
107
+ meanwhile) with that Content-Length, read off the event loop. What the trace writer
108
+ adds meanwhile is not sent: a FileResponse would send it past the Content-Length it
109
+ declared, and the server would break the download off. A file cut meanwhile (by
110
+ another program) ends the download early. Closes `fh`; `head`: headers only."""
111
+ size = os.fstat(fh.fileno()).st_size
112
+
113
+ async def body():
114
+ try:
115
+ left = 0 if head else size
116
+ while left > 0:
117
+ data = await asyncio.to_thread(fh.read, min(chunk_size, left))
118
+ if not data:
119
+ break
120
+ left -= len(data)
121
+ yield data
122
+ finally:
123
+ fh.close()
124
+
125
+ quoted = urllib.parse.quote(filename) # a trace name may be in any script
126
+ disposition = (f'attachment; filename="{filename}"' if quoted == filename
127
+ else f"attachment; filename*=utf-8''{quoted}")
128
+ return StreamingResponse(body(), media_type=media_type, headers={
129
+ "Content-Length": str(size), "Content-Disposition": disposition})
130
+
131
+
132
+ def _bulk_refused(refused: list, total: int) -> str:
133
+ """Why some mappings of a bulk start / restart were not changed (the others were)."""
134
+ reasons = list(dict.fromkeys(str(r.error) for r in refused)) # once each, in order
135
+ return ngettext("{0} of {1} selected mapping not changed: {2}",
136
+ "{0} of {1} selected mappings not changed: {2}", total).format(
137
+ len(refused), total, " ".join(reasons[:3]) + (" …" if len(reasons) > 3 else ""))
138
+
139
+
140
+ def routes(ctx: Ctx) -> list:
141
+ state, render = ctx.state, ctx.render
142
+ current_user, require_role, can_edit = ctx.current_user, ctx.require_role, ctx.can_edit
143
+
144
+ async def dashboard(request):
145
+ return render(
146
+ request, "dashboard.html",
147
+ mappings=state.config.mappings,
148
+ statuses=state.supervisor.all_status(),
149
+ admin=state.config.admin_ui,
150
+ cluster_on=state.config.cluster.active,
151
+ me=current_user(request), can_edit=can_edit(request),
152
+ )
153
+
154
+ async def mapping_duplicate(request):
155
+ if deny := require_role(request, "operator"):
156
+ return deny
157
+ mid = request.path_params["mid"]
158
+ dup = None
159
+ try:
160
+ async with state.edit_config():
161
+ m = state.config.get_mapping(mid)
162
+ if m: # disabled, on a free port, with an MQTT identity of its own
163
+ dup = ops.copy_of(state.config, m)
164
+ state.config.mappings.append(dup)
165
+ except ConfigError as e: # not saved, so not copied (config.json unwritable too)
166
+ return PlainTextResponse(_("Duplicate failed: {0}").format(e), status_code=400)
167
+ if dup is None:
168
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
169
+ ctx.audit(request, "mapping_duplicate", dup.name)
170
+ return Response("", headers={**TRIGGER, **notice(
171
+ _("Copied as “{0}” (disabled: it uses the same serial port).").format(dup.name))})
172
+
173
+ table = LiveTable(ctx, "_mappings_body.html")
174
+
175
+ async def api_status(request):
176
+ # polled every 2 s: 204 while unchanged, only the In / Out counters when only they
177
+ # changed, else the table rendered off the event loop (see LiveTable). On a
178
+ # snapshot: an edit replaces a mapping object, a delete removes it from the list.
179
+ mappings, statuses = list(state.config.mappings), state.supervisor.all_status()
180
+ edit = can_edit(request)
181
+ # Trace is offered while tracing is on, and after that as long as files are left
182
+ traced = await asyncio.to_thread(state.traced_mapping_ids) if edit else set()
183
+
184
+ def snapshot():
185
+ return ((edit, sorted(traced), [_row_shape(m, statuses.get(m.id)) for m in mappings]),
186
+ {f"m-{m.id}-bytes": _in_out(statuses.get(m.id)) for m in mappings})
187
+ # the server's clock: the browser counts the clients' connection times on from it
188
+ return await table.answer(request, snapshot, headers={"X-Server-Time": f"{time.time():.3f}"},
189
+ mappings=mappings, statuses=statuses, can_edit=edit,
190
+ traced=traced)
191
+
192
+ async def mapping_bulk(request):
193
+ """Start / stop / restart several mappings at once (the dashboard's selection)."""
194
+ if deny := require_role(request, "operator"):
195
+ return deny
196
+ form = await read_form(request)
197
+ action = form.get("action", "")
198
+ if action not in ("start", "stop", "restart"):
199
+ return PlainTextResponse(_("Unknown action"), status_code=400)
200
+ ids = [i for i in form.getlist("ids") if i]
201
+ if not ids or len(ids) > 500:
202
+ return PlainTextResponse(_("Select one or more mappings."), status_code=400)
203
+ results = await ops.apply_actions(state, ids, action) # one save for all
204
+ done, refused, failed = [], [], []
205
+ for mid, res in zip(dict.fromkeys(ids), results, strict=True):
206
+ if res.error is not None: # (this one can't be started; the others go on)
207
+ refused.append(res)
208
+ state.log(f"bulk {action}: {res.error}")
209
+ elif res.found:
210
+ done.append(res.name)
211
+ ctx.audit(request, f"mapping_{action}", res.name)
212
+ if action != "stop" and start_failed(state, mid):
213
+ failed.append(res.name)
214
+ state.log(f"bulk {action}: {len(done)} mapping(s)"
215
+ + (f", {len(refused)} refused" if refused else ""))
216
+ total = len(results)
217
+ if refused: # not changed, with the reasons; the others are done
218
+ return PlainTextResponse(_bulk_refused(refused, total), status_code=400,
219
+ headers=TRIGGER)
220
+ n = len(done) - len(failed)
221
+ text = (ngettext("Started {0} of {1} selected mapping.", "Started {0} of {1} selected mappings.",
222
+ total) if action == "start" else
223
+ ngettext("Stopped {0} of {1} selected mapping.", "Stopped {0} of {1} selected mappings.",
224
+ total) if action == "stop" else
225
+ ngettext("Restarted {0} of {1} selected mapping.",
226
+ "Restarted {0} of {1} selected mappings.", total)).format(n, total)
227
+ if failed:
228
+ more = len(failed) - 5
229
+ text += " " + _("Could not start: {0}.").format(
230
+ ", ".join(failed[:5]) + (" " + _("(+{0} more)").format(more) if more > 0 else ""))
231
+ if warning := next((r.warning for r in results if r.warning), ""):
232
+ text += " " + warning # stopped, but not saved
233
+ return Response("", headers={**TRIGGER, **notice(text, "warn" if failed or warning
234
+ else "ok")})
235
+
236
+ # ---------------- mapping form ----------------
237
+ # The form is for editing (operator+), so viewers get 403 — the UI never offers it to
238
+ # them. It shows every setting but the MQTT password, which it only says is stored.
239
+ async def mapping_form_new(request):
240
+ if deny := require_role(request, "operator"):
241
+ return deny
242
+ return render(request, "_mapping_form.html", **form_context(
243
+ MappingConfig(name=""), await state.ports.current(), netinfo.list_ip_candidates(),
244
+ is_new=True))
245
+
246
+ async def mapping_form_edit(request):
247
+ if deny := require_role(request, "operator"):
248
+ return deny
249
+ m = state.config.get_mapping(request.path_params["mid"])
250
+ if not m:
251
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
252
+ ports = await state.ports.current()
253
+ return render(request, "_mapping_form.html", **form_context(
254
+ m, ports, netinfo.list_ip_candidates(), is_new=False,
255
+ rev=mapping_revision(state.config.secret_key, m),
256
+ links=portlist.links(portlist.visible(ports), (m.serial.port, m.serial_b.port)),
257
+ mqtt_stored=m.mqtt.password))
258
+
259
+ async def mapping_modbus_read(request):
260
+ """The mapping form's test read: the points in the posted points JSON, as typed (not
261
+ saved), read once now through the mapping's running gateway (its turn on the bus
262
+ with the poll and the masters); nothing is published. A fragment for the form."""
263
+ if deny := require_role(request, "operator"):
264
+ return deny
265
+ m = state.config.get_mapping(request.path_params["mid"])
266
+ if not m:
267
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
268
+ form = await read_form(request)
269
+ try:
270
+ poll = ModbusPoll.from_dict(
271
+ {"points": _parse_modbus_points(form.get("modbus_points", ""), strict=True)})
272
+ poll.validate()
273
+ poll.validate_input()
274
+ except (ValueError, ConfigError) as e:
275
+ return render(request, "_modbus_read.html", rows=[], error=str(e))
276
+ runner = state.supervisor.get_runner(m.id)
277
+ read_now = getattr(runner, "read_now", None) # (a Modbus gateway's runner)
278
+ if runner is None or read_now is None or runner.status.state in ("stopped", "error"):
279
+ return render(request, "_modbus_read.html", rows=[], error=_(
280
+ "Start this Modbus gateway to read the points: it reads them over its serial "
281
+ "port."))
282
+ if not poll.points:
283
+ return render(request, "_modbus_read.html", rows=[], error=_("There are no points "
284
+ "to read."))
285
+ rows = await read_now(poll.points)
286
+ return render(request, "_modbus_read.html", error=None, more=len(poll.points) > len(rows),
287
+ rows=[{"name": p.name, "unit": p.unit, "value": v, "error": e}
288
+ for p, v, e in rows])
289
+
290
+ async def mapping_log(request):
291
+ mid = request.path_params["mid"]
292
+ m = state.config.get_mapping(mid)
293
+ if not m:
294
+ # deleted meanwhile: 286 makes htmx stop the panel's 5-second refresh, and the
295
+ # panel says why (a 404 would be polled - and reported - every 5 seconds)
296
+ return render(request, "_mapping_log.html", status_code=286, gone=True)
297
+ lines = await asyncio.to_thread(state.read_mapping_log, mid, 1000)
298
+ # refreshed every 5 s: 204 while the browser shows these lines already (the hash
299
+ # goes into the panel, which each refresh replaces)
300
+ digest = hashlib.sha256(json.dumps([getattr(request.state, "lang", ""), m.name, lines])
301
+ .encode()).hexdigest()[:16]
302
+ if request.headers.get("x-content-hash") == digest:
303
+ return Response(status_code=204)
304
+ return await ctx.arender(request, "_mapping_log.html", mid=mid, mapping_name=m.name,
305
+ lines=lines, content_hash=digest)
306
+
307
+ def _trace_files(mid):
308
+ out = []
309
+ for path in state.trace_files(mid):
310
+ try:
311
+ st = os.stat(path)
312
+ except OSError:
313
+ continue
314
+ out.append({"name": os.path.basename(path), "path": path,
315
+ "size_text": bytesize(st.st_size), "time": clock.stamp(st.st_mtime)})
316
+ return out
317
+
318
+ async def _traces_panel(request, m, ok="", error=""):
319
+ files = await asyncio.to_thread(_trace_files, m.id)
320
+ o = m.options
321
+ st = state.supervisor.status(m.id) or {}
322
+ return render(request, "_mapping_traces.html", status_code=400 if error else 200,
323
+ mid=m.id, mapping_name=m.name, files=files,
324
+ tracing=bool(o.trace_both), current=f"{m.id}-{o.trace_both}",
325
+ max_mb="%g" % o.trace_max_mb, keep=o.trace_keep, hexdump=o.trace_hexdump,
326
+ trace_error=st.get("trace_error", ""), ok=ok, error=error,
327
+ trace_days=state.config.trace_max_age_days)
328
+
329
+ # Traces record both directions, what clients type to the device included (see
330
+ # common.sees_to_device): listing, downloading and deleting them needs operator+.
331
+ async def mapping_traces(request):
332
+ if deny := require_role(request, "operator"):
333
+ return deny
334
+ m = state.config.get_mapping(request.path_params["mid"])
335
+ if not m:
336
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
337
+ return await _traces_panel(request, m)
338
+
339
+ async def mapping_traces_delete(request):
340
+ """Delete one of a mapping's trace files (a name of its own list) or, without a
341
+ name, all of them; audited. Answers the trace panel with what was done (400 when
342
+ something was not deleted: htmx shows it too)."""
343
+ if deny := require_role(request, "operator"):
344
+ return deny
345
+ m = state.config.get_mapping(request.path_params["mid"])
346
+ if not m:
347
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
348
+ name = request.path_params.get("name")
349
+ deleted, failed = await state.delete_trace_files(m.id, None if name is None else [name])
350
+ if deleted:
351
+ detail = f"{m.name}/{deleted[0]}" if name is not None else \
352
+ f"{m.name}: {len(deleted)} file(s): {', '.join(deleted)}"
353
+ state.log(f"trace file(s) deleted: {detail}")
354
+ ctx.audit(request, "trace_delete", detail)
355
+ ok = ngettext("Deleted {0} trace file.", "Deleted {0} trace files.",
356
+ len(deleted)).format(len(deleted)) if deleted else ""
357
+ if ok and m.options.trace_both and f"{m.id}-{m.options.trace_both}" in deleted:
358
+ ok += " " + _("Tracing is still on: new traffic begins a new file.")
359
+ error = "; ".join(_("Could not delete {0}: {1}").format(n, why) for n, why in failed)
360
+ if not deleted and not failed:
361
+ ok = _("No trace files to delete.")
362
+ return await _traces_panel(request, m, ok=ok, error=error)
363
+
364
+ async def mapping_trace_file(request):
365
+ """Download one of a mapping's trace files — only a name from its own list."""
366
+ if deny := require_role(request, "operator"):
367
+ return deny
368
+ mid, name = request.path_params["mid"], request.path_params["name"]
369
+ m = state.config.get_mapping(mid)
370
+ f = next((f for f in await asyncio.to_thread(_trace_files, mid) if f["name"] == name), None)
371
+ if not m or f is None:
372
+ return PlainTextResponse(_("Trace file not found"), status_code=404)
373
+ try: # the file it is now, as it is now: the trace may go on writing it
374
+ fh = await asyncio.to_thread(open, f["path"], "rb")
375
+ except OSError: # rotated or deleted meanwhile
376
+ return PlainTextResponse(_("Trace file not found"), status_code=404)
377
+ ctx.audit(request, "trace_download", f"{m.name}/{name}")
378
+ return snapshot_response(fh, name, "text/plain; charset=utf-8",
379
+ head=request.method == "HEAD")
380
+
381
+ async def console_view(request):
382
+ mid = request.path_params["mid"]
383
+ m = state.config.get_mapping(mid)
384
+ if not m:
385
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
386
+ me = current_user(request) # what the console WebSockets will allow this user
387
+ return render(request, "_console.html", mid=mid, mapping_name=m.name,
388
+ interactive=console_interactive(me, m), to_device=sees_to_device(me, m),
389
+ bridge=m.kind == "serialbridge",
390
+ # a Modbus gateway's text view is binary noise: its frames in hex
391
+ hex_first=m.kind == "net" and m.network.protocol == "modbus",
392
+ encoding=console_encoding(m.options),
393
+ charset=console_charset(console_encoding(m.options)))
394
+
395
+ async def mapping_save(request):
396
+ if deny := require_role(request, "operator"):
397
+ return deny
398
+ form = await read_form(request)
399
+ ok, msg, saved = await ops.save_mapping_from_form(state, form)
400
+ if not ok or saved is None:
401
+ return _form_error(render, request, state, form, msg)
402
+ state.log(f"mapping saved: {msg}")
403
+ ctx.audit(request, "mapping_save", msg)
404
+ # the focus then: the mapping's Edit button (a new one's, once the table shows it)
405
+ focus = f"m-{saved.id}-edit"
406
+ if saved.start_error: # saved, but it didn't start: say why, and what to do
407
+ why, raw = _start_failure(saved.start_error, saved.retrying)
408
+ note = notice(_("“{0}” saved, but it could not start: {1}").format(msg, why),
409
+ "warn", focus, raw)
410
+ else:
411
+ note = notice(_("“{0}” saved.").format(msg), focus=focus)
412
+ return Response("", headers={**TRIGGER, **note})
413
+
414
+ async def mapping_delete(request):
415
+ if deny := require_role(request, "operator"):
416
+ return deny
417
+ try:
418
+ removed = await ops.delete_mapping(state, request.path_params["mid"])
419
+ except ConfigError as e: # not deleted
420
+ return PlainTextResponse(_("Not deleted: {0}").format(e), status_code=400)
421
+ if removed is None:
422
+ return Response("", headers=TRIGGER)
423
+ state.log(f"mapping deleted: {removed.name}")
424
+ ctx.audit(request, "mapping_delete", removed.name)
425
+ return Response("", headers={**TRIGGER, **notice(_("“{0}” deleted.").format(removed.name))})
426
+
427
+ async def mapping_action(request):
428
+ if deny := require_role(request, "operator"):
429
+ return deny
430
+ mid = request.path_params["mid"]
431
+ action = request.path_params["action"]
432
+ if action not in ("start", "stop", "restart"):
433
+ return PlainTextResponse(_("Unknown action"), status_code=400)
434
+ res = await ops.apply_action(state, mid, action)
435
+ if not res.found: # (ops answers in English: the REST API and the cluster peers use it too)
436
+ return PlainTextResponse(_("Mapping not found"), status_code=404)
437
+ if res.error is not None: # nothing changed (a serial port in use, config.json, ...)
438
+ return PlainTextResponse(str(res.error), status_code=400)
439
+ state.log(f"mapping {action}: {res.name}")
440
+ ctx.audit(request, f"mapping_{action}", res.name)
441
+ if res.warning: # stopped, but config.json could not keep it
442
+ note = notice(res.warning, "warn")
443
+ elif error := (start_failed(state, mid) if action != "stop" else ""):
444
+ # as for a save: the reason, and the hints for a taken port and the bind retry
445
+ retrying = bool((state.supervisor.status(mid) or {}).get("retrying"))
446
+ why, raw = _start_failure(error, retrying)
447
+ note = notice(_("“{0}” could not start: {1}").format(res.name, why), "warn", detail=raw)
448
+ else:
449
+ note = notice(action_done(action, res.name))
450
+ return Response("", headers={**TRIGGER, **note})
451
+
452
+ return [
453
+ Route("/", dashboard),
454
+ # before /api/mappings/{mid}/{action}: "duplicate" is not a start/stop action
455
+ Route("/api/mappings/{mid}/duplicate", mapping_duplicate, methods=["POST"]),
456
+ Route("/api/status", api_status),
457
+ Route("/api/mappings/bulk", mapping_bulk, methods=["POST"]),
458
+ Route("/api/mappings/form", mapping_form_new),
459
+ Route("/api/mappings/{mid}/form", mapping_form_edit),
460
+ Route("/api/mappings/{mid}/modbus-read", mapping_modbus_read, methods=["POST"]),
461
+ Route("/api/mappings/{mid}/log", mapping_log),
462
+ Route("/api/mappings/{mid}/console-view", console_view),
463
+ Route("/api/mappings/{mid}/traces", mapping_traces),
464
+ Route("/api/mappings/{mid}/traces", mapping_traces_delete, methods=["DELETE"]),
465
+ Route("/api/mappings/{mid}/traces/{name}", mapping_trace_file),
466
+ Route("/api/mappings/{mid}/traces/{name}", mapping_traces_delete, methods=["DELETE"]),
467
+ Route("/api/mappings/save", mapping_save, methods=["POST"]),
468
+ Route("/api/mappings/{mid}", mapping_delete, methods=["DELETE"]),
469
+ Route("/api/mappings/{mid}/{action}", mapping_action, methods=["POST"]),
470
+ ]