mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0577c807eb | ||
|
|
5a7893b11a |
@@ -19,6 +19,58 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()`
|
||||
|
||||
The in-process way in that stage 5 of the control socket needed
|
||||
(`docs/IPC_CONTROL_SOCKET.md`, "Plugins in the display process").
|
||||
|
||||
- **`BasePlugin.request_on_demand(mode=None, duration=None, pinned=False)`**
|
||||
shows the plugin now, and **`BasePlugin.end_on_demand()`** gives the
|
||||
screen back. Both are safe from any thread (an MQTT callback, a timer
|
||||
thread): `PluginManager.request_on_demand()` / `end_on_demand()` hand the
|
||||
request to `DisplayController.submit_plugin_on_demand()`, which only
|
||||
queues it (at most 32) and wakes the render thread through the control
|
||||
socket's flag (`ControlServer.wake()`). The render thread applies it with
|
||||
the socket's commands, through the same handler as a web on-demand
|
||||
request, so it lands within a frame rather than on the mailbox's
|
||||
once-a-second look. Both return the request id, or `None` when no display
|
||||
runs in the process (the web interface, `scripts/check_plugin.py`) or the
|
||||
queue is full.
|
||||
- **A plugin's stop ends only its own session.** A mailbox stop still ends
|
||||
any session, whoever started it.
|
||||
- **Older cores.** Plugins detect the methods with `hasattr` and write the
|
||||
`display_on_demand_request` mailbox when they are missing or answer
|
||||
`None`; the pattern is in `docs/PLUGIN_API_REFERENCE.md` ("On-demand
|
||||
display"). The display still reads the mailbox for plugins that write it.
|
||||
|
||||
### Web UI: Schedule and General are ES-module pages (stage 3)
|
||||
|
||||
- The Schedule and General tabs follow stage 2 (#727): their inline
|
||||
`<script>` blocks are now `static/v3/js/pages/schedule.js` and
|
||||
`pages/general.js`, started once per swap-in by the page registry and
|
||||
stopped on swap-out. Neither partial has an inline script, `onclick`,
|
||||
`onsubmit` or `oninput` any more.
|
||||
- Schedule: both pickers are drawn from the saved config the partial
|
||||
carries as JSON in `data-schedule-config` / `data-dim-schedule-config`.
|
||||
The forms' `hx-on` save handlers became one `htmx:afterRequest` listener
|
||||
on the page; the forms are marked `data-reports-result`, which `app.js`
|
||||
now honours like an `hx-on` after-request handler, so a save still shows
|
||||
one notification.
|
||||
- General: the timezone picker reads the saved zone from `data-timezone`.
|
||||
The Security section's forms and buttons carry `data-action` and use one
|
||||
delegated submit and one delegated click listener, so a token row added
|
||||
after a create needs no listener of its own. Requests go through
|
||||
`core/api.js`: the optional login's "sign in again" answer no longer
|
||||
flashes an error while the page navigates to the login form. A login
|
||||
change made just before a swap is still reported.
|
||||
- Old globals keep working as deprecated aliases through `window.LEDMatrix`
|
||||
(one console warning each): `handleScheduleResponse`,
|
||||
`handleDimScheduleResponse`, and `webLogin` (its five methods).
|
||||
- New DOM suites `test/js/dom/test_{schedule,general}_page.js`;
|
||||
`unit/test_general_web_login_token.js` imports the module instead of
|
||||
slicing the template, and `unit/test_restart_banner.js` covers
|
||||
`data-reports-result`.
|
||||
|
||||
### The control socket carries every web command; the mailboxes are a fallback
|
||||
|
||||
Stage 4 of the web → display control socket (`docs/IPC_CONTROL_SOCKET.md`).
|
||||
|
||||
@@ -489,7 +489,7 @@ restart banner, as before.
|
||||
|
||||
| Mailbox | Written by | Read by the display | While the socket is up |
|
||||
|---|---|---|---|
|
||||
| `display_on_demand_request` | the web interface, only on fallback; four plugins directly (birdnet-go, mqtt-notifications, on-air, pomodoro-timer) | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||
| `display_on_demand_request` | the web interface, only on fallback; plugins that predate `BasePlugin.request_on_demand()`, or run on a core without it | the render thread, `_poll_on_demand_requests()` | looked at every 1 s (`MAILBOX_POLL_INTERVAL_WITH_SOCKET`), 0.25 s without a socket |
|
||||
| `plugin_error_clear_request` | the web interface, only on fallback | the error publisher's thread, every 5 s tick | unchanged rate |
|
||||
|
||||
A look is one `stat()` of the mailbox file (`CacheManager.file_signature`):
|
||||
@@ -502,8 +502,25 @@ the mailbox instead of being re-read until it expires.
|
||||
|
||||
A request that comes through the on-demand mailbox while the socket is up
|
||||
is logged once per writer (`came through the file mailbox although the
|
||||
control socket is up`), which names the plugins that still need an
|
||||
in-process way in before the mailbox is removed.
|
||||
control socket is up`), which names the plugins that still write it.
|
||||
|
||||
### Plugins in the display process
|
||||
|
||||
A plugin asks for the screen with `BasePlugin.request_on_demand()` and gives
|
||||
it back with `end_on_demand()` (see "On-demand display" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)). Neither goes through
|
||||
the socket or a file: `PluginManager` hands the mailbox-shaped request,
|
||||
marked `source: 'plugin'`, to `DisplayController.submit_plugin_on_demand`,
|
||||
which queues it in memory (at most `PLUGIN_ON_DEMAND_QUEUE_SIZE`, 32) from
|
||||
whatever thread the plugin called on, and wakes the render thread through
|
||||
the socket's queue flag (`ControlServer.wake()`). The render thread applies
|
||||
it in `_drain_control_commands`, after the socket's commands, through the
|
||||
same `_handle_on_demand_request`, so it lands within a frame like a socket
|
||||
command. Without a socket it lands on the next pending-changes pass (typically
|
||||
within 0.25 s). A plugin's stop ends only a session that plugin owns. The four
|
||||
plugins that wrote the mailbox (birdnet-go, mqtt-notifications, on-air,
|
||||
pomodoro-timer) use it where the core has it and write the mailbox
|
||||
otherwise.
|
||||
|
||||
## Robustness
|
||||
|
||||
@@ -659,9 +676,11 @@ device never touches the live display.
|
||||
running display (their routes say so); they are not mailboxes.
|
||||
5. **Remove the mailboxes (next release).** Once every device has run a
|
||||
display with stage 4, the web interface stops writing both mailboxes and
|
||||
the display stops reading them. The four plugins that write
|
||||
`display_on_demand_request` need an in-process way to ask for the screen
|
||||
first. The display also stops writing `display_current_state`,
|
||||
the display stops reading them. The four plugins that wrote
|
||||
`display_on_demand_request` now have an in-process way to ask for the
|
||||
screen (`BasePlugin.request_on_demand()` / `end_on_demand()`, see
|
||||
"Plugins in the display process"); they keep the mailbox write only as
|
||||
their fallback on older cores. The display also stops writing `display_current_state`,
|
||||
`display_on_demand_state` and `plugin_runtime_snapshot` once the web
|
||||
interface no longer falls back to them.
|
||||
|
||||
|
||||
@@ -488,6 +488,78 @@ working for the plugin itself. `get_vegas_segment_width()` read the
|
||||
`vegas_panel_count` config value, which has never affected Vegas — a card's
|
||||
width comes from `get_vegas_content()` and `vegas_width_pct`.
|
||||
|
||||
### On-demand display
|
||||
|
||||
A plugin that reacts to something outside the rotation (an MQTT message, a
|
||||
timer, a detection) can take the screen for it, and give it back. Both
|
||||
methods are safe from any thread, including an MQTT callback: they only
|
||||
queue the request, and the display applies it on its render thread within a
|
||||
frame or so, exactly like an on-demand start or stop from the web interface.
|
||||
|
||||
#### `request_on_demand(mode=None, duration=None, pinned=False) -> Optional[str]`
|
||||
|
||||
Show this plugin now.
|
||||
|
||||
- `mode`: one of the plugin's display modes; `None` for its first.
|
||||
- `duration`: seconds before the rotation resumes; `None` (or `0`) for no
|
||||
limit, until `end_on_demand()` or the user stops it.
|
||||
- `pinned`: stay on `mode` instead of cycling through the plugin's other
|
||||
modes.
|
||||
|
||||
Returns the request id once the display has queued it, or `None` when
|
||||
there is no display in this process to ask (the web interface's plugin
|
||||
manager, `scripts/check_plugin.py`) or its queue is full. A bad argument
|
||||
(a `mode` that is not a string, a `duration` that is not a number) raises
|
||||
`ValueError`.
|
||||
|
||||
#### `end_on_demand() -> Optional[str]`
|
||||
|
||||
Give the screen back. Ends only a session this plugin owns: a session the
|
||||
user started for another plugin, or one that already ended, is left alone.
|
||||
Returns the request id once queued, or `None` as above.
|
||||
|
||||
#### Older cores: feature detection
|
||||
|
||||
These methods are new after core 3.8.0 (see `CHANGELOG.md`). Before them,
|
||||
plugins wrote the `display_on_demand_request` cache key (the "mailbox")
|
||||
themselves. The display reads it only once a second while the control
|
||||
socket is up, and it will be removed in a future release (see
|
||||
[IPC_CONTROL_SOCKET.md](IPC_CONTROL_SOCKET.md), stage 5). A plugin that
|
||||
must keep working on older cores checks for the method, and writes the
|
||||
mailbox only when the method is missing or answers `None`:
|
||||
|
||||
```python
|
||||
import time, uuid
|
||||
|
||||
def _show_alert(self):
|
||||
if hasattr(self, "request_on_demand") and self.request_on_demand(
|
||||
mode="my_alert", duration=15):
|
||||
return
|
||||
# Older core, or no display in this process: the mailbox, as before.
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "start",
|
||||
"plugin_id": self.plugin_id, "mode": "my_alert",
|
||||
"duration": 15, "pinned": False, "timestamp": time.time(),
|
||||
})
|
||||
|
||||
def _release(self):
|
||||
if hasattr(self, "end_on_demand") and self.end_on_demand():
|
||||
return
|
||||
self.cache_manager.set("display_on_demand_request", {
|
||||
"request_id": str(uuid.uuid4()), "action": "stop",
|
||||
"plugin_id": self.plugin_id, "timestamp": time.time(),
|
||||
})
|
||||
```
|
||||
|
||||
Keep `ledmatrix_min_version` where it is: the fallback is what keeps the
|
||||
plugin working on older cores. A mailbox stop ends any on-demand session,
|
||||
whoever started it; `end_on_demand()` ends only the plugin's own.
|
||||
|
||||
Both methods answer a request id only when the plugin manager returned a
|
||||
string, so a test that gives the plugin a `MagicMock()` plugin manager gets
|
||||
`None` and exercises the mailbox path. To test the new path, set
|
||||
`plugin_manager.request_on_demand.return_value = "some-id"`.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
> source, the source wins — please open an issue or PR to fix the doc.
|
||||
@@ -966,6 +1038,14 @@ if info:
|
||||
self.logger.info(f"Plugin: {info['name']}, Version: {info.get('version')}")
|
||||
```
|
||||
|
||||
#### `request_on_demand(plugin_id, mode=None, duration=None, pinned=False)` / `end_on_demand(plugin_id)`
|
||||
|
||||
What `BasePlugin.request_on_demand()` and `end_on_demand()` call, with the
|
||||
plugin's own id. Call those instead; see
|
||||
[On-demand display](#on-demand-display). The display controller routes them
|
||||
to itself with `set_on_demand_handler()`; a plugin manager without a
|
||||
display behind it answers `None`.
|
||||
|
||||
#### `get_all_plugin_info() -> List[Dict[str, Any]]`
|
||||
|
||||
Get information for all plugins.
|
||||
|
||||
@@ -81,6 +81,14 @@ The conventions the converted pages share:
|
||||
to the module's export of the same name and warns once.
|
||||
- **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot
|
||||
undo by itself.
|
||||
- **A page reports its own htmx saves.** A form whose result a page module
|
||||
shows (an `htmx:afterRequest` listener on the page root, in place of an
|
||||
`hx-on` attribute naming a global) carries `data-reports-result`. `app.js`
|
||||
then leaves the server's message to the page, as it does for a form with
|
||||
an `hx-on` after-request handler, so a save shows one notification.
|
||||
- **Server data for the module goes in `data-*` attributes**, as JSON where
|
||||
it is structured (`data-schedule-config='{{ schedule_config | tojson }}'`),
|
||||
not templated into a script.
|
||||
|
||||
`core/registry.js` handles the rest:
|
||||
|
||||
@@ -239,8 +247,8 @@ are the inline script in each partial today.
|
||||
| 3 | Operation History | 293 lines, now 0 | **Done in stage 2.** Read-only list; rows drawn with `textContent`, the search debounce cleared on destroy. The "Showing x to y" counters now also reset when nothing matches |
|
||||
| 4 | Config Editor (`raw_json.html`) | 212 lines, now 0 | **Done in stage 2.** Plain textareas (no CodeMirror on this page). It defined 5 globals after all (`formatJson`, `manualValidateJson`, `validateJSON`, `saveMainConfig`, `saveSecretsConfig`); nothing else used them, and they are deprecated aliases now. The live "Invalid JSON" line no longer puts the parser's message into `innerHTML` |
|
||||
| 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) |
|
||||
| 6 | Schedule | 193 | 2 globals used as `hx-on` response handlers. Moves `hx-on` handlers into page listeners |
|
||||
| 7 | General | 147 | `webLogin` global and the security section. The first page that touches login |
|
||||
| 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script |
|
||||
| 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does |
|
||||
| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy |
|
||||
| 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals |
|
||||
| 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device |
|
||||
@@ -284,6 +292,8 @@ Unit suites need only node. They import the shipped modules directly:
|
||||
| `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text |
|
||||
| `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text |
|
||||
| `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points |
|
||||
| `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points |
|
||||
| `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points |
|
||||
| `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no `<script>` and no `onclick`; every moved global is aliased in `boot.js` and exported by its module, and no template defines it any more |
|
||||
| `test/test_field_model_parity.py` | pytest | The model against the macro for every available schema |
|
||||
|
||||
@@ -14,12 +14,6 @@ project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if project_dir not in sys.path:
|
||||
sys.path.insert(0, project_dir)
|
||||
|
||||
# Cap glibc's malloc arenas before any thread exists (arenas already made
|
||||
# stay): the in-process twin of the unit's MALLOC_ARENA_MAX=2, for units
|
||||
# installed before that line. A no-op off glibc. See src/malloc_tuning.py.
|
||||
from src import malloc_tuning
|
||||
print('EXP cap_arenas=%s' % malloc_tuning.cap_arenas(), flush=True)
|
||||
|
||||
# Under systemd the watchdog clock is already running, and start-up (plugin
|
||||
# loads, initial updates) takes far longer than the render loop's limit. Widen
|
||||
# it before anything slow is imported; the render loop narrows it again once
|
||||
|
||||
+104
-21
@@ -37,7 +37,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disab
|
||||
import pytz
|
||||
|
||||
from src import display_watchdog
|
||||
from src.malloc_tuning import MallocTrimmer
|
||||
from src.display_arbiter import (
|
||||
Arbiter, ArbiterInputs, ArbiterState, FramePolicy,
|
||||
ScreenPlan, Source, WifiNotice, live_pick, live_takeover, on_demand_bound, rotation_plan,
|
||||
@@ -395,6 +394,12 @@ class DisplayController:
|
||||
plugin_time = time.time()
|
||||
self.plugin_manager = None
|
||||
self._plugin_runtime_publisher = None
|
||||
# On-demand requests plugins make in this process (BasePlugin.
|
||||
# request_on_demand / end_on_demand), from any thread; the render
|
||||
# thread drains them with the socket's commands. Created before the
|
||||
# plugins load, because a plugin may ask from its first thread.
|
||||
self._plugin_on_demand: deque = deque()
|
||||
self._plugin_on_demand_lock = threading.Lock()
|
||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||
@@ -509,6 +514,12 @@ class DisplayController:
|
||||
cache_manager=self.cache_manager,
|
||||
font_manager=self.font_manager
|
||||
)
|
||||
# BasePlugin.request_on_demand() / end_on_demand() land here.
|
||||
# Before any plugin loads: a plugin may ask from its first thread.
|
||||
# getattr: tests and golden traces stand in simpler managers.
|
||||
set_handler = getattr(self.plugin_manager, 'set_on_demand_handler', None)
|
||||
if callable(set_handler):
|
||||
set_handler(self.submit_plugin_on_demand)
|
||||
|
||||
# The web UI's loaded / state / error_info for each plugin read
|
||||
# what this publishes. Started before loading, so the loads that
|
||||
@@ -1883,6 +1894,10 @@ class DisplayController:
|
||||
_on_demand_mailbox: Optional[MailboxWatch] = None
|
||||
#: Writers whose mailbox requests have been logged (_note_mailbox_request).
|
||||
_mailbox_writers_logged: FrozenSet[str] = frozenset()
|
||||
#: Most plugin on-demand requests waiting for the render thread at once.
|
||||
#: A plugin that asks faster than the display drains (four times a
|
||||
#: second at worst) is refused, not queued without end.
|
||||
PLUGIN_ON_DEMAND_QUEUE_SIZE = 32
|
||||
|
||||
def _service_pending_changes(self) -> None:
|
||||
"""Apply changes made elsewhere while the display thread is busy.
|
||||
@@ -1907,7 +1922,7 @@ class DisplayController:
|
||||
# A command queued on the control socket skips the floor: it is in
|
||||
# memory, so applying it now costs no disk read.
|
||||
if (last is not None and now - last < self.PENDING_CHANGES_INTERVAL
|
||||
and not (self._control_server and self._control_server.has_pending)):
|
||||
and not self._control_command_pending()):
|
||||
return
|
||||
self._last_pending_service = now
|
||||
|
||||
@@ -2106,11 +2121,15 @@ class DisplayController:
|
||||
here. A plugin reload waits for the top of the next loop pass, where
|
||||
no plugin is on the stack (_apply_pending_plugin_reloads); until
|
||||
then the current screen ends early (_plugin_reload_pending).
|
||||
|
||||
Plugins' own on-demand requests (submit_plugin_on_demand) are
|
||||
applied here too, after the socket's, with or without a socket.
|
||||
"""
|
||||
server = self._control_server
|
||||
if server is None or not server.has_pending:
|
||||
return
|
||||
for command in server.drain():
|
||||
# drain() clears the wake flag before the plugin queue is read below,
|
||||
# so a plugin request queued from here on wakes the next wait.
|
||||
commands = server.drain() if server is not None and server.has_pending else []
|
||||
for command in commands:
|
||||
try:
|
||||
if command.cmd == ControlCommand.BRIGHTNESS_SET:
|
||||
self._apply_control_brightness(command)
|
||||
@@ -2122,24 +2141,78 @@ class DisplayController:
|
||||
logger.exception("Failed to apply control socket command %s",
|
||||
command.request_id)
|
||||
command.fail(ControlErrorCode.INTERNAL, 'the display failed to apply it')
|
||||
self._drain_plugin_on_demand()
|
||||
|
||||
# -- plugins' in-process on-demand requests ---------------------------------
|
||||
|
||||
def submit_plugin_on_demand(self, request: Dict[str, Any]) -> bool:
|
||||
"""Queue a plugin's on-demand request for the render thread. Any thread.
|
||||
|
||||
``PluginManager.request_on_demand`` / ``end_on_demand`` (which
|
||||
BasePlugin's methods of the same names call) build ``request``: the
|
||||
mailbox's shape, with ``source: 'plugin'`` and the asking plugin's
|
||||
id. Nothing here touches the panel or the on-demand state; the render
|
||||
thread applies the request where it applies a socket command
|
||||
(_drain_control_commands), through _handle_on_demand_request, and is
|
||||
woken for it when the control socket is up. True when it was queued;
|
||||
False (logged) when the queue is full.
|
||||
"""
|
||||
pending = self.__dict__.get('_plugin_on_demand')
|
||||
lock = self.__dict__.get('_plugin_on_demand_lock')
|
||||
if pending is None or lock is None:
|
||||
return False # a controller built without __init__ (tests)
|
||||
with lock:
|
||||
if len(pending) >= self.PLUGIN_ON_DEMAND_QUEUE_SIZE:
|
||||
logger.warning("Plugin on-demand queue full; refusing %s %s from %s",
|
||||
request.get('action'), request.get('request_id'),
|
||||
request.get('plugin_id'))
|
||||
return False
|
||||
pending.append(dict(request))
|
||||
server = self._control_server
|
||||
if server is not None:
|
||||
server.wake()
|
||||
return True
|
||||
|
||||
def _plugin_on_demand_pending(self) -> bool:
|
||||
"""A plugin's on-demand request is waiting. A length check: no lock."""
|
||||
return bool(self.__dict__.get('_plugin_on_demand'))
|
||||
|
||||
def _drain_plugin_on_demand(self) -> None:
|
||||
"""Apply the plugins' queued on-demand requests, oldest first. Render thread."""
|
||||
pending = self.__dict__.get('_plugin_on_demand')
|
||||
while pending:
|
||||
try:
|
||||
request = pending.popleft()
|
||||
except IndexError:
|
||||
break
|
||||
try:
|
||||
self._handle_on_demand_request(request)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to apply on-demand request %s from plugin %s",
|
||||
request.get('request_id'), request.get('plugin_id'))
|
||||
|
||||
def _wait_for_control(self, timeout: float) -> bool:
|
||||
"""Sleep up to ``timeout``, waking early for a control socket command.
|
||||
|
||||
True when a command is waiting. Without a socket (Windows, switched
|
||||
off, tests) this is the plain sleep it replaces.
|
||||
off, tests) this is the plain sleep it replaces, unless a plugin's
|
||||
on-demand request is already waiting.
|
||||
"""
|
||||
server = self._control_server
|
||||
wait = getattr(server, 'wait_for_command', None) if server is not None else None
|
||||
if wait is None:
|
||||
if self._plugin_on_demand_pending():
|
||||
return True
|
||||
time.sleep(timeout)
|
||||
return False
|
||||
return bool(wait(timeout))
|
||||
|
||||
def _control_command_pending(self) -> bool:
|
||||
"""A socket command is queued: Vegas checks this every frame."""
|
||||
"""A socket command or a plugin's on-demand request is queued: Vegas
|
||||
checks this every frame."""
|
||||
server = self._control_server
|
||||
return bool(server is not None and server.has_pending)
|
||||
return bool((server is not None and server.has_pending)
|
||||
or self._plugin_on_demand_pending())
|
||||
|
||||
def _wait_frame_interval(self, interval: float, screen: Screen) -> Optional[ScreenPlan]:
|
||||
"""The static screen's sleep between frames, woken by socket commands.
|
||||
@@ -2451,21 +2524,36 @@ class DisplayController:
|
||||
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
|
||||
"""Process one on-demand request, from the mailbox or the control socket.
|
||||
|
||||
A socket command carries ``source: 'socket'``. Only a mailbox request
|
||||
is removed from the mailbox afterwards: a socket command never put
|
||||
anything there, so that would be a disk read and maybe a delete for
|
||||
nothing.
|
||||
A socket command carries ``source: 'socket'``, and a plugin's own
|
||||
request (submit_plugin_on_demand) ``source: 'plugin'``. Only a
|
||||
mailbox request is removed from the mailbox afterwards: the others
|
||||
never put anything there, so that would be a disk read and maybe a
|
||||
delete for nothing.
|
||||
|
||||
A plugin's stop ends only that plugin's own session: a plugin
|
||||
releasing the screen must not end one the user started for
|
||||
another plugin. (A stop through the mailbox ends any session, as it
|
||||
always has.)
|
||||
"""
|
||||
request_id = request.get('request_id')
|
||||
if not request_id:
|
||||
return
|
||||
from_mailbox = request.get('source') != 'socket'
|
||||
source = request.get('source')
|
||||
from_mailbox = source not in ('socket', 'plugin')
|
||||
|
||||
action = request.get('action')
|
||||
|
||||
# For stop requests, always process them (don't check processed_id)
|
||||
# This allows stopping even if the same stop request was sent before
|
||||
if action == 'stop':
|
||||
if source == 'plugin' and not (
|
||||
self.on_demand_active
|
||||
and self.on_demand_plugin_id == request.get('plugin_id')):
|
||||
logger.debug("On-demand stop %s from plugin %s ignored: it does not own "
|
||||
"the screen (on-demand %s, plugin %s)", request_id,
|
||||
request.get('plugin_id'), self.on_demand_status,
|
||||
self.on_demand_plugin_id)
|
||||
return
|
||||
logger.info("Received on-demand stop request %s", request_id)
|
||||
# Always process stop requests, even if same request_id (user might click multiple times)
|
||||
if self.on_demand_active:
|
||||
@@ -2508,8 +2596,9 @@ class DisplayController:
|
||||
self._consume_on_demand_request(request_id)
|
||||
return
|
||||
|
||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s)",
|
||||
request_id, action, request.get('plugin_id'), request.get('mode'))
|
||||
logger.info("Received on-demand request %s: %s (plugin_id=%s, mode=%s, via %s)",
|
||||
request_id, action, request.get('plugin_id'), request.get('mode'),
|
||||
'mailbox' if from_mailbox else source)
|
||||
|
||||
# Mark as processed BEFORE processing (to prevent duplicate processing)
|
||||
self.cache_manager.set('display_on_demand_processed_id', request_id, ttl=3600)
|
||||
@@ -4128,7 +4217,6 @@ class DisplayController:
|
||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||
self._publish_current_mode_state()
|
||||
runner = ScreenRunner(_MODULE_CLOCK, _ScreenHost(self), logger)
|
||||
trimmer = MallocTrimmer()
|
||||
|
||||
while True:
|
||||
# Arms the watchdog after the first frame -- or after the
|
||||
@@ -4136,11 +4224,6 @@ class DisplayController:
|
||||
# it from then on.
|
||||
display_watchdog.watchdog.loop_pass()
|
||||
|
||||
# Between screens, nothing being drawn: every few minutes hand
|
||||
# the memory glibc is holding for freed images back to the OS
|
||||
# (src/malloc_tuning.py). A clock read when none is due.
|
||||
trimmer.maybe_trim()
|
||||
|
||||
# Apply plugin enable/disable edits saved via the web UI. The
|
||||
# config-watcher thread only sets the flag; loading/unloading and
|
||||
# rebuilding available_modes happens here on the render thread so
|
||||
|
||||
+15
-1
@@ -772,8 +772,22 @@ class ControlServer:
|
||||
"""
|
||||
return self._pending.wait(timeout)
|
||||
|
||||
def wake(self) -> None:
|
||||
"""Wake the render thread as a queued command would, with nothing queued.
|
||||
|
||||
For work that reaches the display another way in the same process (a
|
||||
plugin's on-demand request, ``DisplayController.submit_plugin_on_demand``):
|
||||
the render thread returns from :meth:`wait_for_command` and drains,
|
||||
and reads the caller's own queue there. Safe from any thread.
|
||||
"""
|
||||
self._pending.set()
|
||||
|
||||
def drain(self) -> List[QueuedCommand]:
|
||||
"""Every queued command, oldest first. Called from the render thread."""
|
||||
"""Every queued command, oldest first. Called from the render thread.
|
||||
|
||||
Clears the wake flag first, so anything queued (or woken for) while
|
||||
this runs wakes the next wait again.
|
||||
"""
|
||||
commands: List[QueuedCommand] = []
|
||||
self._pending.clear()
|
||||
while True:
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Keep glibc's malloc from holding on to memory the display has freed.
|
||||
|
||||
The display process allocates and frees PIL images and numpy buffers all day
|
||||
from a dozen threads. glibc gives each allocating thread its own malloc arena
|
||||
(up to 8 x CPU count) and returns little of what is freed inside them to the
|
||||
OS, so resident memory climbs for hours while the live data stays flat. Two
|
||||
in-process remedies, both standard library only (ctypes) and both no-ops off
|
||||
Linux/glibc:
|
||||
|
||||
* :func:`cap_arenas` -- ``mallopt(M_ARENA_MAX, 2)``, the in-process twin of the
|
||||
unit's ``Environment=MALLOC_ARENA_MAX=2``. Units installed before that line
|
||||
existed never got it (systemd runs the copy in /etc/systemd/system), so the
|
||||
process applies it itself. Call it before any other thread starts: arenas
|
||||
already created stay. A ``MALLOC_ARENA_MAX`` set in the environment wins.
|
||||
* :class:`MallocTrimmer` -- ``malloc_trim(0)`` at most every few minutes,
|
||||
called from the render loop between screens, where no frame is being drawn.
|
||||
glibc 2.8+ releases free pages from the middle of every arena, not only the
|
||||
top of the main heap.
|
||||
|
||||
Without glibc (macOS, Windows, musl, the dev server on any of them) nothing is
|
||||
loaded and every call returns False.
|
||||
"""
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: glibc's mallopt() parameter number for the arena cap (malloc.h).
|
||||
M_ARENA_MAX = -8
|
||||
|
||||
#: The arena cap applied when the environment does not set one; the same value
|
||||
#: as the unit's ``MALLOC_ARENA_MAX``.
|
||||
DEFAULT_ARENA_MAX = 2
|
||||
|
||||
#: Seconds between malloc_trim() calls. A trim takes about 1-20 ms on a Pi 4,
|
||||
#: so this keeps it far from frame timing while still returning memory long
|
||||
#: before it piles up.
|
||||
TRIM_INTERVAL_SECONDS = 300.0
|
||||
|
||||
_UNLOADED = object()
|
||||
_libc: Any = _UNLOADED
|
||||
|
||||
|
||||
def _load_libc() -> Optional[Any]:
|
||||
"""The process's C library if it is glibc with malloc_trim, else None."""
|
||||
global _libc
|
||||
if _libc is _UNLOADED:
|
||||
_libc = None
|
||||
if sys.platform.startswith('linux'):
|
||||
try:
|
||||
libc = ctypes.CDLL(None)
|
||||
# gnu_get_libc_version is glibc-only: musl also lacks
|
||||
# malloc_trim, but this says why without guessing.
|
||||
libc.gnu_get_libc_version
|
||||
libc.malloc_trim.argtypes = [ctypes.c_size_t]
|
||||
libc.malloc_trim.restype = ctypes.c_int
|
||||
libc.mallopt.argtypes = [ctypes.c_int, ctypes.c_int]
|
||||
libc.mallopt.restype = ctypes.c_int
|
||||
_libc = libc
|
||||
except (OSError, AttributeError, TypeError):
|
||||
logger.debug("glibc malloc controls unavailable", exc_info=True)
|
||||
return _libc
|
||||
|
||||
|
||||
def cap_arenas(max_arenas: int = DEFAULT_ARENA_MAX) -> bool:
|
||||
"""Cap glibc's malloc arenas at ``max_arenas``. True when the cap was set.
|
||||
|
||||
Skipped when ``MALLOC_ARENA_MAX`` is in the environment: glibc has read it
|
||||
already, and an operator who set it chose that value.
|
||||
"""
|
||||
if os.environ.get('MALLOC_ARENA_MAX'):
|
||||
return False
|
||||
libc = _load_libc()
|
||||
if libc is None:
|
||||
return False
|
||||
try:
|
||||
return bool(libc.mallopt(M_ARENA_MAX, int(max_arenas)))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("mallopt(M_ARENA_MAX) failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
class MallocTrimmer:
|
||||
"""Calls ``malloc_trim(0)`` at most once per ``interval`` seconds.
|
||||
|
||||
:meth:`maybe_trim` is meant for an idle point of the render loop; it costs
|
||||
one clock read when no trim is due. The first trim comes one interval
|
||||
after construction, so start-up's allocations have settled.
|
||||
"""
|
||||
|
||||
def __init__(self, interval: float = TRIM_INTERVAL_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._interval = interval
|
||||
self._clock = clock
|
||||
self._libc = _load_libc()
|
||||
self._next = clock() + interval
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._libc is not None
|
||||
|
||||
def maybe_trim(self) -> bool:
|
||||
"""Trim if one is due. True when malloc_trim ran and released memory."""
|
||||
if self._libc is None:
|
||||
return False
|
||||
now = self._clock()
|
||||
if now < self._next:
|
||||
return False
|
||||
self._next = now + self._interval
|
||||
try:
|
||||
released = bool(self._libc.malloc_trim(0))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.debug("malloc_trim failed; not trying again", exc_info=True)
|
||||
self._libc = None
|
||||
return False
|
||||
def _rss():
|
||||
try:
|
||||
with open('/proc/self/statm') as f:
|
||||
return int(f.read().split()[1]) * 4096 // 1024
|
||||
except Exception:
|
||||
return -1
|
||||
logger.info("EXP malloc_trim(0) took %.1f ms, released=%s rss_kb_after=%d",
|
||||
(self._clock() - now) * 1000.0, released, _rss())
|
||||
return released
|
||||
@@ -1070,6 +1070,65 @@ class BasePlugin(ABC):
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def request_on_demand(self, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Take the screen now: show this plugin on demand. Safe from any thread.
|
||||
|
||||
For a plugin that reacts to something outside the rotation -- an MQTT
|
||||
message, a timer, a detection -- and wants the panel for it. The
|
||||
request goes straight to the display in this process and is applied
|
||||
on its render thread within a frame or so, exactly like an on-demand
|
||||
start from the web interface.
|
||||
|
||||
Args:
|
||||
mode: One of this plugin's display modes; None for its first.
|
||||
duration: Seconds to show it before the rotation resumes; None
|
||||
(or zero) for no limit, until end_on_demand() or the user
|
||||
stops it.
|
||||
pinned: Stay on ``mode`` instead of cycling through the
|
||||
plugin's other modes.
|
||||
|
||||
Returns:
|
||||
The request id once the display has queued it, or None when
|
||||
there is no display in this process to ask (the web interface,
|
||||
scripts/check_plugin.py) or its queue is full. A plugin that
|
||||
also runs on cores without this method writes the
|
||||
``display_on_demand_request`` mailbox on None, as before; see
|
||||
"On-demand display" in docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Example::
|
||||
|
||||
if not (hasattr(self, 'request_on_demand')
|
||||
and self.request_on_demand(mode='my_alert', duration=15)):
|
||||
self._write_on_demand_mailbox(...) # older cores
|
||||
"""
|
||||
request = getattr(getattr(self, 'plugin_manager', None), 'request_on_demand', None)
|
||||
if not callable(request):
|
||||
return None
|
||||
request_id = request(self.plugin_id, mode=mode, duration=duration, pinned=pinned)
|
||||
# Only a real id counts: a test's MagicMock manager answers a mock,
|
||||
# which must read as "not taken" so the plugin's fallback runs.
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def end_on_demand(self) -> Optional[str]:
|
||||
"""
|
||||
Give the screen back: end this plugin's on-demand session. Any thread.
|
||||
|
||||
Ends only a session this plugin owns. One the user started for
|
||||
another plugin, or a session that already ended, is left alone. The
|
||||
rotation resumes where it left off.
|
||||
|
||||
Returns:
|
||||
The request id once queued, or None as request_on_demand() does.
|
||||
"""
|
||||
end = getattr(getattr(self, 'plugin_manager', None), 'end_on_demand', None)
|
||||
if not callable(end):
|
||||
return None
|
||||
request_id = end(self.plugin_id)
|
||||
return request_id if isinstance(request_id, str) else None
|
||||
|
||||
def get_vegas_participation(self) -> str:
|
||||
"""
|
||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||
|
||||
@@ -15,6 +15,7 @@ import sys
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
import logging
|
||||
@@ -214,6 +215,9 @@ class PluginManager:
|
||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||
# worker can iterate it without a lock.
|
||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
||||
# Where plugins' on-demand requests go: the display controller's
|
||||
# submit_plugin_on_demand. See set_on_demand_handler().
|
||||
self._on_demand_handler: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||
# with the instance they were meant for. See apply_config_change().
|
||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
||||
@@ -1844,3 +1848,73 @@ class PluginManager:
|
||||
done = sorted(self._completed_updates)
|
||||
self._completed_updates.clear()
|
||||
return done
|
||||
|
||||
# -- on-demand requests from plugins -------------------------------------
|
||||
|
||||
def set_on_demand_handler(
|
||||
self, handler: Optional[Callable[[Dict[str, Any]], bool]]) -> None:
|
||||
"""Route plugins' on-demand requests to ``handler`` (None: nowhere).
|
||||
|
||||
The display controller sets its ``submit_plugin_on_demand`` here
|
||||
before any plugin loads. The handler takes a mailbox-shaped request
|
||||
from any thread, queues it for the render thread and returns True,
|
||||
or False when it could not. A plugin manager with no handler (the
|
||||
web interface's, a test's, scripts/check_plugin.py's) has no screen
|
||||
to give, so request_on_demand() there answers None.
|
||||
"""
|
||||
self._on_demand_handler = handler
|
||||
|
||||
def request_on_demand(self, plugin_id: str, mode: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
pinned: bool = False) -> Optional[str]:
|
||||
"""Ask the display to show ``plugin_id`` now. Safe from any thread.
|
||||
|
||||
BasePlugin.request_on_demand() lands here; see it for the arguments.
|
||||
Returns the request id once the display has queued the request (it
|
||||
is applied on the render thread within a frame or so), or None when
|
||||
this process has no display to ask or its queue is full.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
raise ValueError('plugin_id is required')
|
||||
if mode is not None and (not isinstance(mode, str) or not mode):
|
||||
raise ValueError('mode must be a non-empty string or None')
|
||||
if duration is not None:
|
||||
if isinstance(duration, bool) or not isinstance(duration, (int, float)):
|
||||
raise ValueError('duration must be a number of seconds or None')
|
||||
if not math.isfinite(duration) or duration <= 0:
|
||||
duration = None # the display reads these as "no limit" too
|
||||
else:
|
||||
duration = float(duration)
|
||||
return self._submit_on_demand({
|
||||
'action': 'start', 'plugin_id': plugin_id, 'mode': mode,
|
||||
'duration': duration, 'pinned': bool(pinned)})
|
||||
|
||||
def end_on_demand(self, plugin_id: str) -> Optional[str]:
|
||||
"""Give the screen back, if ``plugin_id``'s on-demand session has it.
|
||||
|
||||
BasePlugin.end_on_demand() lands here. A session the plugin does not
|
||||
own (the user started another plugin from the web interface, say) is
|
||||
left alone. Returns the request id once queued, or None as
|
||||
request_on_demand() does.
|
||||
"""
|
||||
if not isinstance(plugin_id, str) or not plugin_id:
|
||||
raise ValueError('plugin_id is required')
|
||||
return self._submit_on_demand({'action': 'stop', 'plugin_id': plugin_id})
|
||||
|
||||
def _submit_on_demand(self, request: Dict[str, Any]) -> Optional[str]:
|
||||
# __dict__.get: tests build bare managers with PluginManager.__new__.
|
||||
handler = self.__dict__.get('_on_demand_handler')
|
||||
if handler is None:
|
||||
return None
|
||||
request_id = str(uuid.uuid4())
|
||||
request.update({'request_id': request_id, 'timestamp': time.time(),
|
||||
'source': 'plugin'})
|
||||
try:
|
||||
accepted = handler(request)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_rate_limited(
|
||||
"on-demand-handler",
|
||||
"The on-demand request from plugin %s failed: %r",
|
||||
request.get('plugin_id'), exc)
|
||||
return None
|
||||
return request_id if accepted else None
|
||||
|
||||
+3
-1
@@ -60,7 +60,7 @@ server has none.
|
||||
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
|
||||
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
@@ -69,6 +69,8 @@ server has none.
|
||||
| `dom/test_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
|
||||
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
|
||||
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
|
||||
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
|
||||
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
|
||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// The General tab as a page module (static/v3/js/pages/general.js), in a real
|
||||
// DOM (jsdom) with the real server-rendered partial, the real timezone
|
||||
// widget and the real web-login endpoints' answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="general" and the Security section's forms and buttons name
|
||||
// an action
|
||||
// * the timezone picker is drawn once per swap-in, with the saved zone
|
||||
// * after five swaps, each Security action makes exactly one request
|
||||
// * a login change is a write: a swap does not cancel it, its result is
|
||||
// still reported, and nothing is drawn into the page that has gone
|
||||
// * token names reach the page as text
|
||||
// * the settings form itself is left to htmx
|
||||
// * window.webLogin's entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/general');
|
||||
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const generalPage = await load('pages/general.js');
|
||||
|
||||
console.log('\n── General tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
|
||||
const security = /id="web-login-settings"/.test(partial);
|
||||
ok('the server renders the Security section (it has a login store)', security);
|
||||
ok('the real token list answers in the shape the section shows',
|
||||
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
|
||||
ok('the Security forms name their action',
|
||||
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
|
||||
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="general-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('general-content');
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
|
||||
|
||||
let confirmAnswer = true;
|
||||
const confirms = [];
|
||||
window.confirm = m => { confirms.push(m); return confirmAnswer; };
|
||||
const reloads = [];
|
||||
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
let mode = 'ok';
|
||||
let nextId = 1;
|
||||
const requests = [];
|
||||
const pending = [];
|
||||
function fakeFetch(url, init) {
|
||||
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
|
||||
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
|
||||
const id = 'tok' + (nextId++);
|
||||
const name = JSON.parse(init.body).name;
|
||||
const answer = () => respond(201, {
|
||||
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
|
||||
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
|
||||
});
|
||||
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
|
||||
return answer();
|
||||
}
|
||||
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
|
||||
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
|
||||
}
|
||||
if (url === '/api/v3/auth/password') {
|
||||
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('general', generalPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="general"]');
|
||||
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
|
||||
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
|
||||
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
|
||||
const lastNote = () => notes[notes.length - 1] || [];
|
||||
function submit(form) {
|
||||
const event = new window.Event('submit', { bubbles: true, cancelable: true });
|
||||
form.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
const form = action => root().querySelector(`form[data-action="${action}"]`);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── the timezone picker ─────────────────────────────────────────────────
|
||||
const savedZone = $('timezone_container').dataset.timezone;
|
||||
ok('the partial carries the saved timezone', !!savedZone, savedZone);
|
||||
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
|
||||
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
|
||||
$('timezone_data') && $('timezone_data').value);
|
||||
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
|
||||
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
// ── the settings form is htmx's ─────────────────────────────────────────
|
||||
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
|
||||
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
|
||||
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
|
||||
|
||||
if (security) {
|
||||
// ── create a token ────────────────────────────────────────────────────
|
||||
const before = rows().length;
|
||||
const create = form('create-token');
|
||||
create.querySelector('[name="name"]').value = HOSTILE;
|
||||
create.setAttribute('data-dirty', '');
|
||||
ok('Create token is handled by the page', submit(create).defaultPrevented);
|
||||
await tick(20);
|
||||
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
|
||||
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
|
||||
ok('a row is added', rows().length === before + 1, rows().length);
|
||||
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
|
||||
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
|
||||
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
|
||||
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
|
||||
&& !$('web-login-new-token').classList.contains('hidden'));
|
||||
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
|
||||
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
|
||||
|
||||
// ── copy it (plain http: not a secure context, so it is selected) ────
|
||||
root().querySelector('button[data-action="copy-token"]').click();
|
||||
ok('Copy selects the token where the clipboard API is unavailable',
|
||||
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
|
||||
|
||||
// ── revoke it (the row drawn by the page, so delegation covers it) ───
|
||||
confirmAnswer = false;
|
||||
const added = rows()[rows().length - 1];
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
|
||||
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
|
||||
confirmAnswer = true;
|
||||
added.querySelector('button[data-action="revoke-token"]').click();
|
||||
await tick(20);
|
||||
ok('Revoke sends one DELETE for that token',
|
||||
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
|
||||
calls('DELETE', '/api/v3/auth/tokens/'));
|
||||
ok('...and removes its row', rows().length === before, rows().length);
|
||||
|
||||
// ── the password ──────────────────────────────────────────────────────
|
||||
const pw = form('set-password');
|
||||
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
|
||||
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
|
||||
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
|
||||
submit(pw);
|
||||
await tick(20);
|
||||
const sent = calls('POST', '/api/v3/auth/password');
|
||||
ok('a matching password is sent once', sent.length === 1, sent.length);
|
||||
ok('...with the current password only when the form has one',
|
||||
sent[0] && sent[0].body.new_password === 'correct horse battery'
|
||||
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
|
||||
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
|
||||
&& reloads[0][2] === '#general-content', reloads);
|
||||
|
||||
// ── refused, network failure, login redirect ──────────────────────────
|
||||
mode = 'refuse';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
|
||||
mode = 'network';
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
|
||||
mode = 'login';
|
||||
const quiet = notes.length;
|
||||
submit(form('create-token'));
|
||||
await tick(20);
|
||||
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
|
||||
|
||||
// ── a write survives a swap ───────────────────────────────────────────
|
||||
mode = 'hang';
|
||||
await swap();
|
||||
const rowsBefore = rows().length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Late';
|
||||
submit(form('create-token'));
|
||||
await tick(5);
|
||||
await swap();
|
||||
pending.shift()();
|
||||
await tick(20);
|
||||
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
|
||||
ok('...and draws nothing into the new page', rows().length === rowsBefore
|
||||
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
|
||||
mode = 'ok';
|
||||
|
||||
// ── window.webLogin ────────────────────────────────────────────────────
|
||||
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
|
||||
form('create-token').querySelector('[name="name"]').value = 'Alias';
|
||||
await generalPage.webLogin.createToken(form('create-token'));
|
||||
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
|
||||
ok('webLogin has the five old methods',
|
||||
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
|
||||
}
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,167 @@
|
||||
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial and the real
|
||||
// widget registry and schedule-picker widget. Built like test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="schedule" and carries both saved schedules as JSON
|
||||
// * both pickers are drawn once per swap-in, from the saved config, however
|
||||
// many swaps came first
|
||||
// * each form's save is reported in exactly one notification (the forms
|
||||
// are marked data-reports-result so app.js stays quiet)
|
||||
// * the dim brightness label follows the slider
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting draws nothing
|
||||
// * the old globals' entry points still work
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/schedule');
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const schedulePage = await load('pages/schedule.js');
|
||||
|
||||
console.log('\n── Schedule tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|input|change|submit)=/i.test(partial));
|
||||
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
|
||||
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
|
||||
ok('both forms are marked data-reports-result',
|
||||
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="schedule-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('schedule-content');
|
||||
// base.html defines LEDEscape (app-early.js) before any tab loads; the
|
||||
// widget escapes with it.
|
||||
require('../led_escape').install(window);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
|
||||
const widgets = window.LEDMatrixWidgets;
|
||||
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
|
||||
|
||||
const notes = [];
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: null, notify: (m, t) => notes.push([m, t]) },
|
||||
});
|
||||
registry.register('schedule', schedulePage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="schedule"]');
|
||||
const saved = key => JSON.parse(root().dataset[key]);
|
||||
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
|
||||
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function answer(formId, xhr) {
|
||||
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
|
||||
}
|
||||
|
||||
await registry.start();
|
||||
await tick(20);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
|
||||
const schedule = saved('scheduleConfig');
|
||||
const dim = saved('dimScheduleConfig');
|
||||
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
|
||||
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
|
||||
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
|
||||
[$('schedule_mode_value').value, schedule.mode]);
|
||||
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
|
||||
[$('dim_schedule_mode_value').value, dim.mode]);
|
||||
ok('the dim picker shows the saved start time',
|
||||
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
|
||||
|
||||
// ── repeated swaps ──────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
|
||||
|
||||
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
|
||||
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
|
||||
ok('a schedule save is reported once', notes.length === 1, notes);
|
||||
ok('...with the server message and status',
|
||||
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
|
||||
answer('dim_schedule_form', okXhr({ status: 'success' }));
|
||||
ok('a dim schedule save without a message says so',
|
||||
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
|
||||
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
|
||||
ok('a refused save without a message says so',
|
||||
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
|
||||
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
|
||||
ok('a non-JSON answer is an error',
|
||||
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
|
||||
answer('schedule_form', { status: 200, responseText: 'null' });
|
||||
ok('a JSON null answer is an error, not a crash',
|
||||
notes.length === 5 && notes[4][1] === 'error', notes[4]);
|
||||
// An htmx request from elsewhere on the page (outside both forms) is not a save.
|
||||
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
|
||||
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
|
||||
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
|
||||
|
||||
// ── the brightness label ────────────────────────────────────────────────
|
||||
$('dim_brightness').value = '42';
|
||||
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
|
||||
$('dim_brightness_display').textContent);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(150);
|
||||
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
|
||||
|
||||
delete window.LEDMatrixWidgets;
|
||||
await swap();
|
||||
const kept = root();
|
||||
await swap('<p>another tab</p>');
|
||||
window.LEDMatrixWidgets = widgets;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting draws nothing',
|
||||
kept.querySelectorAll('.schedule-picker-widget').length === 0);
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
|
||||
// ── the old globals ─────────────────────────────────────────────────────
|
||||
await swap();
|
||||
const before = notes.length;
|
||||
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
|
||||
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
|
||||
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
|
||||
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
ok('no DOM errors', errs.length === 0, errs);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
+2
-1
@@ -36,7 +36,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js'];
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -3,32 +3,24 @@
|
||||
// app.js marks a form data-dirty on any input in it and clears the mark only
|
||||
// after a successful htmx request; its beforeunload handler then asks "Leave
|
||||
// site?" while any visible form is still dirty. The token form posts with
|
||||
// fetch (window.webLogin.createToken in partials/general.html), so after a
|
||||
// token was created the form stayed dirty and reloading the page while the
|
||||
// General tab was open prompted about changes that had been saved.
|
||||
// fetch (createToken in static/v3/js/pages/general.js), so after a token was
|
||||
// created the form stayed dirty and reloading the page while the General tab
|
||||
// was open prompted about changes that had been saved.
|
||||
//
|
||||
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
|
||||
// and no server needed.
|
||||
// Imports the shipped page module and runs createToken with a fake fetch and
|
||||
// DOM -- no jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html');
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
const load = f => import(pathToFileURL(path.join(JS, f)).href);
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function webLoginScript() {
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
||||
const found = scripts.find(s => s.includes('window.webLogin = {'));
|
||||
if (!found) throw new Error('webLogin script not found in general.html');
|
||||
return found;
|
||||
}
|
||||
|
||||
function el() {
|
||||
const classes = new Set(['hidden']);
|
||||
return {
|
||||
@@ -38,29 +30,28 @@ function el() {
|
||||
};
|
||||
}
|
||||
|
||||
function load(answer) {
|
||||
async function setup(answer) {
|
||||
const { createApi } = await load('core/api.js');
|
||||
const general = await load('pages/general.js');
|
||||
const elements = {
|
||||
'web-login-tokens': el(),
|
||||
'web-login-new-token-value': el(),
|
||||
'web-login-new-token': el(),
|
||||
'#web-login-tokens': el(),
|
||||
'#web-login-new-token-value': el(),
|
||||
'#web-login-new-token': el(),
|
||||
};
|
||||
const notes = [];
|
||||
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
|
||||
const context = {
|
||||
window, console,
|
||||
document: {
|
||||
getElementById: id => elements[id] || null,
|
||||
createElement: () => el(),
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
fetch: () => Promise.resolve({
|
||||
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||
json: () => Promise.resolve(answer.body),
|
||||
}),
|
||||
const doc = { createElement: () => el(), defaultView: { confirm: () => true } };
|
||||
const root = { ownerDocument: doc, querySelector: sel => elements[sel] || null, querySelectorAll: () => [] };
|
||||
const fetch = () => Promise.resolve({
|
||||
ok: answer.ok, status: answer.ok ? 200 : 400,
|
||||
headers: { get: () => null },
|
||||
text: () => Promise.resolve(JSON.stringify(answer.body)),
|
||||
});
|
||||
const ctx = {
|
||||
root, state: {}, signal: { aborted: false },
|
||||
api: createApi({ fetch }),
|
||||
notify: (m, t) => notes.push([m, t]),
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(webLoginScript(), context);
|
||||
return { webLogin: context.window.webLogin, elements, notes };
|
||||
return { general, ctx, elements, notes };
|
||||
}
|
||||
|
||||
function dirtyForm() {
|
||||
@@ -74,29 +65,25 @@ function dirtyForm() {
|
||||
};
|
||||
}
|
||||
|
||||
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
||||
|
||||
(async () => {
|
||||
console.log('\n── General tab: API token form ──');
|
||||
|
||||
{
|
||||
const t = load({ ok: true, body: {
|
||||
const t = await setup({ ok: true, body: {
|
||||
status: 'success', message: 'Token created',
|
||||
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
||||
} });
|
||||
const form = dirtyForm();
|
||||
t.webLogin.createToken(form);
|
||||
await flush();
|
||||
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('the new token is shown', t.elements['#web-login-new-token-value'].textContent === 'lmx_secret');
|
||||
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
||||
!form.hasAttribute('data-dirty'));
|
||||
}
|
||||
|
||||
{
|
||||
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||
const t = await setup({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
||||
const form = dirtyForm();
|
||||
t.webLogin.createToken(form);
|
||||
await flush();
|
||||
await t.general.createToken(t.ctx, form);
|
||||
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
||||
t.notes);
|
||||
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
||||
|
||||
@@ -19,7 +19,7 @@ const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function load() {
|
||||
function load(notes) {
|
||||
const handlers = {};
|
||||
const listen = (target) => (type, fn) => { (handlers[target + ':' + type] ||= []).push(fn); };
|
||||
const banner = { style: { display: 'none' } };
|
||||
@@ -43,14 +43,18 @@ function load() {
|
||||
removeItem: (k) => { delete store[k]; },
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
},
|
||||
showNotification: () => {},
|
||||
showNotification: (m, t) => { if (notes) notes.push([m, t]); },
|
||||
setTimeout: () => 0,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(V3, 'app.js'), 'utf8'), context);
|
||||
const afterRequest = (handlers['body:htmx:afterRequest'] || [])[0];
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false }) => {
|
||||
const elt = { closest: () => (reportsItself ? {} : null) };
|
||||
// `marks`: the attribute selectors the requesting element (or its form)
|
||||
// matches, for app.js's elt.closest(<selector list>).
|
||||
const fire = ({ status = 200, body, path: reqPath = '/api/v3/anything', reportsItself = false, marks = [] }) => {
|
||||
const elt = {
|
||||
closest: (sel) => (reportsItself || sel.split(',').some(s => marks.includes(s.trim())) ? {} : null),
|
||||
};
|
||||
afterRequest({
|
||||
target: { closest: () => null },
|
||||
detail: {
|
||||
@@ -100,5 +104,20 @@ console.log('\nhtmx after-request follows the flag, not the URL');
|
||||
ok('a flagged answer raises it, even from a form that reports itself', t.banner.style.display === 'block');
|
||||
}
|
||||
|
||||
console.log('\nthe server message toast');
|
||||
{
|
||||
const notes = [];
|
||||
const t = load(notes);
|
||||
const answer = { status: 'success', message: 'Schedule saved' };
|
||||
t.fire({ body: answer });
|
||||
ok('a plain htmx request shows the server message', notes.length === 1 && notes[0][0] === 'Schedule saved', notes);
|
||||
t.fire({ body: answer, marks: ['[hx-on\\:htmx\\:after-request]'] });
|
||||
ok('a form with its own hx-on after-request handler does not', notes.length === 1, notes);
|
||||
// Page modules (js/pages/schedule.js) report a form's save from a listener
|
||||
// and mark the form data-reports-result instead of an hx-on attribute.
|
||||
t.fire({ body: answer, marks: ['[data-reports-result]'] });
|
||||
ok('nor does a form a page module reports for (data-reports-result)', notes.length === 1, notes);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
|
||||
and end_on_demand().
|
||||
|
||||
A plugin running in the display process used to write the
|
||||
``display_on_demand_request`` mailbox, which the display reads once a second
|
||||
while the control socket is up. These tests pin the way in that replaces it:
|
||||
|
||||
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
|
||||
which only queues, from any thread;
|
||||
* the render thread applies the queue where it applies socket commands,
|
||||
through the mailbox's own handler, without the mailbox's read floor, and
|
||||
woken by the control socket when it is up;
|
||||
* a plugin's stop ends only its own session;
|
||||
* no display to ask (the web interface's plugin manager, an old core's
|
||||
plugin manager) answers None, which is a plugin's cue to fall back to the
|
||||
mailbox;
|
||||
* the mailbox still works for plugins that write it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ipc.server import ControlServer
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
|
||||
def _plugin(plugin_id, manager):
|
||||
plugin = _Plugin.__new__(_Plugin)
|
||||
plugin.plugin_id = plugin_id
|
||||
plugin.plugin_manager = manager
|
||||
return plugin
|
||||
|
||||
|
||||
def _manager(handler=None):
|
||||
manager = PluginManager.__new__(PluginManager)
|
||||
manager.logger = logging.getLogger('test.plugin_on_demand')
|
||||
if handler is not None:
|
||||
manager.set_on_demand_handler(handler)
|
||||
return manager
|
||||
|
||||
|
||||
class _WakeServer:
|
||||
"""The parts of ControlServer the controller uses, with no socket."""
|
||||
|
||||
def __init__(self):
|
||||
self.woken = 0
|
||||
self.has_pending = False
|
||||
|
||||
def wake(self):
|
||||
self.woken += 1
|
||||
self.has_pending = True
|
||||
|
||||
def drain(self):
|
||||
self.has_pending = False
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
c_ = test_display_controller
|
||||
c_.on_demand_active = False
|
||||
c_.on_demand_request_id = None
|
||||
c_._last_on_demand_poll = None
|
||||
mailbox = {'value': None}
|
||||
|
||||
def fake_get(key, *a, **kw):
|
||||
if key == 'display_on_demand_request':
|
||||
return mailbox['value']
|
||||
return None
|
||||
|
||||
c_.cache_manager.get = MagicMock(side_effect=fake_get)
|
||||
c_.cache_manager.set = MagicMock()
|
||||
c_.cache_manager.delete = MagicMock()
|
||||
c_._activate_on_demand = MagicMock()
|
||||
c_.mailbox = mailbox
|
||||
return c_
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wired(controller):
|
||||
"""A real PluginManager wired to the controller, as __init__ wires it."""
|
||||
manager = _manager(controller.submit_plugin_on_demand)
|
||||
return controller, manager
|
||||
|
||||
|
||||
class TestWiring:
|
||||
def test_the_controller_wires_its_plugin_manager(self, controller):
|
||||
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
|
||||
controller.submit_plugin_on_demand)
|
||||
|
||||
def test_a_start_reaches_the_mailbox_handler(self, wired):
|
||||
controller, manager = wired
|
||||
rid = _plugin('pomodoro-timer', manager).request_on_demand(
|
||||
mode='pomodoro', duration=30, pinned=True)
|
||||
assert isinstance(rid, str) and rid
|
||||
controller._activate_on_demand.assert_not_called() # only queued
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
request = controller._activate_on_demand.call_args.args[0]
|
||||
assert request['request_id'] == rid
|
||||
assert request['action'] == 'start'
|
||||
assert request['plugin_id'] == 'pomodoro-timer'
|
||||
assert request['mode'] == 'pomodoro'
|
||||
assert request['duration'] == 30.0 and request['pinned'] is True
|
||||
assert request['source'] == 'plugin'
|
||||
assert controller.on_demand_request_id == rid
|
||||
|
||||
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
|
||||
controller, manager = wired
|
||||
_plugin('on-air', manager).request_on_demand(mode='on_air')
|
||||
controller._drain_control_commands()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
|
||||
if call.args[0] == 'display_on_demand_request']
|
||||
assert mailbox_reads == []
|
||||
controller.cache_manager.delete.assert_not_called()
|
||||
|
||||
def test_requests_apply_in_order(self, wired):
|
||||
controller, manager = wired
|
||||
seen = []
|
||||
controller._activate_on_demand = MagicMock(
|
||||
side_effect=lambda r: seen.append(r['mode']))
|
||||
plugin = _plugin('p', manager)
|
||||
for mode in ('a', 'b', 'c'):
|
||||
plugin.request_on_demand(mode=mode)
|
||||
controller._poll_on_demand_requests()
|
||||
assert seen == ['a', 'b', 'c']
|
||||
|
||||
def test_the_mailbox_still_works_for_older_plugins(self, wired):
|
||||
controller, manager = wired
|
||||
controller.mailbox['value'] = {'request_id': 'mb', 'action': 'start',
|
||||
'plugin_id': 'birdnet-go'}
|
||||
_plugin('on-air', manager).request_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
ids = [call.args[0]['request_id'] for call in controller._activate_on_demand.call_args_list]
|
||||
assert 'mb' in ids and len(ids) == 2
|
||||
|
||||
def test_a_failing_request_is_contained(self, wired):
|
||||
controller, manager = wired
|
||||
calls = []
|
||||
|
||||
def activate(request):
|
||||
calls.append(request['mode'])
|
||||
if request['mode'] == 'bad':
|
||||
raise RuntimeError('plugin exploded')
|
||||
|
||||
controller._activate_on_demand = MagicMock(side_effect=activate)
|
||||
plugin = _plugin('p', manager)
|
||||
plugin.request_on_demand(mode='bad')
|
||||
plugin.request_on_demand(mode='good')
|
||||
controller._poll_on_demand_requests()
|
||||
assert calls == ['bad', 'good']
|
||||
|
||||
|
||||
class TestPromptness:
|
||||
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = None
|
||||
controller._service_pending_changes()
|
||||
_plugin('p', manager).request_on_demand()
|
||||
controller._service_pending_changes() # well inside the 0.25 s floor
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_a_plugin_request_skips_the_mailbox_floor(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = _WakeServer()
|
||||
controller._poll_on_demand_requests() # sets the 1 s mailbox floor
|
||||
_plugin('p', manager).request_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
|
||||
def test_it_wakes_the_control_socket_wait(self, wired):
|
||||
controller, manager = wired
|
||||
server = ControlServer('/nonexistent/control.sock') # never started
|
||||
controller._control_server = server
|
||||
assert not server.wait_for_command(0)
|
||||
_plugin('p', manager).request_on_demand()
|
||||
assert server.has_pending
|
||||
assert controller._wait_for_control(5.0) is True # returns at once
|
||||
assert controller._control_command_pending()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
assert not server.has_pending
|
||||
assert not controller._control_command_pending()
|
||||
|
||||
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
|
||||
controller, manager = wired
|
||||
controller._control_server = None
|
||||
_plugin('p', manager).request_on_demand()
|
||||
started = time.monotonic()
|
||||
assert controller._wait_for_control(5.0) is True
|
||||
assert time.monotonic() - started < 1.0
|
||||
assert controller._control_command_pending()
|
||||
|
||||
def test_nothing_waiting_keeps_the_floor(self, controller):
|
||||
controller._control_server = None
|
||||
controller._poll_on_demand_requests = MagicMock()
|
||||
controller._service_pending_changes()
|
||||
controller._service_pending_changes()
|
||||
assert controller._poll_on_demand_requests.call_count == 1
|
||||
|
||||
|
||||
class TestThreads:
|
||||
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
|
||||
controller, manager = wired
|
||||
seen = []
|
||||
controller._activate_on_demand = MagicMock(
|
||||
side_effect=lambda r: seen.append(r['mode']))
|
||||
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
|
||||
threads_n, each = 8, 50
|
||||
barrier = threading.Barrier(threads_n)
|
||||
|
||||
def ask(n):
|
||||
plugin = _plugin(f'p{n}', manager)
|
||||
barrier.wait()
|
||||
for i in range(each):
|
||||
assert plugin.request_on_demand(mode=f'{n}:{i}')
|
||||
|
||||
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
# Drain while they ask, as the render thread would.
|
||||
while any(t.is_alive() for t in threads):
|
||||
controller._drain_control_commands()
|
||||
for t in threads:
|
||||
t.join()
|
||||
controller._drain_control_commands()
|
||||
assert len(seen) == threads_n * each
|
||||
for n in range(threads_n):
|
||||
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
|
||||
assert mine == list(range(each))
|
||||
|
||||
def test_a_full_queue_refuses(self, wired, caplog):
|
||||
controller, manager = wired
|
||||
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
|
||||
plugin = _plugin('p', manager)
|
||||
assert plugin.request_on_demand()
|
||||
assert plugin.request_on_demand()
|
||||
assert plugin.request_on_demand() is None
|
||||
assert 'queue full' in caplog.text
|
||||
controller._poll_on_demand_requests()
|
||||
assert controller._activate_on_demand.call_count == 2
|
||||
assert plugin.request_on_demand() # room again
|
||||
|
||||
|
||||
class TestStop:
|
||||
def test_a_plugin_ends_its_own_session(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'on-air'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
assert _plugin('on-air', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
controller.cache_manager.delete.assert_not_called()
|
||||
|
||||
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'clock' # the user started it
|
||||
controller.on_demand_request_id = 'user'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
_plugin('pomodoro-timer', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_not_called()
|
||||
assert controller.on_demand_request_id == 'user'
|
||||
|
||||
def test_a_stop_with_no_session_does_nothing(self, wired):
|
||||
controller, manager = wired
|
||||
controller.on_demand_status = 'error'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
_plugin('on-air', manager).end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_not_called()
|
||||
|
||||
def test_a_mailbox_stop_still_ends_any_session(self, wired):
|
||||
controller, _ = wired
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = 'clock'
|
||||
controller._clear_on_demand = MagicMock()
|
||||
controller.mailbox['value'] = {'request_id': 's', 'action': 'stop',
|
||||
'plugin_id': 'on-air'}
|
||||
controller._poll_on_demand_requests()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
|
||||
controller, manager = wired
|
||||
|
||||
def activate(request):
|
||||
controller.on_demand_active = True
|
||||
controller.on_demand_plugin_id = request['plugin_id']
|
||||
|
||||
controller._activate_on_demand = MagicMock(side_effect=activate)
|
||||
controller._clear_on_demand = MagicMock()
|
||||
plugin = _plugin('pomodoro-timer', manager)
|
||||
plugin.request_on_demand(mode='pomodoro', pinned=True)
|
||||
plugin.end_on_demand()
|
||||
controller._poll_on_demand_requests()
|
||||
controller._activate_on_demand.assert_called_once()
|
||||
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
||||
|
||||
|
||||
class TestNoDisplay:
|
||||
"""None is a plugin's cue to write the mailbox instead."""
|
||||
|
||||
def test_a_manager_with_no_handler_answers_none(self):
|
||||
plugin = _plugin('p', _manager())
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_no_plugin_manager_answers_none(self):
|
||||
plugin = _plugin('p', None)
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_an_old_cores_plugin_manager_answers_none(self):
|
||||
class OldManager:
|
||||
plugin_manifests = {}
|
||||
|
||||
plugin = _plugin('p', OldManager())
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_a_handler_that_raises_answers_none(self):
|
||||
def broken(request):
|
||||
raise RuntimeError('boom')
|
||||
|
||||
plugin = _plugin('p', _manager(broken))
|
||||
assert plugin.request_on_demand() is None
|
||||
assert plugin.end_on_demand() is None
|
||||
|
||||
def test_a_handler_that_refuses_answers_none(self):
|
||||
plugin = _plugin('p', _manager(lambda request: False))
|
||||
assert plugin.request_on_demand() is None
|
||||
|
||||
def test_a_controller_built_without_init_refuses(self):
|
||||
from src.display_controller import DisplayController
|
||||
bare = DisplayController.__new__(DisplayController)
|
||||
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
|
||||
assert bare._plugin_on_demand_pending() is False
|
||||
bare._drain_plugin_on_demand() # nothing to do, no error
|
||||
|
||||
def test_the_feature_detection_pattern(self):
|
||||
"""The hasattr pattern from docs/PLUGIN_API_REFERENCE.md."""
|
||||
writes = []
|
||||
|
||||
class OldCorePlugin: # an older core's BasePlugin has no such method
|
||||
pass
|
||||
|
||||
for plugin, expect_mailbox in ((OldCorePlugin(), True),
|
||||
(_plugin('p', _manager()), True),
|
||||
(_plugin('p', _manager(lambda r: True)), False)):
|
||||
writes.clear()
|
||||
if not (hasattr(plugin, 'request_on_demand')
|
||||
and plugin.request_on_demand(mode='m')):
|
||||
writes.append('mailbox')
|
||||
assert (writes == ['mailbox']) is expect_mailbox
|
||||
|
||||
|
||||
class TestArguments:
|
||||
def test_the_manager_shapes_the_request(self):
|
||||
got = []
|
||||
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
|
||||
plugin.request_on_demand()
|
||||
plugin.end_on_demand()
|
||||
start, stop = got
|
||||
assert start['plugin_id'] == 'p' and start['mode'] is None
|
||||
assert start['duration'] is None and start['pinned'] is False
|
||||
assert start['source'] == 'plugin' and start['timestamp'] > 0
|
||||
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
|
||||
'timestamp': stop['timestamp'], 'source': 'plugin'}
|
||||
assert start['request_id'] != stop['request_id']
|
||||
|
||||
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
|
||||
def test_no_positive_duration_means_no_limit(self, duration):
|
||||
got = []
|
||||
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
|
||||
duration=duration)
|
||||
assert got[0]['duration'] is None
|
||||
|
||||
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
|
||||
{'duration': True}])
|
||||
def test_bad_arguments_raise(self, kwargs):
|
||||
plugin = _plugin('p', _manager(lambda r: True))
|
||||
with pytest.raises(ValueError):
|
||||
plugin.request_on_demand(**kwargs)
|
||||
|
||||
|
||||
class TestMockManagers:
|
||||
def test_a_magicmock_manager_reads_as_not_taken(self):
|
||||
"""A plugin's test with a MagicMock manager keeps its mailbox path."""
|
||||
plugin = _plugin('p', MagicMock())
|
||||
assert plugin.request_on_demand(mode='m') is None
|
||||
assert plugin.end_on_demand() is None
|
||||
plugin.plugin_manager.request_on_demand.assert_called_once_with(
|
||||
'p', mode='m', duration=None, pinned=False)
|
||||
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
|
||||
|
||||
def test_a_mocked_id_is_passed_through(self):
|
||||
manager = MagicMock()
|
||||
manager.request_on_demand.return_value = 'rid'
|
||||
manager.end_on_demand.return_value = 'rid2'
|
||||
plugin = _plugin('p', manager)
|
||||
assert plugin.request_on_demand() == 'rid'
|
||||
assert plugin.end_on_demand() == 'rid2'
|
||||
@@ -43,7 +43,7 @@ def _url(path):
|
||||
def test_the_module_directories_hold_modules():
|
||||
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js",
|
||||
"durations.js", "operation-history.js", "raw-json.js",
|
||||
"backup-restore.js"}
|
||||
"backup-restore.js", "schedule.js", "general.js"}
|
||||
for directory in MODULE_DIRS:
|
||||
# node needs this to import them in the JS tests; browsers ignore it.
|
||||
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
|
||||
@@ -104,6 +104,8 @@ CONVERTED = {
|
||||
"operation-history": ("operation_history.html", "/partials/operation-history"),
|
||||
"raw-json": ("raw_json.html", "/partials/raw-json"),
|
||||
"backup-restore": ("backup_restore.html", "/partials/backup-restore"),
|
||||
"schedule": ("schedule.html", "/partials/schedule"),
|
||||
"general": ("general.html", "/partials/general"),
|
||||
}
|
||||
|
||||
# Old window.* names that moved into a page module. Each stays as a
|
||||
@@ -114,6 +116,8 @@ ALIASES = {
|
||||
"saveMainConfig", "saveSecretsConfig"],
|
||||
"backup-restore": ["exportBackup", "loadBackupList", "validateRestoreFile",
|
||||
"clearRestore", "runRestore"],
|
||||
"schedule": ["handleScheduleResponse", "handleDimScheduleResponse"],
|
||||
"general": ["webLogin"],
|
||||
}
|
||||
|
||||
|
||||
@@ -155,12 +159,15 @@ def test_moved_globals_stay_as_aliases(name):
|
||||
assert f"'{global_name}'" in boot, f"boot.js does not alias {global_name}"
|
||||
assert re.search(rf"^export (?:function|const) {global_name}\b", module, re.M), (
|
||||
f"pages/{name}.js does not export {global_name}")
|
||||
# No template defines them any more.
|
||||
# No template defines them any more, or calls them (an inline handler
|
||||
# would reach the page only through the deprecated alias).
|
||||
for partial in PARTIALS.glob("*.html"):
|
||||
text = partial.read_text(encoding="utf-8")
|
||||
for global_name in ALIASES[name]:
|
||||
assert f"window.{global_name} =" not in text, partial.name
|
||||
assert f"function {global_name}(" not in text, partial.name
|
||||
assert not re.search(rf"\b{global_name}\b", text), (
|
||||
f"{partial.name} still names {global_name}")
|
||||
|
||||
|
||||
def test_every_registered_page_has_its_module_and_partial():
|
||||
@@ -180,3 +187,32 @@ def test_converted_partials_carry_no_inline_script():
|
||||
if "data-page=" in text:
|
||||
assert "<script" not in text.lower(), (
|
||||
f"{partial.name} is a page module now; its code belongs in js/pages/")
|
||||
|
||||
|
||||
def test_the_schedule_page_reads_its_config_back_intact():
|
||||
"""schedule.html hands both saved schedules to pages/schedule.js as JSON in
|
||||
single-quoted data attributes; a value with quotes or markup must neither
|
||||
end the attribute nor change on the way."""
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import web_interface.app as web_app
|
||||
from flask import render_template
|
||||
|
||||
hostile = {"mode": "per-day", "start_time": "07:00", "note": "it's a \"<b>\" & '</div>"}
|
||||
|
||||
class Root(HTMLParser):
|
||||
attrs = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if dict(attrs).get("data-page") == "schedule":
|
||||
self.attrs = dict(attrs)
|
||||
|
||||
with web_app.app.test_request_context():
|
||||
html = render_template("v3/partials/schedule.html", schedule_config=hostile,
|
||||
dim_schedule_config=None, normal_brightness=90)
|
||||
parser = Root()
|
||||
parser.feed(html)
|
||||
assert parser.attrs is not None
|
||||
assert json.loads(parser.attrs["data-schedule-config"]) == hostile
|
||||
# A missing dim schedule arrives as null; the page treats that as {}.
|
||||
assert json.loads(parser.attrs["data-dim-schedule-config"]) is None
|
||||
|
||||
@@ -57,11 +57,13 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Show the server's message, unless the element that made the request
|
||||
// (or its form) has its own after-request handler: every such handler in
|
||||
// the templates reports the result itself, and this used to repeat it,
|
||||
// so each save showed two toasts.
|
||||
// so each save showed two toasts. A form whose page module reports the
|
||||
// result instead (a js/pages/*.js listener for htmx:afterRequest) is
|
||||
// marked data-reports-result.
|
||||
const response = event.detail.xhr;
|
||||
const elt = event.detail.elt;
|
||||
const reportsItself = elt && elt.closest &&
|
||||
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
|
||||
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request], [data-reports-result]');
|
||||
if (!reportsItself && response && response.responseText) {
|
||||
try {
|
||||
const data = JSON.parse(response.responseText);
|
||||
|
||||
@@ -43,18 +43,33 @@ const pages = {
|
||||
'operation-history': page(function() { return import('../pages/operation-history.js'); }),
|
||||
'raw-json': page(function() { return import('../pages/raw-json.js'); }),
|
||||
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
|
||||
'schedule': page(function() { return import('../pages/schedule.js'); }),
|
||||
'general': page(function() { return import('../pages/general.js'); }),
|
||||
};
|
||||
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
|
||||
|
||||
/** Keep window[name] working: forward to the page module's export of the same name. */
|
||||
function alias(pageName, name, replacement) {
|
||||
/** A function that calls `pick(module)` with its arguments, loading the page's module if needed. */
|
||||
function forward(pageName, pick) {
|
||||
const loader = pages[pageName];
|
||||
facade.deprecate(name, function() {
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const module = loader.loaded();
|
||||
if (module) return module[name].apply(null, args);
|
||||
return loader().then(function(loaded) { return loaded[name].apply(null, args); });
|
||||
}, replacement);
|
||||
if (module) return pick(module).apply(null, args);
|
||||
return loader().then(function(loaded) { return pick(loaded).apply(null, args); });
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep window[name] working: forward to the page module's export of the same name. */
|
||||
function alias(pageName, name, replacement) {
|
||||
facade.deprecate(name, forward(pageName, function(module) { return module[name]; }), replacement);
|
||||
}
|
||||
|
||||
/** The same for an old global object of functions: each method forwards to the export's method. */
|
||||
function aliasObject(pageName, name, methods, replacement) {
|
||||
const target = Object.fromEntries(methods.map(function(method) {
|
||||
return [method, forward(pageName, function(module) { return module[name][method]; })];
|
||||
}));
|
||||
facade.deprecate(name, Object.freeze(target), replacement);
|
||||
}
|
||||
|
||||
// Old globals the converted pages used to define.
|
||||
@@ -65,5 +80,10 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
|
||||
['exportBackup', 'loadBackupList', 'validateRestoreFile', 'clearRestore', 'runRestore'].forEach(function(name) {
|
||||
alias('backup-restore', name, "the Backup & Restore tab's buttons");
|
||||
});
|
||||
['handleScheduleResponse', 'handleDimScheduleResponse'].forEach(function(name) {
|
||||
alias('schedule', name, "the Schedule tab's own save handling");
|
||||
});
|
||||
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
|
||||
"the General tab's Security buttons");
|
||||
|
||||
registry.start();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* pages/general.js -- the General tab (templates/v3/partials/general.html).
|
||||
*
|
||||
* The settings form stays plain htmx (hx-post to /api/v3/config/main; its
|
||||
* hx-on calls the shared showSaveResult). This module:
|
||||
* - draws the timezone-selector widget into #timezone_container, with the
|
||||
* saved zone from its data-timezone attribute
|
||||
* - runs the optional Security section (web login, #683): three forms and
|
||||
* two kinds of button, all marked data-action and handled by one
|
||||
* delegated submit and one delegated click listener on the page root, so
|
||||
* a token row added later needs no listener of its own
|
||||
*
|
||||
* Login changes are writes: they are not cancelled when the page is swapped
|
||||
* away, so the server always finishes and the result is still reported, but
|
||||
* nothing is drawn into a page that has gone. Token names reach the page as
|
||||
* text.
|
||||
*
|
||||
* window.webLogin (setPassword, disable, createToken, copyToken, revoke) is a
|
||||
* deprecated alias made in core/boot.js; it forwards to the webLogin export
|
||||
* at the bottom of this file.
|
||||
*/
|
||||
|
||||
const API = '/api/v3/auth';
|
||||
const RETRY_MS = 50;
|
||||
const MAX_TRIES = 200;
|
||||
|
||||
// The mounted page, for the deprecated alias (boot.js).
|
||||
let active = null;
|
||||
|
||||
function windowOf(ctx) {
|
||||
return ctx.root.ownerDocument.defaultView;
|
||||
}
|
||||
|
||||
function find(ctx, id) {
|
||||
return ctx.root.querySelector('#' + id);
|
||||
}
|
||||
|
||||
function field(form, name) {
|
||||
const el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A login request. Resolves to {ok, d}: d is the JSON body, also for a
|
||||
* refused request (its message is shown). A network failure rejects; the
|
||||
* optional login's own "sign in again" answer resolves to null, since the
|
||||
* page is already on its way to the login form.
|
||||
*/
|
||||
function send(ctx, method, url, body) {
|
||||
return ctx.api.request(method, url, body === undefined ? {} : { json: body }).then(function(d) {
|
||||
return { ok: true, d: d };
|
||||
}, function(err) {
|
||||
if (err && err.loginRequired) return null;
|
||||
// Only an HTTP answer is a refusal; anything else is a failure.
|
||||
if (!err || err.name !== 'ApiError' || err.network || !err.status) throw err;
|
||||
return { ok: false, d: err.body && typeof err.body === 'object' ? err.body : {} };
|
||||
});
|
||||
}
|
||||
|
||||
function failed(ctx) {
|
||||
return function(err) { ctx.notify('Request failed: ' + ((err && err.message) || err), 'error'); };
|
||||
}
|
||||
|
||||
function reloadSection(ctx) {
|
||||
if (ctx.signal.aborted) return; // already swapped for a fresh copy
|
||||
const win = windowOf(ctx);
|
||||
if (win.htmx) {
|
||||
win.htmx.ajax('GET', '/v3/partials/general', { target: '#general-content', swap: 'innerHTML' });
|
||||
} else {
|
||||
win.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenRow(doc, record) {
|
||||
const row = doc.createElement('div');
|
||||
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
|
||||
row.dataset.tokenId = record.id;
|
||||
const text = doc.createElement('div');
|
||||
text.className = 'text-sm';
|
||||
[['font-semibold text-gray-900', record.name],
|
||||
['font-mono text-gray-600 ml-2', record.prefix + '…'],
|
||||
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
|
||||
const span = doc.createElement('span');
|
||||
span.className = part[0];
|
||||
span.textContent = part[1];
|
||||
text.appendChild(span);
|
||||
});
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'text-sm text-red-600 hover:underline';
|
||||
button.dataset.action = 'revoke-token';
|
||||
button.dataset.tokenName = record.name;
|
||||
button.textContent = 'Revoke';
|
||||
row.appendChild(text);
|
||||
row.appendChild(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The password form: set (or change) the password, then redraw the section. */
|
||||
export function setPassword(ctx, form) {
|
||||
const next = field(form, 'new_password');
|
||||
if (next !== field(form, 'confirm_password')) {
|
||||
ctx.notify('The two new passwords do not match.', 'error');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const body = { new_password: next };
|
||||
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
|
||||
return send(ctx, 'POST', API + '/password', body).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection(ctx);
|
||||
return res.ok;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The "Turn login off" form. */
|
||||
export function disableLogin(ctx, form) {
|
||||
if (!windowOf(ctx).confirm('Turn login off? Anyone on your network will be able to open this page.')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return send(ctx, 'POST', API + '/disable', { current_password: field(form, 'current_password') }).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection(ctx);
|
||||
return res.ok;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The "Create token" form: add the row and show the token once. */
|
||||
export function createToken(ctx, form) {
|
||||
return send(ctx, 'POST', API + '/tokens', { name: field(form, 'name') }).then(function(res) {
|
||||
if (!res) return false;
|
||||
if (!res.ok) {
|
||||
ctx.notify(res.d.message || 'Could not create the token', 'error');
|
||||
return false;
|
||||
}
|
||||
const data = res.d.data || {};
|
||||
// app.js marks a form dirty on input and clears the mark only after
|
||||
// an htmx save; this one posts with fetch, so clear it here or a
|
||||
// reload asks "Leave site?" about a saved token.
|
||||
form.reset();
|
||||
form.removeAttribute('data-dirty');
|
||||
if (!ctx.signal.aborted) {
|
||||
const list = find(ctx, 'web-login-tokens');
|
||||
if (list && data.record) {
|
||||
const empty = list.querySelector('[data-empty]');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(tokenRow(ctx.root.ownerDocument, data.record));
|
||||
}
|
||||
const value = find(ctx, 'web-login-new-token-value');
|
||||
const box = find(ctx, 'web-login-new-token');
|
||||
if (value) value.textContent = data.token;
|
||||
if (box) box.classList.remove('hidden');
|
||||
}
|
||||
ctx.notify(res.d.message || 'Token created', 'success');
|
||||
return true;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The Copy button next to a new token. */
|
||||
export function copyToken(ctx) {
|
||||
const win = windowOf(ctx);
|
||||
const box = find(ctx, 'web-login-new-token-value');
|
||||
if (!box) return;
|
||||
if (win.navigator.clipboard && win.isSecureContext) {
|
||||
win.navigator.clipboard.writeText(box.textContent).then(function() { ctx.notify('Token copied', 'success'); });
|
||||
return;
|
||||
}
|
||||
// Plain http on a LAN is not a secure context: select it instead.
|
||||
const range = ctx.root.ownerDocument.createRange();
|
||||
range.selectNodeContents(box);
|
||||
const selection = win.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
|
||||
}
|
||||
|
||||
/** A token row's Revoke button. */
|
||||
export function revokeToken(ctx, id, name) {
|
||||
if (!windowOf(ctx).confirm('Revoke the token "' + name + '"? Anything using it stops working.')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return send(ctx, 'DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
|
||||
if (!res.ok) return false;
|
||||
ctx.root.querySelectorAll('#web-login-tokens [data-token-id]').forEach(function(row) {
|
||||
if (row.dataset.tokenId === id) row.remove();
|
||||
});
|
||||
return true;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
||||
|
||||
function drawTimezone(root, ctx) {
|
||||
const win = root.ownerDocument.defaultView;
|
||||
let tries = 0;
|
||||
function attempt() {
|
||||
ctx.state.timer = null;
|
||||
if (ctx.signal.aborted) return;
|
||||
const container = root.querySelector('#timezone_container');
|
||||
if (!container) return;
|
||||
const widget = win.LEDMatrixWidgets && win.LEDMatrixWidgets.get('timezone-selector');
|
||||
if (!widget) {
|
||||
if (++tries < MAX_TRIES) ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
||||
else win.console.error('[General] timezone-selector widget not available');
|
||||
return;
|
||||
}
|
||||
// Only render if container is empty (not already rendered)
|
||||
if (container.children.length > 0) return;
|
||||
widget.render(container, {
|
||||
'x-options': { showOffset: true, placeholder: 'Select your timezone...' },
|
||||
}, container.dataset.timezone || 'America/Chicago', {
|
||||
fieldId: 'timezone',
|
||||
name: 'timezone',
|
||||
});
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
|
||||
export function init(root, ctx) {
|
||||
const on = { signal: ctx.signal };
|
||||
root.addEventListener('submit', function(event) {
|
||||
const form = event.target;
|
||||
const action = form && form.dataset ? FORM_ACTIONS[form.dataset.action] : null;
|
||||
if (!action) return; // the settings form: htmx posts it
|
||||
event.preventDefault();
|
||||
action(ctx, form);
|
||||
}, on);
|
||||
root.addEventListener('click', function(event) {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button || !root.contains(button)) return;
|
||||
if (button.dataset.action === 'copy-token') {
|
||||
copyToken(ctx);
|
||||
} else if (button.dataset.action === 'revoke-token') {
|
||||
const row = button.closest('[data-token-id]');
|
||||
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
||||
}
|
||||
}, on);
|
||||
drawTimezone(root, ctx);
|
||||
active = ctx;
|
||||
}
|
||||
|
||||
export function destroy(root, ctx) {
|
||||
if (ctx.state.timer) {
|
||||
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
||||
ctx.state.timer = null;
|
||||
}
|
||||
if (active === ctx) active = null;
|
||||
}
|
||||
|
||||
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
|
||||
// window.webLogin's methods took the form (or the token's id and name) and
|
||||
// acted on the General tab on screen.
|
||||
export const webLogin = Object.freeze({
|
||||
setPassword: function(form) { return active ? setPassword(active, form) : undefined; },
|
||||
disable: function(form) { return active ? disableLogin(active, form) : undefined; },
|
||||
createToken: function(form) { return active ? createToken(active, form) : undefined; },
|
||||
copyToken: function() { return active ? copyToken(active) : undefined; },
|
||||
revoke: function(id, name) { return active ? revokeToken(active, id, name) : undefined; },
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* pages/schedule.js -- the Schedule tab (templates/v3/partials/schedule.html):
|
||||
* when the display is on, and when it dims.
|
||||
*
|
||||
* Both forms stay plain htmx (hx-post with json-enc). This module:
|
||||
* - draws the two schedule-picker widgets from the saved config the
|
||||
* partial carries in data-schedule-config / data-dim-schedule-config
|
||||
* - reports each form's save in one notification. The forms used to call
|
||||
* window.handleScheduleResponse / handleDimScheduleResponse from an
|
||||
* hx-on attribute; now one htmx:afterRequest listener on the page root
|
||||
* does it, and the forms carry data-reports-result so app.js does not
|
||||
* show the server's message a second time
|
||||
* - keeps the "30%" label next to the dim brightness slider current
|
||||
*
|
||||
* The widget registry (LEDMatrixWidgets) and the schedule-picker widget are
|
||||
* classic deferred scripts and normally load before this module runs; the
|
||||
* short retry covers a page that arrives first anyway.
|
||||
*
|
||||
* The old globals are deprecated aliases made in core/boot.js; they forward
|
||||
* to the exports of the same name at the bottom of this file.
|
||||
*/
|
||||
|
||||
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
const RETRY_MS = 100;
|
||||
const MAX_TRIES = 50;
|
||||
|
||||
const PICKERS = [
|
||||
{
|
||||
label: 'Schedule', container: 'schedule_picker_container', data: 'data-schedule-config',
|
||||
fieldId: 'schedule', start: '07:00', end: '23:00',
|
||||
// The display schedule fills in days only when it has no "days" at
|
||||
// all; the dim schedule also when "days" is empty.
|
||||
fillEmptyDays: false,
|
||||
},
|
||||
{
|
||||
label: 'DimSchedule', container: 'dim_schedule_picker_container', data: 'data-dim-schedule-config',
|
||||
fieldId: 'dim_schedule', start: '20:00', end: '07:00',
|
||||
fillEmptyDays: true,
|
||||
},
|
||||
];
|
||||
|
||||
const FORMS = {
|
||||
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
||||
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
||||
};
|
||||
|
||||
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
||||
|
||||
// The mounted page, for the deprecated aliases (boot.js).
|
||||
let active = null;
|
||||
|
||||
function readConfig(root, attribute) {
|
||||
try {
|
||||
const parsed = JSON.parse(root.getAttribute(attribute) || 'null');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The saved config, in the shape the schedule-picker widget takes. */
|
||||
export function widgetValue(config, spec) {
|
||||
config = config || {};
|
||||
let mode = 'global';
|
||||
if (config.mode) {
|
||||
// Normalize mode value (handle both 'per_day' and 'per-day')
|
||||
mode = config.mode.replace('-', '_');
|
||||
} else if (config.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
const value = {
|
||||
enabled: config.enabled || false,
|
||||
mode: mode,
|
||||
start_time: config.start_time || spec.start,
|
||||
end_time: config.end_time || spec.end,
|
||||
days: config.days || {},
|
||||
};
|
||||
const noDays = !config.days || (spec.fillEmptyDays && Object.keys(config.days).length === 0);
|
||||
if (noDays) {
|
||||
value.days = Object.fromEntries(DAYS.map(function(day) {
|
||||
return [day, { enabled: true, start_time: spec.start, end_time: spec.end }];
|
||||
}));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** One notification for a schedule form's answer. */
|
||||
function report(notify, xhr, labels) {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
if (!response || typeof response !== 'object') {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
const message = response.message || (response.status === 'success' ? labels.saved : labels.failed);
|
||||
notify(message, response.status || 'info');
|
||||
}
|
||||
|
||||
function drawPickers(root, ctx) {
|
||||
const win = root.ownerDocument.defaultView;
|
||||
let tries = 0;
|
||||
function attempt() {
|
||||
ctx.state.timer = null;
|
||||
if (ctx.signal.aborted) return;
|
||||
const registry = win.LEDMatrixWidgets;
|
||||
const widget = registry && registry.get('schedule-picker');
|
||||
if (!widget) {
|
||||
if (++tries < MAX_TRIES) {
|
||||
ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
||||
} else {
|
||||
win.console.error(registry
|
||||
? '[Schedule] schedule-picker widget not registered'
|
||||
: '[Schedule] LEDMatrixWidgets registry not available');
|
||||
}
|
||||
return;
|
||||
}
|
||||
PICKERS.forEach(function(spec) {
|
||||
const container = root.querySelector('#' + spec.container);
|
||||
if (!container) {
|
||||
win.console.error('[' + spec.label + '] Container not found');
|
||||
return;
|
||||
}
|
||||
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
||||
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
||||
});
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
|
||||
export function init(root, ctx) {
|
||||
const on = { signal: ctx.signal };
|
||||
|
||||
root.addEventListener('htmx:afterRequest', function(event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
||||
const labels = form && root.contains(form) ? FORMS[form.id] : null;
|
||||
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
||||
}, on);
|
||||
|
||||
const slider = root.querySelector('#dim_brightness');
|
||||
const label = root.querySelector('#dim_brightness_display');
|
||||
if (slider && label) {
|
||||
slider.addEventListener('input', function() { label.textContent = slider.value + '%'; }, on);
|
||||
}
|
||||
|
||||
drawPickers(root, ctx);
|
||||
active = ctx;
|
||||
}
|
||||
|
||||
export function destroy(root, ctx) {
|
||||
if (ctx.state.timer) {
|
||||
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
||||
ctx.state.timer = null;
|
||||
}
|
||||
if (active === ctx) active = null;
|
||||
}
|
||||
|
||||
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
|
||||
function notifier(event) {
|
||||
if (active) return active.notify;
|
||||
const win = event && event.target && event.target.ownerDocument
|
||||
? event.target.ownerDocument.defaultView : globalThis;
|
||||
return function(message, type) { return win.showNotification(message, type); };
|
||||
}
|
||||
|
||||
/** window.handleScheduleResponse(event): an htmx:afterRequest event. */
|
||||
export function handleScheduleResponse(event) {
|
||||
report(notifier(event), event.detail.xhr, FORMS.schedule_form);
|
||||
}
|
||||
|
||||
/** window.handleDimScheduleResponse(event): an htmx:afterRequest event. */
|
||||
export function handleDimScheduleResponse(event) {
|
||||
report(notifier(event), event.detail.xhr, FORMS.dim_schedule_form);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
{# No inline script: static/v3/js/pages/general.js draws the timezone picker
|
||||
and runs the Security section's forms and buttons (data-action). The page
|
||||
registry (static/v3/js/core/registry.js) starts it when this root appears
|
||||
and stops it when the partial is swapped away. #}
|
||||
<div data-page="general">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
|
||||
@@ -88,7 +93,7 @@
|
||||
<!-- Timezone -->
|
||||
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||
<div id="timezone_container" class="mt-1"></div>
|
||||
<div id="timezone_container" class="mt-1" data-timezone="{{ main_config.timezone or 'America/Chicago' }}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy scroll frame rate (device-wide). Core scrolling no longer reads it. -->
|
||||
@@ -102,32 +107,6 @@
|
||||
max="200"
|
||||
class="form-control">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function initTimezoneWidget() {
|
||||
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
|
||||
var widget = window.LEDMatrixWidgets.get('timezone-selector');
|
||||
if (!widget) { setTimeout(initTimezoneWidget, 50); return; }
|
||||
var container = document.getElementById('timezone_container');
|
||||
if (!container) return;
|
||||
|
||||
// Only render if container is empty (not already rendered)
|
||||
if (container.children.length > 0) return;
|
||||
|
||||
widget.render(container, {
|
||||
'x-options': { showOffset: true, placeholder: 'Select your timezone...' }
|
||||
}, {{ (main_config.timezone or "America/Chicago")|tojson }}, {
|
||||
fieldId: 'timezone',
|
||||
name: 'timezone'
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTimezoneWidget);
|
||||
} else {
|
||||
setTimeout(initTimezoneWidget, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Location Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
||||
@@ -216,7 +195,7 @@
|
||||
the password inputs keep their own labels. #}
|
||||
<label>{% if web_login.enabled %}Change the web interface password{% else %}Web interface password{% endif %}{{ ui.help_tip('Optional. With a password set, every page and API call needs a login or an API token.\nAlways allowed without one: this Pi itself (localhost), and the Wi-Fi setup page while the Pi is in access-point mode.\nForgot it? On the Pi run: sudo python3 ~/LEDMatrix/scripts/reset_web_password.py', 'Web interface password') }}</label>
|
||||
</h3>
|
||||
<form class="space-y-4" onsubmit="window.webLogin.setPassword(this); return false;" autocomplete="off">
|
||||
<form class="space-y-4" data-action="set-password" autocomplete="off">
|
||||
{% if web_login.enabled %}
|
||||
<div>
|
||||
<label for="web-login-current" class="block text-sm font-medium text-gray-700">Current password</label>
|
||||
@@ -251,7 +230,7 @@
|
||||
{% if web_login.enabled %}
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Turn login off</h3>
|
||||
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.disable(this); return false;" autocomplete="off">
|
||||
<form class="flex flex-wrap items-end gap-2" data-action="disable-login" autocomplete="off">
|
||||
<div class="flex-1">
|
||||
<label for="web-login-disable-current" class="block text-sm font-medium text-gray-700">Current password</label>
|
||||
<input type="password" id="web-login-disable-current" name="current_password" required
|
||||
@@ -280,8 +259,7 @@
|
||||
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-red-600 hover:underline"
|
||||
data-token-name="{{ token.name }}"
|
||||
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
|
||||
data-action="revoke-token" data-token-name="{{ token.name }}">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
@@ -289,7 +267,7 @@
|
||||
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
|
||||
<form class="flex flex-wrap items-end gap-2" data-action="create-token" autocomplete="off">
|
||||
<div class="flex-1">
|
||||
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
|
||||
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
|
||||
@@ -304,7 +282,7 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
|
||||
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
|
||||
onclick="window.webLogin.copyToken()">
|
||||
data-action="copy-token">
|
||||
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -312,131 +290,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var API = '/api/v3/auth';
|
||||
|
||||
function notify(message, type) {
|
||||
if (typeof window.showNotification === 'function') window.showNotification(message, type);
|
||||
else window.alert(message);
|
||||
}
|
||||
|
||||
function send(method, url, body) {
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
}).then(function(r) {
|
||||
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
|
||||
});
|
||||
}
|
||||
|
||||
function reloadSection() {
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function field(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function tokenRow(record) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
|
||||
row.dataset.tokenId = record.id;
|
||||
var text = document.createElement('div');
|
||||
text.className = 'text-sm';
|
||||
[['font-semibold text-gray-900', record.name],
|
||||
['font-mono text-gray-600 ml-2', record.prefix + '…'],
|
||||
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
|
||||
var span = document.createElement('span');
|
||||
span.className = part[0];
|
||||
span.textContent = part[1];
|
||||
text.appendChild(span);
|
||||
});
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'text-sm text-red-600 hover:underline';
|
||||
btn.textContent = 'Revoke';
|
||||
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
|
||||
row.appendChild(text);
|
||||
row.appendChild(btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
window.webLogin = {
|
||||
setPassword: function(form) {
|
||||
var next = field(form, 'new_password');
|
||||
if (next !== field(form, 'confirm_password')) {
|
||||
notify('The two new passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
var body = {new_password: next};
|
||||
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
|
||||
send('POST', API + '/password', body).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection();
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
disable: function(form) {
|
||||
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
|
||||
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection();
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
createToken: function(form) {
|
||||
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
|
||||
if (!res.ok) {
|
||||
notify(res.d.message || 'Could not create the token', 'error');
|
||||
return;
|
||||
}
|
||||
var list = document.getElementById('web-login-tokens');
|
||||
if (list) {
|
||||
var empty = list.querySelector('[data-empty]');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(tokenRow(res.d.data.record));
|
||||
}
|
||||
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
|
||||
document.getElementById('web-login-new-token').classList.remove('hidden');
|
||||
form.reset();
|
||||
// app.js marks a form dirty on input and clears the mark only
|
||||
// after an htmx save; this one posts with fetch, so clear it
|
||||
// here or a reload asks "Leave site?" about a saved token.
|
||||
form.removeAttribute('data-dirty');
|
||||
notify(res.d.message || 'Token created', 'success');
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
copyToken: function() {
|
||||
var box = document.getElementById('web-login-new-token-value');
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
|
||||
return;
|
||||
}
|
||||
// Plain http on a LAN is not a secure context: select it instead.
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(box);
|
||||
var sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
|
||||
},
|
||||
revoke: function(id, name) {
|
||||
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
|
||||
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
|
||||
if (!res.ok) return;
|
||||
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
|
||||
Array.prototype.forEach.call(rows, function(row) {
|
||||
if (row.dataset.tokenId === id) row.remove();
|
||||
});
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
{# No inline script: static/v3/js/pages/schedule.js draws both schedule
|
||||
pickers from the data-*-config attributes, reports each form's save and
|
||||
keeps the dim brightness label current. The page registry
|
||||
(static/v3/js/core/registry.js) starts it when this root appears and stops
|
||||
it when the partial is swapped away. The forms carry data-reports-result so
|
||||
app.js leaves the save notification to the page. #}
|
||||
<div data-page="schedule"
|
||||
data-schedule-config='{{ schedule_config | tojson }}'
|
||||
data-dim-schedule-config='{{ dim_schedule_config | tojson }}'>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
|
||||
@@ -12,7 +21,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="handleScheduleResponse(event)"
|
||||
data-reports-result
|
||||
class="space-y-6">
|
||||
|
||||
<!-- Schedule Picker Widget Container -->
|
||||
@@ -41,7 +50,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="handleDimScheduleResponse(event)"
|
||||
data-reports-result
|
||||
class="space-y-6">
|
||||
|
||||
<!-- Dim Brightness Level -->
|
||||
@@ -56,8 +65,7 @@
|
||||
min="0"
|
||||
max="100"
|
||||
value="{{ dim_schedule_config.dim_brightness | default(30) }}"
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
oninput="document.getElementById('dim_brightness_display').textContent = this.value + '%'">
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<span id="dim_brightness_display" class="text-sm font-medium text-gray-700 w-12 text-right">
|
||||
{{ dim_schedule_config.dim_brightness | default(30) }}%
|
||||
</span>
|
||||
@@ -77,198 +85,4 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Initialize schedule picker widget when DOM is ready
|
||||
function initSchedulePicker() {
|
||||
const container = document.getElementById('schedule_picker_container');
|
||||
if (!container) {
|
||||
console.error('[Schedule] Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if widget registry is available
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[Schedule] LEDMatrixWidgets registry not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const widget = window.LEDMatrixWidgets.get('schedule-picker');
|
||||
if (!widget) {
|
||||
console.error('[Schedule] schedule-picker widget not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get schedule config from template data (injected by Jinja2)
|
||||
// Default to empty object if null/undefined
|
||||
const scheduleConfig = {{ schedule_config | tojson | safe }} || {};
|
||||
|
||||
// Determine mode: prefer explicit mode, then infer from days, then default to global
|
||||
let mode = 'global';
|
||||
if (scheduleConfig.mode) {
|
||||
// Normalize mode value (handle both 'per_day' and 'per-day')
|
||||
mode = scheduleConfig.mode.replace('-', '_');
|
||||
} else if (scheduleConfig.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
|
||||
// Convert flat config to nested format expected by widget
|
||||
const widgetValue = {
|
||||
enabled: scheduleConfig.enabled || false,
|
||||
mode: mode,
|
||||
start_time: scheduleConfig.start_time || '07:00',
|
||||
end_time: scheduleConfig.end_time || '23:00',
|
||||
days: scheduleConfig.days || {}
|
||||
};
|
||||
|
||||
// If we have per-day data in the old flat format, convert it
|
||||
if (!scheduleConfig.days) {
|
||||
widgetValue.days = {};
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
days.forEach(function(day) {
|
||||
widgetValue.days[day] = {
|
||||
enabled: true,
|
||||
start_time: '07:00',
|
||||
end_time: '23:00'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Render the widget
|
||||
widget.render(container, {
|
||||
'x-options': {
|
||||
showModeToggle: true,
|
||||
showEnableToggle: true,
|
||||
compactMode: false
|
||||
}
|
||||
}, widgetValue, {
|
||||
fieldId: 'schedule'
|
||||
});
|
||||
|
||||
debugLog('[Schedule] Schedule picker widget initialized');
|
||||
}
|
||||
|
||||
// Handle form submission response
|
||||
window.handleScheduleResponse = function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
|
||||
const message = response.message || (response.status === 'success' ? 'Schedule settings saved' : 'Error saving schedule');
|
||||
const type = response.status || 'info';
|
||||
|
||||
// Use global notification function if available
|
||||
window.showNotification(message, type);
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready or if already loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSchedulePicker);
|
||||
} else {
|
||||
// Small delay to ensure widget scripts are loaded
|
||||
setTimeout(initSchedulePicker, 50);
|
||||
}
|
||||
})();
|
||||
|
||||
// Dim Schedule Picker initialization
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function initDimSchedulePicker() {
|
||||
const container = document.getElementById('dim_schedule_picker_container');
|
||||
if (!container) {
|
||||
console.error('[DimSchedule] Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if widget registry is available
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[DimSchedule] LEDMatrixWidgets registry not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const widget = window.LEDMatrixWidgets.get('schedule-picker');
|
||||
if (!widget) {
|
||||
console.error('[DimSchedule] schedule-picker widget not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get dim schedule config from template data (injected by Jinja2)
|
||||
const dimScheduleConfig = {{ dim_schedule_config | tojson | safe }} || {};
|
||||
|
||||
// Determine mode
|
||||
let mode = 'global';
|
||||
if (dimScheduleConfig.mode) {
|
||||
mode = dimScheduleConfig.mode.replace('-', '_');
|
||||
} else if (dimScheduleConfig.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
|
||||
// Convert config to widget format
|
||||
const widgetValue = {
|
||||
enabled: dimScheduleConfig.enabled || false,
|
||||
mode: mode,
|
||||
start_time: dimScheduleConfig.start_time || '20:00',
|
||||
end_time: dimScheduleConfig.end_time || '07:00',
|
||||
days: dimScheduleConfig.days || {}
|
||||
};
|
||||
|
||||
// If no days config, initialize with defaults for dim schedule (20:00-07:00)
|
||||
if (!dimScheduleConfig.days || Object.keys(dimScheduleConfig.days).length === 0) {
|
||||
widgetValue.days = {};
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
days.forEach(function(day) {
|
||||
widgetValue.days[day] = {
|
||||
enabled: true,
|
||||
start_time: '20:00',
|
||||
end_time: '07:00'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Render the widget with a different field ID
|
||||
widget.render(container, {
|
||||
'x-options': {
|
||||
showModeToggle: true,
|
||||
showEnableToggle: true,
|
||||
compactMode: false
|
||||
}
|
||||
}, widgetValue, {
|
||||
fieldId: 'dim_schedule'
|
||||
});
|
||||
|
||||
debugLog('[DimSchedule] Dim schedule picker widget initialized');
|
||||
}
|
||||
|
||||
// Handle dim schedule form submission response
|
||||
window.handleDimScheduleResponse = function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
|
||||
const message = response.message || (response.status === 'success' ? 'Dim schedule settings saved' : 'Error saving dim schedule');
|
||||
const type = response.status || 'info';
|
||||
|
||||
// Use global notification function if available
|
||||
window.showNotification(message, type);
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initDimSchedulePicker);
|
||||
} else {
|
||||
setTimeout(initDimSchedulePicker, 100);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user