Revert "Submit GUI commands without page navigation"
This reverts commit 0da5377ff2.
This commit is contained in:
@@ -20,8 +20,6 @@ through Home Assistant MQTT Discovery plus an emergency HTTP fallback GUI.
|
||||
300s, white light 600s. The GUI timer is rendered from a backend timestamp,
|
||||
so page refreshes do not reset it.
|
||||
- Siren `ON` actions require a browser confirmation in the fallback GUI.
|
||||
- The fallback GUI sends camera commands with same-origin JavaScript `fetch()`
|
||||
and keeps plain POST forms as a no-JavaScript fallback.
|
||||
- If `ffmpeg.inputs[].path` points at `rtsp://127.0.0.1:8554/...`, the sidecar
|
||||
treats it as a go2rtc gateway and resolves the real camera URL from
|
||||
`go2rtc.streams`.
|
||||
|
||||
@@ -1636,22 +1636,6 @@ class CameraControlBridge:
|
||||
payload["redirect_url"] = redirect_url
|
||||
return payload
|
||||
|
||||
def action_payload(self, lang, action, command, result, redirect_url):
|
||||
ok = bool(result.get("ok", True))
|
||||
payload = {
|
||||
"ok": ok,
|
||||
"command": command,
|
||||
"message": text_for(lang, "action.success" if ok else "action.failed_generic"),
|
||||
"redirect_url": redirect_url,
|
||||
"result": result,
|
||||
}
|
||||
if action is not None:
|
||||
payload["action_id"] = action.id
|
||||
payload["action"] = self.api_action(action, lang)
|
||||
if action.kind == "all_off":
|
||||
payload["actions"] = [self.api_action(item, lang) for item in self.actions_snapshot()]
|
||||
return payload
|
||||
|
||||
def frigate_refresh_loop(self):
|
||||
interval = int(self.config.get("defaults", {}).get("frigate_refresh_seconds", 300))
|
||||
if interval <= 0:
|
||||
@@ -1842,36 +1826,17 @@ class CameraControlBridge:
|
||||
if len(parts) == 3 and parts[0] == "action":
|
||||
action_id = urllib.parse.unquote(parts[1])
|
||||
command = urllib.parse.unquote(parts[2]).lower()
|
||||
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
|
||||
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
||||
redirect_url = f"/?lang={urllib.parse.quote(lang)}{token_query}"
|
||||
wants_json = "application/json" in self.headers.get("Accept", "").lower()
|
||||
try:
|
||||
result = bridge.execute_action(action_id, command, publish=True)
|
||||
action = bridge.action_by_id(action_id)
|
||||
if wants_json:
|
||||
payload = bridge.action_payload(lang, action, command, result, redirect_url)
|
||||
self.send_json(payload, status=200 if payload.get("ok") else 500)
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header("Location", redirect_url)
|
||||
self.end_headers()
|
||||
token_param = urllib.parse.parse_qs(parsed.query).get("token", [None])[0]
|
||||
token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else ""
|
||||
self.send_response(303)
|
||||
self.send_header("Location", f"/?lang={urllib.parse.quote(lang)}{token_query}")
|
||||
self.end_headers()
|
||||
except Exception as exc:
|
||||
if wants_json:
|
||||
self.send_json(
|
||||
{
|
||||
"ok": False,
|
||||
"action_id": action_id,
|
||||
"command": command,
|
||||
"message": text_for(lang, "action.failed", error=str(exc)),
|
||||
"redirect_url": redirect_url,
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
else:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(str(exc).encode("utf-8"))
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(str(exc).encode("utf-8"))
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
@@ -1909,27 +1874,20 @@ class CameraControlBridge:
|
||||
LOG.info("HTTP fallback GUI listening on %s:%s", bind, port)
|
||||
|
||||
def api_actions(self):
|
||||
return [self.api_action(action, self.default_language) for action in self.actions_snapshot()]
|
||||
|
||||
def api_action(self, action, lang=None):
|
||||
lang = lang or self.default_language
|
||||
state_value = (action.state or "unknown").lower()
|
||||
state_label = text_for(lang, f"state.{state_value}")
|
||||
if state_label == f"state.{state_value}":
|
||||
state_label = state_value.upper()
|
||||
return {
|
||||
"id": action.id,
|
||||
"name": action.display_name(lang),
|
||||
"platform": action.platform,
|
||||
"commands": action.command_list(),
|
||||
"state": action.state,
|
||||
"state_label": state_label,
|
||||
"snapshot": bool(action.cfg.get("snapshot")),
|
||||
"snapshot_version": int(action.snapshot_version or 0),
|
||||
"auto_off_seconds": action.auto_off_seconds,
|
||||
"auto_off_at": int(action.auto_off_at) if action.auto_off_at else None,
|
||||
"requires_ack": bool(action.cfg.get("requires_ack")),
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": action.id,
|
||||
"name": action.display_name(self.default_language),
|
||||
"platform": action.platform,
|
||||
"commands": action.command_list(),
|
||||
"state": action.state,
|
||||
"snapshot": bool(action.cfg.get("snapshot")),
|
||||
"auto_off_seconds": action.auto_off_seconds,
|
||||
"auto_off_at": int(action.auto_off_at) if action.auto_off_at else None,
|
||||
"requires_ack": bool(action.cfg.get("requires_ack")),
|
||||
}
|
||||
for action in self.actions_snapshot()
|
||||
]
|
||||
|
||||
def html_page(self, lang=None, token_param=None):
|
||||
with self.action_lock:
|
||||
|
||||
@@ -560,11 +560,6 @@ h3 {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cmd.is-working {
|
||||
cursor: progress;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.cmd-on,
|
||||
.cmd-press {
|
||||
border-color: var(--blue);
|
||||
@@ -601,26 +596,6 @@ h3 {
|
||||
background: #edfdf5;
|
||||
}
|
||||
|
||||
.action-status {
|
||||
display: none;
|
||||
min-height: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.action-status.is-visible {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.action-status.is-success {
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
.action-status.is-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.global-strip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -641,11 +616,6 @@ h3 {
|
||||
box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
.global-controls-wrap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.global-main {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
|
||||
@@ -129,174 +129,6 @@ function refreshSnapshot(button) {
|
||||
}, SNAPSHOT_REFRESH_DELAY_MS);
|
||||
}
|
||||
|
||||
function actionContainerById(actionId) {
|
||||
for (const container of document.querySelectorAll("[data-action-id]")) {
|
||||
if (container.dataset.actionId === actionId) {
|
||||
return container;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function actionStatusForForm(form) {
|
||||
return form.closest(".action-control, .global-action")?.querySelector("[data-action-status]");
|
||||
}
|
||||
|
||||
function showActionMessage(form, message, type = "success") {
|
||||
const status = actionStatusForForm(form);
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.textContent = message || "";
|
||||
status.classList.toggle("is-visible", Boolean(message));
|
||||
status.classList.toggle("is-success", type === "success");
|
||||
status.classList.toggle("is-error", type === "error");
|
||||
}
|
||||
|
||||
function setCommandBusy(button, busy) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (busy) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-working");
|
||||
button.setAttribute("aria-busy", "true");
|
||||
return;
|
||||
}
|
||||
if (button.dataset.timerLocked !== "true") {
|
||||
button.disabled = false;
|
||||
}
|
||||
button.classList.remove("is-working");
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
function updateStateChip(container, action) {
|
||||
const chip = container.querySelector("[data-state-chip]");
|
||||
if (!chip || !action.state) {
|
||||
return;
|
||||
}
|
||||
const state = String(action.state).toLowerCase();
|
||||
for (const className of Array.from(chip.classList)) {
|
||||
if (className.startsWith("chip-state-")) {
|
||||
chip.classList.remove(className);
|
||||
}
|
||||
}
|
||||
chip.classList.add(`chip-state-${state}`);
|
||||
chip.textContent = action.state_label || chip.dataset[`label${state.charAt(0).toUpperCase()}${state.slice(1)}`] || action.state;
|
||||
}
|
||||
|
||||
function updateTimer(container, action) {
|
||||
const timer = container.querySelector("[data-timer]");
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
if (action.auto_off_at) {
|
||||
timer.dataset.autoOffAt = String(action.auto_off_at);
|
||||
timer.classList.remove("timer-idle", "timer-expired");
|
||||
updateTimers();
|
||||
return;
|
||||
}
|
||||
delete timer.dataset.autoOffAt;
|
||||
timer.classList.add("timer-idle");
|
||||
timer.classList.remove("timer-expired");
|
||||
timer.textContent = timer.dataset.idleLabel || "";
|
||||
}
|
||||
|
||||
function updateOnButtonLock(container, action) {
|
||||
const button = container.querySelector(".cmd-on");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (action.auto_off_at) {
|
||||
const label = button.dataset.disabledLabel || "Already active until automatic turn-off";
|
||||
button.dataset.timerLocked = "true";
|
||||
button.disabled = true;
|
||||
button.title = label;
|
||||
button.setAttribute("aria-label", label);
|
||||
return;
|
||||
}
|
||||
delete button.dataset.timerLocked;
|
||||
if (!button.classList.contains("is-working")) {
|
||||
button.disabled = false;
|
||||
}
|
||||
button.removeAttribute("title");
|
||||
button.removeAttribute("aria-label");
|
||||
}
|
||||
|
||||
function applyActionState(action) {
|
||||
if (!action?.id) {
|
||||
return;
|
||||
}
|
||||
const container = actionContainerById(action.id);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
updateStateChip(container, action);
|
||||
updateTimer(container, action);
|
||||
updateOnButtonLock(container, action);
|
||||
}
|
||||
|
||||
function applyActionPayload(payload) {
|
||||
if (Array.isArray(payload.actions)) {
|
||||
payload.actions.forEach(applyActionState);
|
||||
return;
|
||||
}
|
||||
applyActionState(payload.action);
|
||||
}
|
||||
|
||||
function refreshSnapshotForCommand(form) {
|
||||
const button = form.closest(".camera-card")?.querySelector(".snapshot-refresh");
|
||||
if (!button || button.classList.contains("is-refreshing")) {
|
||||
return;
|
||||
}
|
||||
refreshSnapshot(button);
|
||||
}
|
||||
|
||||
async function responseJson(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
return { ok: false, message: text };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommandSubmit(form, event) {
|
||||
event.preventDefault();
|
||||
if (form.dataset.requiresAck === "true") {
|
||||
const message = form.dataset.ackMessage || "Confirm this action";
|
||||
if (!window.confirm(message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const button = form.querySelector(".cmd");
|
||||
if (button?.classList.contains("is-working")) {
|
||||
return;
|
||||
}
|
||||
showActionMessage(form, form.dataset.workingLabel || "Sending command...", "success");
|
||||
setCommandBusy(button, true);
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const payload = await responseJson(response);
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(payload.message || form.dataset.failedLabel || "Command failed.");
|
||||
}
|
||||
applyActionPayload(payload);
|
||||
showActionMessage(form, payload.message || form.dataset.successLabel || "Command sent.", "success");
|
||||
refreshSnapshotForCommand(form);
|
||||
} catch (error) {
|
||||
showActionMessage(form, error.message || form.dataset.failedLabel || "Command failed.", "error");
|
||||
} finally {
|
||||
setCommandBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".snapshot-refresh");
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
@@ -314,10 +146,13 @@ document.addEventListener("submit", (event) => {
|
||||
handleRefreshSubmit(form, event);
|
||||
return;
|
||||
}
|
||||
if (form.classList.contains("command-form")) {
|
||||
handleCommandSubmit(form, event);
|
||||
if (form.dataset.requiresAck !== "true") {
|
||||
return;
|
||||
}
|
||||
const message = form.dataset.ackMessage || "Confirm this action";
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
updateTimers();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<link rel="stylesheet" href="/static/app.css?v={css_version}">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
@@ -40,6 +40,6 @@
|
||||
{cards}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js?v={js_version}" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import html
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
@@ -20,13 +19,6 @@ def read_static_file(name):
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def static_asset_version(name):
|
||||
payload = read_static_file(name)
|
||||
if payload is None:
|
||||
return "missing"
|
||||
return hashlib.sha256(payload).hexdigest()[:12]
|
||||
|
||||
|
||||
def action_visual(action, lang):
|
||||
# Keep GUI visuals tied to action semantics, not to local camera names. New
|
||||
# action families can add another name_key/kind branch without changing MQTT
|
||||
@@ -239,39 +231,31 @@ def render_action_control(action, lang, token_query):
|
||||
if action.auto_off_seconds > 0:
|
||||
if action.auto_off_at:
|
||||
timer_markup = (
|
||||
f'<span class="timer" data-timer data-auto-off-at="{int(action.auto_off_at)}" '
|
||||
f'data-idle-label="{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}" '
|
||||
f'<span class="timer" data-auto-off-at="{int(action.auto_off_at)}" '
|
||||
f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" '
|
||||
f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">'
|
||||
f'{html.escape(text_for(lang, "timer.auto_off"))}</span>'
|
||||
)
|
||||
else:
|
||||
timer_markup = (
|
||||
f'<span class="timer timer-idle" data-timer '
|
||||
f'data-idle-label="{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}" '
|
||||
f'data-prefix="{html.escape(text_for(lang, "timer.auto_off"))}" '
|
||||
f'data-expired-label="{html.escape(text_for(lang, "timer.expired"))}">'
|
||||
f'<span class="timer timer-idle">'
|
||||
f'{html.escape(text_for(lang, "timer.auto_off_configured", seconds=action.auto_off_seconds))}'
|
||||
"</span>"
|
||||
)
|
||||
|
||||
return (
|
||||
f'<section class="action-control" data-action-id="{html.escape(action.id)}">'
|
||||
'<section class="action-control">'
|
||||
'<div class="control-head">'
|
||||
f"{visual_markup(action, lang)}"
|
||||
f"<h3>{html.escape(display_name)}</h3>"
|
||||
"</div>"
|
||||
'<div class="meta-row">'
|
||||
f'<span class="chip">{html.escape(platform_label)}</span>'
|
||||
f'<span class="chip chip-state chip-state-{html.escape(state_value)}" data-state-chip '
|
||||
f'data-label-on="{html.escape(text_for(lang, "state.on"))}" '
|
||||
f'data-label-off="{html.escape(text_for(lang, "state.off"))}" '
|
||||
f'data-label-unknown="{html.escape(text_for(lang, "state.unknown"))}">'
|
||||
f'<span class="chip chip-state chip-state-{html.escape(state_value)}">'
|
||||
f"{html.escape(state_label)}</span>"
|
||||
"</div>"
|
||||
f"{timer_markup}"
|
||||
f'<div class="command-row">{render_action_buttons(action, lang, token_query)}</div>'
|
||||
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
|
||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||
"</section>"
|
||||
)
|
||||
@@ -297,7 +281,7 @@ def render_group_card(group, lang, token_query):
|
||||
|
||||
def render_global_action(action, lang, token_query):
|
||||
return (
|
||||
f'<article class="global-action" data-action-id="{html.escape(action.id)}">'
|
||||
'<article class="global-action">'
|
||||
'<div class="global-main">'
|
||||
f"{visual_markup(action, lang)}"
|
||||
'<div>'
|
||||
@@ -305,10 +289,7 @@ def render_global_action(action, lang, token_query):
|
||||
f'<code class="action-id">{html.escape(action.id)}</code>'
|
||||
"</div>"
|
||||
"</div>"
|
||||
'<div class="global-controls-wrap">'
|
||||
f'<div class="global-controls">{render_action_buttons(action, lang, token_query)}</div>'
|
||||
'<span class="action-status" data-action-status role="status" aria-live="polite"></span>'
|
||||
"</div>"
|
||||
"</article>"
|
||||
)
|
||||
|
||||
@@ -327,23 +308,16 @@ def render_action_buttons(action, lang, token_query):
|
||||
' data-requires-ack="true"'
|
||||
f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"'
|
||||
)
|
||||
common_attrs = (
|
||||
f' data-working-label="{html.escape(text_for(lang, "action.working"))}"'
|
||||
f' data-success-label="{html.escape(text_for(lang, "action.success"))}"'
|
||||
f' data-failed-label="{html.escape(text_for(lang, "action.failed_generic"))}"'
|
||||
)
|
||||
disabled_attrs = ""
|
||||
disabled_label = text_for(lang, "button.on_blocked_timer")
|
||||
if command == "on":
|
||||
common_attrs += f' data-disabled-label="{html.escape(disabled_label)}"'
|
||||
if command == "on" and getattr(action, "auto_off_at", None):
|
||||
disabled_label = text_for(lang, "button.on_blocked_timer")
|
||||
disabled_attrs = (
|
||||
" disabled"
|
||||
f' title="{html.escape(disabled_label)}"'
|
||||
f' aria-label="{html.escape(disabled_label)}"'
|
||||
)
|
||||
buttons.append(
|
||||
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}{common_attrs}>'
|
||||
f'<form class="command-form" method="post" action="{html.escape(action_url)}"{ack_attrs}>'
|
||||
f'<button class="cmd cmd-{html.escape(command)}" type="submit"{disabled_attrs}>'
|
||||
f"<span>{html.escape(label)}</span>"
|
||||
"</button></form>"
|
||||
@@ -403,8 +377,6 @@ def render_page(actions, lang, token_param=None):
|
||||
language_options=render_language_options(lang),
|
||||
token_input=token_input,
|
||||
token_query=html.escape(token_query),
|
||||
css_version=html.escape(static_asset_version("app.css")),
|
||||
js_version=html.escape(static_asset_version("app.js")),
|
||||
global_actions=global_markup,
|
||||
grid_class=html.escape(grid_class),
|
||||
cards=cards_markup,
|
||||
|
||||
@@ -101,18 +101,6 @@ msgstr "Refresh snapshot"
|
||||
msgid "button.on_blocked_timer"
|
||||
msgstr "Already active until automatic turn-off"
|
||||
|
||||
msgid "action.working"
|
||||
msgstr "Sending command..."
|
||||
|
||||
msgid "action.success"
|
||||
msgstr "Command sent."
|
||||
|
||||
msgid "action.failed_generic"
|
||||
msgstr "Command failed."
|
||||
|
||||
msgid "action.failed"
|
||||
msgstr "Command failed: {error}"
|
||||
|
||||
msgid "link.open_stream"
|
||||
msgstr "Open Frigate stream"
|
||||
|
||||
|
||||
@@ -101,18 +101,6 @@ msgstr "Odswiez obraz"
|
||||
msgid "button.on_blocked_timer"
|
||||
msgstr "Juz wlaczone do czasu automatycznego wylaczenia"
|
||||
|
||||
msgid "action.working"
|
||||
msgstr "Wysylanie komendy..."
|
||||
|
||||
msgid "action.success"
|
||||
msgstr "Komenda wyslana."
|
||||
|
||||
msgid "action.failed_generic"
|
||||
msgstr "Komenda nie powiodla sie."
|
||||
|
||||
msgid "action.failed"
|
||||
msgstr "Komenda nie powiodla sie: {error}"
|
||||
|
||||
msgid "link.open_stream"
|
||||
msgstr "Otworz stream we Frigate"
|
||||
|
||||
|
||||
+1
-19
@@ -103,8 +103,7 @@ class UiRenderTest(unittest.TestCase):
|
||||
html = render_page([action], "en")
|
||||
|
||||
self.assertIn('class="cmd cmd-on"', html)
|
||||
self.assertNotIn('class="cmd cmd-on" type="submit" disabled', html)
|
||||
self.assertIn('data-disabled-label="Already active until automatic turn-off"', html)
|
||||
self.assertNotIn('Already active until automatic turn-off', html)
|
||||
|
||||
def test_camera_card_uses_friendly_display_name(self):
|
||||
action = DummyAction()
|
||||
@@ -122,23 +121,6 @@ class UiRenderTest(unittest.TestCase):
|
||||
self.assertIn("SNAPSHOT_REFRESH_DELAY_MS = 5000", script)
|
||||
self.assertIn("window.setTimeout", script)
|
||||
|
||||
def test_command_forms_expose_ajax_status_metadata(self):
|
||||
html = render_page([DummyAction()], "en")
|
||||
|
||||
self.assertIn('class="command-form"', html)
|
||||
self.assertIn('data-working-label="Sending command..."', html)
|
||||
self.assertIn('data-success-label="Command sent."', html)
|
||||
self.assertIn('data-action-status', html)
|
||||
self.assertIn('data-state-chip', html)
|
||||
|
||||
def test_command_submit_uses_fetch_without_page_navigation(self):
|
||||
script = Path("frigate_camera_control_bridge/static/app.js").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("handleCommandSubmit(form, event)", script)
|
||||
self.assertIn('form.classList.contains("command-form")', script)
|
||||
self.assertIn('headers: { Accept: "application/json" }', script)
|
||||
self.assertIn("event.preventDefault()", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user