better-rtplot 0.4.2__tar.gz → 0.4.4__tar.gz
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.
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/PKG-INFO +1 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/pyproject.toml +1 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/server_browser.py +178 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/server_browser_gui.py +160 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/static/index.html +37 -2
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/LICENSE +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/README.md +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/client.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/server.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.4}/rtplot/static/uPlot.min.css +0 -0
|
@@ -160,12 +160,26 @@ parser.add_argument(
|
|
|
160
160
|
default=1000,
|
|
161
161
|
)
|
|
162
162
|
|
|
163
|
+
parser.add_argument(
|
|
164
|
+
"--password",
|
|
165
|
+
help=(
|
|
166
|
+
"Gate the whole UI behind a shared password (HTTP Basic). Any"
|
|
167
|
+
" username is accepted. Can also be set via RTPLOT_PASSWORD env"
|
|
168
|
+
" var; the flag wins if both are given. Leave unset to serve"
|
|
169
|
+
" without auth."
|
|
170
|
+
),
|
|
171
|
+
action="store",
|
|
172
|
+
type=str,
|
|
173
|
+
default=None,
|
|
174
|
+
)
|
|
175
|
+
|
|
163
176
|
args = parser.parse_args()
|
|
164
177
|
|
|
165
178
|
NEW_SUBPLOT_IN_ROW = args.column
|
|
166
179
|
DEBUG_TEXT_ENABLED = args.debug
|
|
167
180
|
SKIP_PLOT_DATAPOINTS = args.skip
|
|
168
181
|
ADAPT_SKIP_PLOT_DATAPOINTS = args.adaptable
|
|
182
|
+
AUTH_PASSWORD = args.password if args.password is not None else os.environ.get("RTPLOT_PASSWORD")
|
|
169
183
|
|
|
170
184
|
|
|
171
185
|
###############################
|
|
@@ -942,6 +956,29 @@ async def rename_tab(tab_id: str, new_name: str):
|
|
|
942
956
|
await broadcast_tab(tab_id)
|
|
943
957
|
|
|
944
958
|
|
|
959
|
+
async def reconnect_tab(tab_id: str):
|
|
960
|
+
"""Close and reopen ``tab_id``'s sockets, restart its receiver task.
|
|
961
|
+
|
|
962
|
+
The tab's buffer and plot config are preserved — if the sender on
|
|
963
|
+
the other side is still running the same stream we avoid a visible
|
|
964
|
+
flash to empty. A new config from the sender will replace the state
|
|
965
|
+
via the normal RECEIVED_PLOT_UPDATE path.
|
|
966
|
+
"""
|
|
967
|
+
t = tabs.get(tab_id)
|
|
968
|
+
if t is None:
|
|
969
|
+
return
|
|
970
|
+
await _cancel_task(t.receiver_task)
|
|
971
|
+
# _open_tab_sockets closes first, then reopens. Status gets set to
|
|
972
|
+
# "idle" on success or "error" + a message on bind/connect failure.
|
|
973
|
+
_open_tab_sockets(t)
|
|
974
|
+
# Reset the data-rate estimate so a stale Hz doesn't mislead the
|
|
975
|
+
# resources panel until new samples arrive.
|
|
976
|
+
t.data_rate_hz = 0.0
|
|
977
|
+
t._last_rx_ts = 0.0
|
|
978
|
+
t.receiver_task = asyncio.create_task(zmq_receiver(t))
|
|
979
|
+
await broadcast_tab(tab_id)
|
|
980
|
+
|
|
981
|
+
|
|
945
982
|
###############################
|
|
946
983
|
# HTTP / WebSocket handlers #
|
|
947
984
|
###############################
|
|
@@ -1035,6 +1072,11 @@ async def handle_ws(request):
|
|
|
1035
1072
|
if tid and tid != BIND_ME_ID:
|
|
1036
1073
|
await delete_tab(tid)
|
|
1037
1074
|
|
|
1075
|
+
elif ptype == "tab_reconnect":
|
|
1076
|
+
tid = payload.get("id")
|
|
1077
|
+
if tid:
|
|
1078
|
+
await reconnect_tab(tid)
|
|
1079
|
+
|
|
1038
1080
|
elif ptype == "control_button":
|
|
1039
1081
|
btn_id = payload.get("id")
|
|
1040
1082
|
tid = ws_tab.get(ws, BIND_ME_ID)
|
|
@@ -1310,9 +1352,140 @@ async def on_cleanup(app):
|
|
|
1310
1352
|
zmq_ctx.term()
|
|
1311
1353
|
|
|
1312
1354
|
|
|
1355
|
+
###############################
|
|
1356
|
+
# Cookie-based password auth #
|
|
1357
|
+
###############################
|
|
1358
|
+
|
|
1359
|
+
# A single shared password unlocks the UI for every browser. We deliberately
|
|
1360
|
+
# don't model users: the browser's native Basic-Auth popup forces a username
|
|
1361
|
+
# field, which was confusing — one password is enough for LAN deployments.
|
|
1362
|
+
# On successful POST /login, the server mints a random session token, stashes
|
|
1363
|
+
# it in an in-memory set, and drops it as an HttpOnly cookie. All other
|
|
1364
|
+
# routes (including the WebSocket upgrade) require that cookie.
|
|
1365
|
+
import hmac as _hmac
|
|
1366
|
+
import secrets as _secrets
|
|
1367
|
+
|
|
1368
|
+
SESSION_COOKIE = "rtplot_session"
|
|
1369
|
+
_AUTH_REQUIRED = AUTH_PASSWORD is not None
|
|
1370
|
+
_AUTH_EXPECTED = AUTH_PASSWORD.encode("utf-8") if AUTH_PASSWORD else None
|
|
1371
|
+
_valid_tokens: set = set()
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
_LOGIN_HTML = """<!doctype html>
|
|
1375
|
+
<html lang=\"en\">
|
|
1376
|
+
<head>
|
|
1377
|
+
<meta charset=\"utf-8\" />
|
|
1378
|
+
<title>rtplot \u2014 sign in</title>
|
|
1379
|
+
<style>
|
|
1380
|
+
html, body { margin: 0; padding: 0; min-height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #eef1f5; color: #222; }
|
|
1381
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1382
|
+
form { background: #fff; border: 1px solid #d2d8e0; border-radius: 8px; padding: 28px 32px; box-shadow: 0 6px 24px rgba(30,40,60,0.08); width: 320px; }
|
|
1383
|
+
h1 { margin: 0 0 6px 0; font-size: 20px; color: #1d3566; }
|
|
1384
|
+
p { margin: 0 0 18px 0; color: #666; font-size: 13px; }
|
|
1385
|
+
label { display: block; font-size: 12px; color: #555; margin-bottom: 6px; }
|
|
1386
|
+
input[type=password] { width: 100%; box-sizing: border-box; padding: 9px 10px; font-size: 14px; border: 1px solid #bfc7d2; border-radius: 4px; font-family: inherit; }
|
|
1387
|
+
input[type=password]:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42,93,176,0.15); }
|
|
1388
|
+
button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; border: none; border-radius: 4px; background: #2a5db0; color: #fff; cursor: pointer; font-weight: 600; }
|
|
1389
|
+
button:hover { background: #244f95; }
|
|
1390
|
+
.err { background: #fce8e8; color: #a11; border: 1px solid #f0c2c2; padding: 8px 10px; border-radius: 4px; font-size: 13px; margin-bottom: 14px; }
|
|
1391
|
+
</style>
|
|
1392
|
+
</head>
|
|
1393
|
+
<body>
|
|
1394
|
+
<form method=\"post\" action=\"/login\" autocomplete=\"on\">
|
|
1395
|
+
<h1>rtplot</h1>
|
|
1396
|
+
<p>Enter the shared password to continue.</p>
|
|
1397
|
+
__ERROR_BLOCK__
|
|
1398
|
+
<label for=\"pw\">Password</label>
|
|
1399
|
+
<input id=\"pw\" type=\"password\" name=\"password\" autofocus required />
|
|
1400
|
+
<button type=\"submit\">Sign in</button>
|
|
1401
|
+
</form>
|
|
1402
|
+
</body>
|
|
1403
|
+
</html>
|
|
1404
|
+
"""
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def _render_login_page(error: bool = False) -> str:
|
|
1408
|
+
err = '<div class="err">Wrong password. Try again.</div>' if error else ""
|
|
1409
|
+
return _LOGIN_HTML.replace("__ERROR_BLOCK__", err)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _has_valid_session(request) -> bool:
|
|
1413
|
+
tok = request.cookies.get(SESSION_COOKIE)
|
|
1414
|
+
return bool(tok) and tok in _valid_tokens
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
async def handle_login(request):
|
|
1418
|
+
# GET /login -> render form
|
|
1419
|
+
# POST /login -> check password, set cookie on success
|
|
1420
|
+
if request.method == "GET":
|
|
1421
|
+
if _has_valid_session(request):
|
|
1422
|
+
raise web.HTTPFound("/")
|
|
1423
|
+
return web.Response(
|
|
1424
|
+
text=_render_login_page(),
|
|
1425
|
+
content_type="text/html",
|
|
1426
|
+
headers={"Cache-Control": "no-store"},
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
if not _AUTH_REQUIRED:
|
|
1430
|
+
raise web.HTTPFound("/")
|
|
1431
|
+
data = await request.post()
|
|
1432
|
+
supplied = (data.get("password") or "").encode("utf-8")
|
|
1433
|
+
if _hmac.compare_digest(supplied, _AUTH_EXPECTED or b""):
|
|
1434
|
+
token = _secrets.token_urlsafe(32)
|
|
1435
|
+
_valid_tokens.add(token)
|
|
1436
|
+
resp = web.HTTPFound("/")
|
|
1437
|
+
resp.set_cookie(
|
|
1438
|
+
SESSION_COOKIE,
|
|
1439
|
+
token,
|
|
1440
|
+
httponly=True,
|
|
1441
|
+
samesite="Lax",
|
|
1442
|
+
max_age=30 * 24 * 3600, # 30 days
|
|
1443
|
+
path="/",
|
|
1444
|
+
)
|
|
1445
|
+
raise resp
|
|
1446
|
+
return web.Response(
|
|
1447
|
+
text=_render_login_page(error=True),
|
|
1448
|
+
content_type="text/html",
|
|
1449
|
+
status=401,
|
|
1450
|
+
headers={"Cache-Control": "no-store"},
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
async def handle_logout(request):
|
|
1455
|
+
tok = request.cookies.get(SESSION_COOKIE)
|
|
1456
|
+
if tok:
|
|
1457
|
+
_valid_tokens.discard(tok)
|
|
1458
|
+
resp = web.HTTPFound("/login")
|
|
1459
|
+
resp.del_cookie(SESSION_COOKIE, path="/")
|
|
1460
|
+
raise resp
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
@web.middleware
|
|
1464
|
+
async def session_auth_middleware(request, handler):
|
|
1465
|
+
if not _AUTH_REQUIRED:
|
|
1466
|
+
return await handler(request)
|
|
1467
|
+
path = request.path
|
|
1468
|
+
# Login page and its form post must stay open; the uPlot assets that
|
|
1469
|
+
# the main page pulls in are gated too, which is consistent with the
|
|
1470
|
+
# rest of the UI.
|
|
1471
|
+
if path == "/login" or path == "/logout":
|
|
1472
|
+
return await handler(request)
|
|
1473
|
+
if _has_valid_session(request):
|
|
1474
|
+
return await handler(request)
|
|
1475
|
+
# WebSocket upgrade can't follow a redirect — respond with 401 so the
|
|
1476
|
+
# browser-side reconnect loop surfaces a clear error.
|
|
1477
|
+
if path == "/ws":
|
|
1478
|
+
return web.Response(status=401, text="Not signed in.\n")
|
|
1479
|
+
raise web.HTTPFound("/login")
|
|
1480
|
+
|
|
1481
|
+
|
|
1313
1482
|
def build_app():
|
|
1314
|
-
|
|
1483
|
+
middlewares = [session_auth_middleware] if _AUTH_REQUIRED else []
|
|
1484
|
+
app = web.Application(middlewares=middlewares)
|
|
1315
1485
|
app.router.add_get("/", handle_index)
|
|
1486
|
+
app.router.add_get("/login", handle_login)
|
|
1487
|
+
app.router.add_post("/login", handle_login)
|
|
1488
|
+
app.router.add_get("/logout", handle_logout)
|
|
1316
1489
|
app.router.add_get("/ws", handle_ws)
|
|
1317
1490
|
app.router.add_get("/snapshot.html", handle_snapshot)
|
|
1318
1491
|
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
@@ -1341,6 +1514,10 @@ def _detect_lan_ips():
|
|
|
1341
1514
|
def main():
|
|
1342
1515
|
app = build_app()
|
|
1343
1516
|
print(f"rtplot browser server listening on http://localhost:{args.port}")
|
|
1517
|
+
if _AUTH_REQUIRED:
|
|
1518
|
+
print(" auth: shared password required (browsers see a login page)")
|
|
1519
|
+
else:
|
|
1520
|
+
print(" auth: none (set --password or RTPLOT_PASSWORD to gate the UI)")
|
|
1344
1521
|
for ip in _detect_lan_ips():
|
|
1345
1522
|
print(f" also reachable at http://{ip}:{args.port}")
|
|
1346
1523
|
is_wsl = "WSL_DISTRO_NAME" in os.environ or "WSL_INTEROP" in os.environ
|
|
@@ -68,6 +68,10 @@ def _settings_path():
|
|
|
68
68
|
DEFAULT_SETTINGS = {
|
|
69
69
|
# Most recent demo-sender targets, newest first, capped at 8.
|
|
70
70
|
"recent_hosts": [],
|
|
71
|
+
# Optional shared password that gates the browser UI. Empty string
|
|
72
|
+
# means "no auth". Stored in plaintext alongside the exe — the
|
|
73
|
+
# threat model is LAN convenience, not cryptographic secrecy.
|
|
74
|
+
"password": "",
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
MAX_RECENT_HOSTS = 8
|
|
@@ -222,10 +226,31 @@ def _parse_wrapper_args(argv):
|
|
|
222
226
|
"machine that only has the exe (no Python install)."
|
|
223
227
|
),
|
|
224
228
|
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--password",
|
|
231
|
+
default=None,
|
|
232
|
+
help=(
|
|
233
|
+
"Gate the UI behind a shared password (HTTP login page). "
|
|
234
|
+
"Overrides the password saved by the GUI's settings file. "
|
|
235
|
+
"Leave unset to use the saved password (or no auth if none "
|
|
236
|
+
"has been set)."
|
|
237
|
+
),
|
|
238
|
+
)
|
|
225
239
|
args, rest = parser.parse_known_args(argv)
|
|
226
240
|
return args, rest
|
|
227
241
|
|
|
228
242
|
|
|
243
|
+
def _resolve_password(args) -> str:
|
|
244
|
+
"""Return the effective password: CLI flag wins, else saved setting.
|
|
245
|
+
|
|
246
|
+
Empty string means "no auth"; anything else gates the UI.
|
|
247
|
+
"""
|
|
248
|
+
if args.password is not None:
|
|
249
|
+
return args.password
|
|
250
|
+
settings = _load_settings()
|
|
251
|
+
return settings.get("password", "") or ""
|
|
252
|
+
|
|
253
|
+
|
|
229
254
|
def _run_headless(args, rest):
|
|
230
255
|
"""Delegate straight to rtplot.server_browser's __main__."""
|
|
231
256
|
import runpy
|
|
@@ -233,6 +258,9 @@ def _run_headless(args, rest):
|
|
|
233
258
|
forwarded = [sys.argv[0], "--port", str(args.port), "--host", args.host]
|
|
234
259
|
if args.pi_ip:
|
|
235
260
|
forwarded += ["-p", args.pi_ip]
|
|
261
|
+
pw = _resolve_password(args)
|
|
262
|
+
if pw:
|
|
263
|
+
forwarded += ["--password", pw]
|
|
236
264
|
forwarded += rest
|
|
237
265
|
sys.argv = forwarded
|
|
238
266
|
runpy.run_module("rtplot.server_browser", run_name="__main__")
|
|
@@ -397,6 +425,11 @@ def _run_with_gui(args, rest):
|
|
|
397
425
|
|
|
398
426
|
settings = _load_settings()
|
|
399
427
|
|
|
428
|
+
# Password resolution order: CLI flag > saved setting > none. Whatever
|
|
429
|
+
# wins is forwarded to server_browser via --password so the server's
|
|
430
|
+
# auth middleware picks it up at import time.
|
|
431
|
+
effective_password = _resolve_password(args)
|
|
432
|
+
|
|
400
433
|
# Seed sys.argv so server_browser's module-level argparse sees what we
|
|
401
434
|
# want. --no-browser is always forced in GUI mode because the window
|
|
402
435
|
# has its own "Open in browser" button.
|
|
@@ -410,6 +443,8 @@ def _run_with_gui(args, rest):
|
|
|
410
443
|
]
|
|
411
444
|
if args.pi_ip:
|
|
412
445
|
forwarded += ["-p", args.pi_ip]
|
|
446
|
+
if effective_password:
|
|
447
|
+
forwarded += ["--password", effective_password]
|
|
413
448
|
forwarded += rest
|
|
414
449
|
sys.argv = forwarded
|
|
415
450
|
|
|
@@ -590,6 +625,13 @@ def _run_with_gui(args, rest):
|
|
|
590
625
|
style="Subhead.TLabel",
|
|
591
626
|
).pack(anchor="w", pady=(4, 10))
|
|
592
627
|
|
|
628
|
+
if effective_password:
|
|
629
|
+
ttk.Label(
|
|
630
|
+
main,
|
|
631
|
+
text="\U0001F512 Password protection is on \u2014 viewers need the shared password.",
|
|
632
|
+
style="Muted.TLabel",
|
|
633
|
+
).pack(anchor="w", pady=(0, 8))
|
|
634
|
+
|
|
593
635
|
# ---- Big URL display (clickable, copy-able) ----
|
|
594
636
|
url = f"http://localhost:{args.port}"
|
|
595
637
|
url_var = tk.StringVar(value=url)
|
|
@@ -855,6 +897,124 @@ def _run_with_gui(args, rest):
|
|
|
855
897
|
|
|
856
898
|
poll_demo_stats()
|
|
857
899
|
|
|
900
|
+
# --- Access control sub-section ---
|
|
901
|
+
ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
|
|
902
|
+
ttk.Label(
|
|
903
|
+
advanced_frame, text="Access control", style="Section.TLabel"
|
|
904
|
+
).pack(anchor="w")
|
|
905
|
+
ttk.Label(
|
|
906
|
+
advanced_frame,
|
|
907
|
+
text=(
|
|
908
|
+
"Set a shared password so the browser UI asks anyone on the\n"
|
|
909
|
+
"network to sign in before they can see your plots."
|
|
910
|
+
),
|
|
911
|
+
style="Help.TLabel",
|
|
912
|
+
).pack(anchor="w", pady=(2, 6))
|
|
913
|
+
|
|
914
|
+
pw_state_var = tk.StringVar()
|
|
915
|
+
pw_restart_note_var = tk.StringVar(value="")
|
|
916
|
+
|
|
917
|
+
def _refresh_pw_state():
|
|
918
|
+
if effective_password:
|
|
919
|
+
pw_state_var.set("\U0001F512 Password is set")
|
|
920
|
+
else:
|
|
921
|
+
pw_state_var.set("No password (UI is open to anyone who can reach it)")
|
|
922
|
+
|
|
923
|
+
_refresh_pw_state()
|
|
924
|
+
ttk.Label(advanced_frame, textvariable=pw_state_var, style="Muted.TLabel").pack(
|
|
925
|
+
anchor="w"
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def _open_password_dialog():
|
|
929
|
+
dlg = tk.Toplevel(root)
|
|
930
|
+
dlg.title("Set password")
|
|
931
|
+
dlg.transient(root)
|
|
932
|
+
dlg.grab_set()
|
|
933
|
+
dlg.resizable(False, False)
|
|
934
|
+
try:
|
|
935
|
+
dlg.configure(bg=BG)
|
|
936
|
+
except tk.TclError:
|
|
937
|
+
pass
|
|
938
|
+
|
|
939
|
+
body = ttk.Frame(dlg, padding=18)
|
|
940
|
+
body.pack(fill="both", expand=True)
|
|
941
|
+
|
|
942
|
+
ttk.Label(
|
|
943
|
+
body,
|
|
944
|
+
text="Shared password",
|
|
945
|
+
style="Section.TLabel",
|
|
946
|
+
).pack(anchor="w")
|
|
947
|
+
ttk.Label(
|
|
948
|
+
body,
|
|
949
|
+
text=(
|
|
950
|
+
"Everyone who opens the rtplot page will be asked for this\n"
|
|
951
|
+
"password. Leave blank + Save to remove password protection."
|
|
952
|
+
),
|
|
953
|
+
style="Help.TLabel",
|
|
954
|
+
).pack(anchor="w", pady=(2, 8))
|
|
955
|
+
|
|
956
|
+
pw_var = tk.StringVar(value=settings.get("password", ""))
|
|
957
|
+
show_var = tk.BooleanVar(value=False)
|
|
958
|
+
|
|
959
|
+
entry = ttk.Entry(body, textvariable=pw_var, show="\u2022", width=32)
|
|
960
|
+
entry.pack(anchor="w", fill="x")
|
|
961
|
+
entry.focus_set()
|
|
962
|
+
|
|
963
|
+
def _toggle_show():
|
|
964
|
+
entry.configure(show="" if show_var.get() else "\u2022")
|
|
965
|
+
|
|
966
|
+
ttk.Checkbutton(
|
|
967
|
+
body, text="Show password", variable=show_var, command=_toggle_show
|
|
968
|
+
).pack(anchor="w", pady=(6, 10))
|
|
969
|
+
|
|
970
|
+
err_var = tk.StringVar(value="")
|
|
971
|
+
ttk.Label(body, textvariable=err_var, foreground="#a11", background=BG).pack(
|
|
972
|
+
anchor="w"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
def _save_and_close():
|
|
976
|
+
new_pw = pw_var.get()
|
|
977
|
+
settings["password"] = new_pw
|
|
978
|
+
_save_settings(settings)
|
|
979
|
+
if new_pw == effective_password:
|
|
980
|
+
pw_restart_note_var.set("Password unchanged.")
|
|
981
|
+
else:
|
|
982
|
+
pw_restart_note_var.set(
|
|
983
|
+
"Saved. Close and reopen rtplot-server.exe to apply."
|
|
984
|
+
)
|
|
985
|
+
_refresh_pw_state()
|
|
986
|
+
dlg.destroy()
|
|
987
|
+
|
|
988
|
+
def _cancel():
|
|
989
|
+
dlg.destroy()
|
|
990
|
+
|
|
991
|
+
btn_row = ttk.Frame(body)
|
|
992
|
+
btn_row.pack(fill="x", pady=(10, 0))
|
|
993
|
+
ttk.Button(btn_row, text="Save", command=_save_and_close).pack(side="right")
|
|
994
|
+
ttk.Button(btn_row, text="Cancel", command=_cancel).pack(
|
|
995
|
+
side="right", padx=(0, 6)
|
|
996
|
+
)
|
|
997
|
+
# Enter saves, Escape cancels — matches typical form UX.
|
|
998
|
+
dlg.bind("<Return>", lambda _e: _save_and_close())
|
|
999
|
+
dlg.bind("<Escape>", lambda _e: _cancel())
|
|
1000
|
+
# Center over the main window.
|
|
1001
|
+
dlg.update_idletasks()
|
|
1002
|
+
rx, ry = root.winfo_rootx(), root.winfo_rooty()
|
|
1003
|
+
rw, rh = root.winfo_width(), root.winfo_height()
|
|
1004
|
+
dw, dh = dlg.winfo_reqwidth(), dlg.winfo_reqheight()
|
|
1005
|
+
dlg.geometry(f"+{rx + (rw - dw) // 2}+{ry + (rh - dh) // 2}")
|
|
1006
|
+
|
|
1007
|
+
pw_btn_row = ttk.Frame(advanced_frame)
|
|
1008
|
+
pw_btn_row.pack(anchor="w", pady=(6, 0))
|
|
1009
|
+
ttk.Button(
|
|
1010
|
+
pw_btn_row,
|
|
1011
|
+
text=("Change password" if effective_password else "Set password"),
|
|
1012
|
+
command=_open_password_dialog,
|
|
1013
|
+
).pack(side="left")
|
|
1014
|
+
ttk.Label(
|
|
1015
|
+
pw_btn_row, textvariable=pw_restart_note_var, style="Muted.TLabel"
|
|
1016
|
+
).pack(side="left", padx=(8, 0))
|
|
1017
|
+
|
|
858
1018
|
# --- Log sub-section (further collapsable inside advanced) ---
|
|
859
1019
|
ttk.Separator(advanced_frame, orient="horizontal").pack(fill="x", pady=(14, 10))
|
|
860
1020
|
log_state = {"expanded": False}
|
|
@@ -18,8 +18,16 @@
|
|
|
18
18
|
.tab .tab-dot.error { background: #e05252; }
|
|
19
19
|
.tab .tab-dot.connecting { background: #e8b13a; animation: pulse 1s ease-in-out infinite alternate; }
|
|
20
20
|
@keyframes pulse { from { opacity: 0.5; } to { opacity: 1.0; } }
|
|
21
|
-
.tab .tab-
|
|
21
|
+
.tab .tab-labels { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
|
|
22
|
+
.tab .tab-name { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
23
|
+
.tab .tab-endpoint { font-family: monospace; font-size: calc(10px * var(--ui-scale)); color: #888; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
24
|
+
.tab.active .tab-endpoint { color: #5a6880; }
|
|
22
25
|
.tab .tab-name-input { border: 1px solid #2a5db0; border-radius: 3px; padding: 1px 4px; font: inherit; color: #111; width: 160px; }
|
|
26
|
+
.tab .tab-reconnect { font-size: calc(13px * var(--ui-scale)); color: #888; cursor: pointer; padding: 1px 5px; border-radius: 2px; opacity: 0.0; line-height: 1; }
|
|
27
|
+
.tab:hover .tab-reconnect, .tab .tab-dot.error ~ .tab-reconnect { opacity: 1.0; }
|
|
28
|
+
.tab .tab-reconnect:hover { background: #e0e6f0; color: #2a5db0; }
|
|
29
|
+
.tab .tab-reconnect.spinning { animation: tab-spin 0.6s linear; }
|
|
30
|
+
@keyframes tab-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
23
31
|
.tab .tab-rename { font-size: calc(11px * var(--ui-scale)); color: #888; cursor: pointer; padding: 1px 4px; border-radius: 2px; opacity: 0.0; }
|
|
24
32
|
.tab:hover .tab-rename { opacity: 1.0; }
|
|
25
33
|
.tab .tab-rename:hover { background: #e0e0e0; color: #333; }
|
|
@@ -916,10 +924,37 @@
|
|
|
916
924
|
if (t.error) dot.title = t.error;
|
|
917
925
|
el.appendChild(dot);
|
|
918
926
|
|
|
927
|
+
// Stacked labels: name on top, endpoint on a muted subline so
|
|
928
|
+
// users can see at a glance which tab is wired to which device.
|
|
929
|
+
const labels = document.createElement('div');
|
|
930
|
+
labels.className = 'tab-labels';
|
|
919
931
|
const name = document.createElement('span');
|
|
920
932
|
name.className = 'tab-name';
|
|
921
933
|
name.textContent = t.name || id;
|
|
922
|
-
|
|
934
|
+
labels.appendChild(name);
|
|
935
|
+
const ep = document.createElement('span');
|
|
936
|
+
ep.className = 'tab-endpoint';
|
|
937
|
+
ep.textContent = (t.mode === 'bind')
|
|
938
|
+
? `listening on ${t.endpoint || '*:5555'}`
|
|
939
|
+
: `connected to ${t.endpoint || '?'}`;
|
|
940
|
+
labels.appendChild(ep);
|
|
941
|
+
el.appendChild(labels);
|
|
942
|
+
|
|
943
|
+
const reconnect = document.createElement('span');
|
|
944
|
+
reconnect.className = 'tab-reconnect';
|
|
945
|
+
reconnect.textContent = '\u21BB';
|
|
946
|
+
reconnect.title = 'Reconnect';
|
|
947
|
+
reconnect.addEventListener('click', (e) => {
|
|
948
|
+
e.stopPropagation();
|
|
949
|
+
sendCtrl({ type: 'tab_reconnect', id: id });
|
|
950
|
+
// Visual feedback: the icon spins once so users see the click
|
|
951
|
+
// registered even when the server returns the same status
|
|
952
|
+
// (e.g. reconnect succeeded, no dot color change).
|
|
953
|
+
reconnect.classList.remove('spinning');
|
|
954
|
+
void reconnect.offsetWidth; // force reflow to restart animation
|
|
955
|
+
reconnect.classList.add('spinning');
|
|
956
|
+
});
|
|
957
|
+
el.appendChild(reconnect);
|
|
923
958
|
|
|
924
959
|
if (id !== 'bind_me') {
|
|
925
960
|
const ren = document.createElement('span');
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|