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.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- 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
|
+
]
|