better-rtplot 0.4.2__tar.gz → 0.4.3__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.3}/PKG-INFO +1 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/pyproject.toml +1 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/server_browser.py +150 -1
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/server_browser_gui.py +160 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/LICENSE +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/README.md +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/client.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/interactive_test.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/saved_plots/.gitignore +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/server.py +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/static/index.html +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/rtplot/static/uPlot.iife.min.js +0 -0
- {better_rtplot-0.4.2 → better_rtplot-0.4.3}/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
|
###############################
|
|
@@ -1310,9 +1324,140 @@ async def on_cleanup(app):
|
|
|
1310
1324
|
zmq_ctx.term()
|
|
1311
1325
|
|
|
1312
1326
|
|
|
1327
|
+
###############################
|
|
1328
|
+
# Cookie-based password auth #
|
|
1329
|
+
###############################
|
|
1330
|
+
|
|
1331
|
+
# A single shared password unlocks the UI for every browser. We deliberately
|
|
1332
|
+
# don't model users: the browser's native Basic-Auth popup forces a username
|
|
1333
|
+
# field, which was confusing — one password is enough for LAN deployments.
|
|
1334
|
+
# On successful POST /login, the server mints a random session token, stashes
|
|
1335
|
+
# it in an in-memory set, and drops it as an HttpOnly cookie. All other
|
|
1336
|
+
# routes (including the WebSocket upgrade) require that cookie.
|
|
1337
|
+
import hmac as _hmac
|
|
1338
|
+
import secrets as _secrets
|
|
1339
|
+
|
|
1340
|
+
SESSION_COOKIE = "rtplot_session"
|
|
1341
|
+
_AUTH_REQUIRED = AUTH_PASSWORD is not None
|
|
1342
|
+
_AUTH_EXPECTED = AUTH_PASSWORD.encode("utf-8") if AUTH_PASSWORD else None
|
|
1343
|
+
_valid_tokens: set = set()
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
_LOGIN_HTML = """<!doctype html>
|
|
1347
|
+
<html lang=\"en\">
|
|
1348
|
+
<head>
|
|
1349
|
+
<meta charset=\"utf-8\" />
|
|
1350
|
+
<title>rtplot \u2014 sign in</title>
|
|
1351
|
+
<style>
|
|
1352
|
+
html, body { margin: 0; padding: 0; min-height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #eef1f5; color: #222; }
|
|
1353
|
+
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1354
|
+
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; }
|
|
1355
|
+
h1 { margin: 0 0 6px 0; font-size: 20px; color: #1d3566; }
|
|
1356
|
+
p { margin: 0 0 18px 0; color: #666; font-size: 13px; }
|
|
1357
|
+
label { display: block; font-size: 12px; color: #555; margin-bottom: 6px; }
|
|
1358
|
+
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; }
|
|
1359
|
+
input[type=password]:focus { outline: none; border-color: #2a5db0; box-shadow: 0 0 0 2px rgba(42,93,176,0.15); }
|
|
1360
|
+
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; }
|
|
1361
|
+
button:hover { background: #244f95; }
|
|
1362
|
+
.err { background: #fce8e8; color: #a11; border: 1px solid #f0c2c2; padding: 8px 10px; border-radius: 4px; font-size: 13px; margin-bottom: 14px; }
|
|
1363
|
+
</style>
|
|
1364
|
+
</head>
|
|
1365
|
+
<body>
|
|
1366
|
+
<form method=\"post\" action=\"/login\" autocomplete=\"on\">
|
|
1367
|
+
<h1>rtplot</h1>
|
|
1368
|
+
<p>Enter the shared password to continue.</p>
|
|
1369
|
+
__ERROR_BLOCK__
|
|
1370
|
+
<label for=\"pw\">Password</label>
|
|
1371
|
+
<input id=\"pw\" type=\"password\" name=\"password\" autofocus required />
|
|
1372
|
+
<button type=\"submit\">Sign in</button>
|
|
1373
|
+
</form>
|
|
1374
|
+
</body>
|
|
1375
|
+
</html>
|
|
1376
|
+
"""
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _render_login_page(error: bool = False) -> str:
|
|
1380
|
+
err = '<div class="err">Wrong password. Try again.</div>' if error else ""
|
|
1381
|
+
return _LOGIN_HTML.replace("__ERROR_BLOCK__", err)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _has_valid_session(request) -> bool:
|
|
1385
|
+
tok = request.cookies.get(SESSION_COOKIE)
|
|
1386
|
+
return bool(tok) and tok in _valid_tokens
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
async def handle_login(request):
|
|
1390
|
+
# GET /login -> render form
|
|
1391
|
+
# POST /login -> check password, set cookie on success
|
|
1392
|
+
if request.method == "GET":
|
|
1393
|
+
if _has_valid_session(request):
|
|
1394
|
+
raise web.HTTPFound("/")
|
|
1395
|
+
return web.Response(
|
|
1396
|
+
text=_render_login_page(),
|
|
1397
|
+
content_type="text/html",
|
|
1398
|
+
headers={"Cache-Control": "no-store"},
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
if not _AUTH_REQUIRED:
|
|
1402
|
+
raise web.HTTPFound("/")
|
|
1403
|
+
data = await request.post()
|
|
1404
|
+
supplied = (data.get("password") or "").encode("utf-8")
|
|
1405
|
+
if _hmac.compare_digest(supplied, _AUTH_EXPECTED or b""):
|
|
1406
|
+
token = _secrets.token_urlsafe(32)
|
|
1407
|
+
_valid_tokens.add(token)
|
|
1408
|
+
resp = web.HTTPFound("/")
|
|
1409
|
+
resp.set_cookie(
|
|
1410
|
+
SESSION_COOKIE,
|
|
1411
|
+
token,
|
|
1412
|
+
httponly=True,
|
|
1413
|
+
samesite="Lax",
|
|
1414
|
+
max_age=30 * 24 * 3600, # 30 days
|
|
1415
|
+
path="/",
|
|
1416
|
+
)
|
|
1417
|
+
raise resp
|
|
1418
|
+
return web.Response(
|
|
1419
|
+
text=_render_login_page(error=True),
|
|
1420
|
+
content_type="text/html",
|
|
1421
|
+
status=401,
|
|
1422
|
+
headers={"Cache-Control": "no-store"},
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
async def handle_logout(request):
|
|
1427
|
+
tok = request.cookies.get(SESSION_COOKIE)
|
|
1428
|
+
if tok:
|
|
1429
|
+
_valid_tokens.discard(tok)
|
|
1430
|
+
resp = web.HTTPFound("/login")
|
|
1431
|
+
resp.del_cookie(SESSION_COOKIE, path="/")
|
|
1432
|
+
raise resp
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
@web.middleware
|
|
1436
|
+
async def session_auth_middleware(request, handler):
|
|
1437
|
+
if not _AUTH_REQUIRED:
|
|
1438
|
+
return await handler(request)
|
|
1439
|
+
path = request.path
|
|
1440
|
+
# Login page and its form post must stay open; the uPlot assets that
|
|
1441
|
+
# the main page pulls in are gated too, which is consistent with the
|
|
1442
|
+
# rest of the UI.
|
|
1443
|
+
if path == "/login" or path == "/logout":
|
|
1444
|
+
return await handler(request)
|
|
1445
|
+
if _has_valid_session(request):
|
|
1446
|
+
return await handler(request)
|
|
1447
|
+
# WebSocket upgrade can't follow a redirect — respond with 401 so the
|
|
1448
|
+
# browser-side reconnect loop surfaces a clear error.
|
|
1449
|
+
if path == "/ws":
|
|
1450
|
+
return web.Response(status=401, text="Not signed in.\n")
|
|
1451
|
+
raise web.HTTPFound("/login")
|
|
1452
|
+
|
|
1453
|
+
|
|
1313
1454
|
def build_app():
|
|
1314
|
-
|
|
1455
|
+
middlewares = [session_auth_middleware] if _AUTH_REQUIRED else []
|
|
1456
|
+
app = web.Application(middlewares=middlewares)
|
|
1315
1457
|
app.router.add_get("/", handle_index)
|
|
1458
|
+
app.router.add_get("/login", handle_login)
|
|
1459
|
+
app.router.add_post("/login", handle_login)
|
|
1460
|
+
app.router.add_get("/logout", handle_logout)
|
|
1316
1461
|
app.router.add_get("/ws", handle_ws)
|
|
1317
1462
|
app.router.add_get("/snapshot.html", handle_snapshot)
|
|
1318
1463
|
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
@@ -1341,6 +1486,10 @@ def _detect_lan_ips():
|
|
|
1341
1486
|
def main():
|
|
1342
1487
|
app = build_app()
|
|
1343
1488
|
print(f"rtplot browser server listening on http://localhost:{args.port}")
|
|
1489
|
+
if _AUTH_REQUIRED:
|
|
1490
|
+
print(" auth: shared password required (browsers see a login page)")
|
|
1491
|
+
else:
|
|
1492
|
+
print(" auth: none (set --password or RTPLOT_PASSWORD to gate the UI)")
|
|
1344
1493
|
for ip in _detect_lan_ips():
|
|
1345
1494
|
print(f" also reachable at http://{ip}:{args.port}")
|
|
1346
1495
|
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}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|