From bb3a316e352d83adb999c1abb983f5f75beb76b7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 28 May 2026 14:58:08 +0300 Subject: [PATCH] refactor(frontend): shared API client + automations registry (audit M7, H8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H8 — automations.ts rule-type registry Convert the two hand-rolled RuleType dispatch ladders into per-type registries (RULE_FIELD_RENDERERS + RULE_COLLECTORS) keyed by RuleType, joining the existing RULE_CHIP_RENDERERS. All three are typed Record for compile-time exhaustiveness; an import-time _assertRuleHandlerCoverage() check logs loudly if any registry drifts from RULE_TYPE_KEYS — mirrors the backend's _RULE_HANDLERS shape, the one intentional divergence being that the frontend logs rather than throws (a thrown error at module import would brick the whole bundle, not just the editor). M7 — shared API client + 35 file migrations New core/api-client.ts wrapping fetchWithAuth with typed apiGet / apiPost / apiPut / apiPatch / apiDelete. Auth, 401-relogin, retry, timeout, and the offline toast all stay owned by fetchWithAuth; the client just collapses the if (!resp.ok) { detail || HTTP } ... resp.json() dance into one typed call. The detail unwrap is hardened to join FastAPI validation arrays instead of stringifying to [object Object]. 35 feature/core files migrated to it across many batches, reviewer- approved for behaviour parity in three passes covering the riskier divergences (bulk Promise.allSettled deletes, inline-error saves, array-detail joins, silent-failure GETs, immutable clones). 9 files remain on fetchWithAuth — the big god-modules tied to the pending C8/C9/C10 splits (streams, settings, targets, dashboard, color-strips/index, graph-editor, assets, value-sources) plus pairing-flow which by design stays on raw fetch (branches on raw Response.status codes). i18n — 14 new locale keys (en / ru / zh) Added save/load/delete error keys across automations, pattern, audio_processing, audio_template, templates, gradient, target, device namespaces, plus backfilled gradient.error.delete_failed into ru/zh. Scan confirms no hardcoded English errorMessage strings remain in the migrated diff. AUDIT_REMAINING.md updated to reflect H6, H8, and M7 status. Verified: tsc --noEmit clean + npm run build clean after every batch. --- AUDIT_REMAINING.md | 103 +- .../src/ledgrab/static/js/core/api-client.ts | 113 +++ server/src/ledgrab/static/js/core/cache.ts | 16 +- .../ledgrab/static/js/core/command-palette.ts | 53 +- .../static/js/core/graph-connections.ts | 8 +- .../ledgrab/static/js/core/process-picker.ts | 22 +- .../src/ledgrab/static/js/core/tag-input.ts | 5 +- .../js/features/advanced-calibration.ts | 30 +- .../js/features/audio-processing-templates.ts | 28 +- .../static/js/features/audio-sources.ts | 40 +- .../ledgrab/static/js/features/automations.ts | 904 +++++++++--------- .../ledgrab/static/js/features/calibration.ts | 81 +- .../ledgrab/static/js/features/card-modes.ts | 11 +- .../js/features/color-strips/gradient.ts | 22 +- .../js/features/color-strips/notification.ts | 24 +- .../static/js/features/dashboard-layout.ts | 11 +- .../static/js/features/device-discovery.ts | 73 +- .../src/ledgrab/static/js/features/devices.ts | 103 +- .../ledgrab/static/js/features/displays.ts | 18 +- .../static/js/features/game-integration.ts | 48 +- .../static/js/features/ha-light-targets.ts | 55 +- .../js/features/home-assistant-sources.ts | 58 +- .../static/js/features/http-endpoints.ts | 58 +- .../ledgrab/static/js/features/icon-picker.ts | 15 +- .../static/js/features/integrations.ts | 8 +- .../static/js/features/mqtt-sources.ts | 50 +- .../js/features/notifications-watcher.ts | 16 +- .../static/js/features/pattern-templates.ts | 34 +- .../ledgrab/static/js/features/perf-charts.ts | 7 +- .../static/js/features/scene-presets.ts | 92 +- .../js/features/streams-audio-templates.ts | 60 +- .../js/features/streams-capture-templates.ts | 27 +- .../ledgrab/static/js/features/sync-clocks.ts | 45 +- .../src/ledgrab/static/js/features/update.ts | 40 +- .../static/js/features/weather-sources.ts | 51 +- .../static/js/features/z2m-light-targets.ts | 43 +- server/src/ledgrab/static/locales/en.json | 13 + server/src/ledgrab/static/locales/ru.json | 14 + server/src/ledgrab/static/locales/zh.json | 14 + 39 files changed, 1133 insertions(+), 1280 deletions(-) create mode 100644 server/src/ledgrab/static/js/core/api-client.ts diff --git a/AUDIT_REMAINING.md b/AUDIT_REMAINING.md index 7c0665c..1db3fd6 100644 --- a/AUDIT_REMAINING.md +++ b/AUDIT_REMAINING.md @@ -18,6 +18,7 @@ context. | `05f73ee` | H6 (bindable extraction only) | | `3b8f00e` + `c1aa2eb` | C7 store-side | | `2f15fbb` | H3 | +| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) | All commits have ≥1 code-review subagent pass with HIGH findings fixed before commit. Tests pass on each commit; ruff clean; tsc + bundle build @@ -100,16 +101,35 @@ registry. **Estimated scope:** 1-2 sessions; coupled to H4. -#### H8 — `automations.ts` 1410 LOC +#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27) Frontend mirror of H2 (rule polymorphism). Already addressed on the -backend in `98fb61d`; the frontend dispatch on `RuleType` is still +backend in `98fb61d`; the frontend dispatch on `RuleType` was hand-rolled. -**Approach:** introduce a rule-type registry on the frontend matching -the backend's `_RULE_HANDLERS` shape. +**Done:** the two remaining hand-rolled dispatch ladders were converted +to registries keyed by `RuleType`, alongside the pre-existing +`RULE_CHIP_RENDERERS`: +- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was + extracted into module-level `_renderXxxFields(container, data)` + functions (they only ever closed over `container`); the in-row + `renderFields` is now a 3-line dispatcher. +- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder + became per-type collectors; the loop is now a registry lookup. +- All three registries are typed `Record` (compile-time + exhaustiveness) and an import-time `_assertRuleHandlerCoverage()` + logs loudly if any registry drifts from `RULE_TYPE_KEYS`. (Frontend + logs rather than throws — a thrown error at import would brick the + whole bundle, not just the editor — the one intentional divergence + from the backend's raising `_assert_rule_handler_coverage`.) -**Estimated scope:** half a session. +Adding a new rule type now means: one entry in `RULE_TYPE_KEYS`, +`RULE_TYPE_ICONS`, and each of the three registries — and tsc + the +coverage check flag any omission. + +Verified: tsc + bundle build clean; typescript-reviewer APPROVE (the +extracted renderer bodies are byte-identical to the originals; no stray +closure captures; http_poll widget-stash + HA entity loading preserved). ### MEDIUM @@ -161,16 +181,66 @@ extract the frame loop into a separate `PreviewFrameLoop` class. **Estimated scope:** half a session. Low impact since the parallel-change problem is already fixed. -#### M7 — No shared frontend API client +#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27) **File:** every `static/js/features/*.ts` `fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every -feature's save / load function. ~25 files. +feature's save / load function. ~45 files, ~243 call sites. -**Approach:** introduce `static/js/core/api-client.ts` with typed -methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing, -error normalisation. Replace `fetchWithAuth` calls across features. +**Done:** `static/js/core/api-client.ts` now provides typed +`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap +`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline +toast are unchanged) and collapse the repeated +`if (!resp.ok) { detail || HTTP } … resp.json()` dance into one +call returning a typed body and throwing `ApiError` on failure. The +`detail` unwrap is hardened to join FastAPI validation arrays instead of +stringifying to `[object Object]`. **35 feature/core files migrated** +(covers GET/POST/PUT/DELETE, typed response bodies, custom i18n error +messages, silent-failure GETs, bulk `Promise.allSettled` deletes, +inline-error saves, array-`detail` joins, fire-and-forget POSTs, and +local catch handling) — reviewer-approved for behaviour parity across +the riskier divergences. Migrated files include the integration sources +(weather / HA / MQTT / HTTP), the template families (capture / audio / +audio-processing / pattern), the scene-preset CRUD, the simple-CRUD +entity files (sync-clocks / audio-sources / game-integration / +gradient / displays / device-discovery), the light-target editors +(z2m / ha), the preferences modules (dashboard-layout / card-modes / +notifications-watcher), the calibration editors (simple + advanced), +the entire `automations.ts` and `devices.ts` CRUD surfaces, and several +core utilities (`api-client.ts` itself, `cache.ts`, `command-palette.ts`, +`graph-connections.ts`, `tag-input.ts`, `process-picker.ts`, +`perf-charts.ts`, `icon-picker.ts`, `update.ts`, `integrations.ts`). + +Also added **14 new locale keys** (en / ru / zh) so the fallback +messages the migration surfaces — `pattern.error.save_failed`, +`audio_processing.error.save_failed`, `audio_template.error.save_failed`, +`audio_template.error.load_failed`, `templates.error.save_failed`, +`templates.error.load_failed`, `gradient.error.save_failed`, +`target.error.load_failed`, `device.error.load_failed`, +`automations.error.{load,save,delete,toggle}_failed`, plus +`gradient.error.delete_failed` for ru/zh — are translated instead of +hardcoded English. A scan confirms **no `errorMessage: ''` +strings remain** in the migrated diff. + +**Remaining:** 9 feature files (~94 call sites). All but one are the +big god-modules whose migration is best done as part of their C8/C9/C10 +splits: `streams.ts` (18), `settings.ts` (18), `targets.ts` (16), +`dashboard.ts` (15), `color-strips/index.ts` (8), `graph-editor.ts` (7), +`assets.ts` (6 — also blocked by multipart upload + blob download paths +that legitimately bypass the JSON client), and `value-sources.ts` (5). +The lone leaf file still on `fetchWithAuth` is `pairing-flow.ts` (1) — +its branching on raw `Response.status` codes (200 / 409 / 4xx) doesn't +fit the api-client contract, so it stays on raw fetch by design. +Migration is mechanical but **not** a blind find/replace — each site +carries its own localised error key that must be preserved as the +`errorMessage` option, and binary/multipart endpoints (e.g. +`assets.ts` file upload / blob download) must stay on raw +`fetchWithAuth` (the client is JSON-only). Each migrated file ideally +gets manual UI smoke-testing. **Behaviour note:** migrated GET sites now +prefer the server's `detail` over the generic localised fallback when +present — matching what the write paths already did; intended, but +user-visible. #### M8 — Global `_cached*` `let` vars @@ -262,7 +332,11 @@ always start before reading). ### Other frontend (severity in main list above) -- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files +- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC) + split into 18 per-entity files under `types/` (joining the existing + `bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so + every `import { … } from '../types.ts'` still resolves. Reviewer + confirmed all 102 exported symbols preserved, none renamed. - **H7** — `device-discovery.ts` 1745 LOC (couple with H4) - **H8** — `automations.ts` 1410 LOC (mirror H2) - **M7** — shared API client @@ -299,6 +373,13 @@ Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above. Critical to have typescript-reviewer feedback + manual UI testing after each split. +> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above +> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts` +> + 3 reference migrations). H8 (automations registry) also landed. Still +> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1. +> Next per the order: introduce the API client everywhere (finish M7), +> then split `value-sources.ts` (C8). + ### Session B — Device redesign (1-2 sessions) Address H4 alone. Touches device storage + provider classes; needs a diff --git a/server/src/ledgrab/static/js/core/api-client.ts b/server/src/ledgrab/static/js/core/api-client.ts new file mode 100644 index 0000000..11bf3b2 --- /dev/null +++ b/server/src/ledgrab/static/js/core/api-client.ts @@ -0,0 +1,113 @@ +/** + * Typed REST client — one place for the request/parse/error-unwrap dance + * that every feature module used to hand-roll on top of `fetchWithAuth`. + * + * Before this module, ~25 feature files repeated the same shape: + * + * ```ts + * const resp = await fetchWithAuth(url, { method, body: JSON.stringify(p) }); + * if (!resp.ok) { + * const err = await resp.json().catch(() => ({})); + * throw new Error(err.detail || `HTTP ${resp.status}`); + * } + * const data = await resp.json(); + * ``` + * + * `apiGet` / `apiPost` / `apiPut` / `apiDelete` collapse that to a single + * call that returns the parsed body (typed via the caller's ``) and + * throws an {@link ApiError} carrying the server's `detail` on failure. + * + * Auth headers, the 401 → re-login flow, timeouts, the 5xx/network retry + * loop, and the offline-toast are all still owned by `fetchWithAuth`; this + * is a thin typed layer on top, not a replacement. + * + * Audit finding M7. + */ + +import { fetchWithAuth, ApiError } from './api.ts'; + +export interface ApiRequestOpts { + /** + * Message for the thrown {@link ApiError} when the server returns a + * non-2xx status *and* provides no usable `detail`. Defaults to + * `HTTP `. Pass a localised string (e.g. `t('foo.error.save')`) + * to preserve the bespoke per-feature messages. + */ + errorMessage?: string; + /** Abort signal forwarded to `fetchWithAuth`. */ + signal?: AbortSignal; + /** Per-request timeout in ms (default 10 000, owned by `fetchWithAuth`). */ + timeout?: number; + /** Disable the 5xx/network auto-retry loop (default: enabled). */ + retry?: boolean; +} + +/** + * Turn a `Response` into a parsed body or throw {@link ApiError}. + * + * `detail` handling mirrors — and slightly hardens — the old hand-rolled + * pattern: a string `detail` is used verbatim; FastAPI validation errors + * (an array of `{msg, ...}`) are joined instead of stringifying to + * `[object Object]`; otherwise we fall back to `errorMessage` then + * `HTTP `. + */ +async function unwrap(resp: Response, opts?: ApiRequestOpts): Promise { + if (!resp.ok) { + const body = await resp.json().catch(() => ({} as Record)); + const detail = (body as { detail?: unknown }).detail; + let message: string; + if (typeof detail === 'string' && detail) { + message = detail; + } else if (Array.isArray(detail) && detail.length > 0) { + message = detail + .map((d) => (d && typeof d === 'object' && 'msg' in d ? String((d as { msg: unknown }).msg) : String(d))) + .join('; '); + } else { + message = opts?.errorMessage || `HTTP ${resp.status}`; + } + throw new ApiError(resp.status, message); + } + // 204 No Content (and other empty bodies) — nothing to parse. + if (resp.status === 204) return undefined as T; + const text = await resp.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +function buildOpts(method: string, body: unknown, opts?: ApiRequestOpts): RequestInit & { retry?: boolean; timeout?: number } { + const init: RequestInit & { retry?: boolean; timeout?: number } = { method }; + if (body !== undefined) init.body = JSON.stringify(body); + if (opts?.signal) init.signal = opts.signal; + if (opts?.timeout !== undefined) init.timeout = opts.timeout; + if (opts?.retry !== undefined) init.retry = opts.retry; + return init; +} + +/** `GET ` → parsed JSON body of type `T`. */ +export async function apiGet(path: string, opts?: ApiRequestOpts): Promise { + const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts)); + return unwrap(resp, opts); +} + +/** `POST ` with a JSON body → parsed JSON body of type `T`. */ +export async function apiPost(path: string, body?: unknown, opts?: ApiRequestOpts): Promise { + const resp = await fetchWithAuth(path, buildOpts('POST', body, opts)); + return unwrap(resp, opts); +} + +/** `PUT ` with a JSON body → parsed JSON body of type `T`. */ +export async function apiPut(path: string, body?: unknown, opts?: ApiRequestOpts): Promise { + const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts)); + return unwrap(resp, opts); +} + +/** `PATCH ` with a JSON body → parsed JSON body of type `T`. */ +export async function apiPatch(path: string, body?: unknown, opts?: ApiRequestOpts): Promise { + const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts)); + return unwrap(resp, opts); +} + +/** `DELETE ` → parsed JSON body of type `T` (often `void`/204). */ +export async function apiDelete(path: string, opts?: ApiRequestOpts): Promise { + const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts)); + return unwrap(resp, opts); +} diff --git a/server/src/ledgrab/static/js/core/cache.ts b/server/src/ledgrab/static/js/core/cache.ts index 42b804e..3d4b3bf 100644 --- a/server/src/ledgrab/static/js/core/cache.ts +++ b/server/src/ledgrab/static/js/core/cache.ts @@ -2,7 +2,8 @@ * Reusable data cache with fetch deduplication, invalidation, and subscribers. */ -import { fetchWithAuth, ApiError } from './api.ts'; +import { ApiError } from './api.ts'; +import { apiGet } from './api-client.ts'; // Server JSON is treated as `any` at the cache boundary because each // extractor knows the endpoint-specific shape (e.g. `json.devices`). @@ -66,19 +67,18 @@ export class DataCache { async _doFetch(): Promise { try { - const resp = await fetchWithAuth(this._endpoint); - if (!resp.ok) { - console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`); - return this._data; - } - const json = await resp.json(); + const json = await apiGet(this._endpoint); this._data = this._extractData(json); this._fresh = true; this._notify(); return this._data; } catch (err: unknown) { if (err instanceof ApiError && err.isAuth) return this._data; - console.error(`Cache fetch ${this._endpoint}:`, err); + if (err instanceof ApiError) { + console.error(`[DataCache] ${this._endpoint}: HTTP ${err.status}`); + } else { + console.error(`Cache fetch ${this._endpoint}:`, err); + } return this._data; } } diff --git a/server/src/ledgrab/static/js/core/command-palette.ts b/server/src/ledgrab/static/js/core/command-palette.ts index 5f1745e..595afe6 100644 --- a/server/src/ledgrab/static/js/core/command-palette.ts +++ b/server/src/ledgrab/static/js/core/command-palette.ts @@ -2,7 +2,8 @@ * Command Palette — global search & navigation (Ctrl+K / Cmd+K). */ -import { fetchWithAuth, escapeHtml } from './api.ts'; +import { escapeHtml } from './api.ts'; +import { apiGet, apiPost } from './api-client.ts'; import { t } from './i18n.ts'; import { navigateToCard } from './navigation.ts'; import { @@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) { action: async () => { const isRunning = actionItem._running; const endpoint = isRunning ? 'stop' : 'start'; - const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' }); - if (resp.ok) { + try { + await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, { + errorMessage: t(`target.error.${endpoint}_failed`), + }); showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success'); actionItem._running = !isRunning; actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start'); actionItem.icon = !isRunning ? '■' : '▶'; _render(); - } else { - const err = await resp.json().catch(() => ({})); - const d = err.detail || err.message || ''; - const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); - showToast(ds || t(`target.error.${endpoint}_failed`), 'error'); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t(`target.error.${endpoint}_failed`), 'error'); } }, }; @@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) { action: async () => { const isEnabled = autoItem._enabled; const endpoint = isEnabled ? 'disable' : 'enable'; - const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' }); - if (resp.ok) { + try { + await apiPost(`/automations/${a.id}/${endpoint}`, undefined, { + errorMessage: t('search.action.' + endpoint) + ' failed', + }); showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success'); autoItem._enabled = !isEnabled; autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable'); _render(); - } else { - const err = await resp.json().catch(() => ({})); - const d = err.detail || err.message || ''; - const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); - showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error'); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error'); } }, }; @@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) { items.push({ name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡', action: async () => { - const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' }); - if (resp.ok) { showToast(t('scenes.activated'), 'success'); } - else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); } + try { + await apiPost(`/scene-presets/${sp.id}/activate`, undefined, { + errorMessage: t('scenes.error.activate_failed'), + }); + showToast(t('scenes.activated'), 'success'); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t('scenes.error.activate_failed'), 'error'); + } }, }); }); @@ -209,14 +216,12 @@ const _responseKeys = [ async function _fetchAllEntities() { const [statesData, ...results] = await Promise.all([ - fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 }) - .then(r => r.ok ? r.json() : {}) - .then((data: any) => data.states || {}) + apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 }) + .then((data) => data.states || {}) .catch(() => ({})), ..._responseKeys.map(([ep, key]) => - fetchWithAuth(ep as string, { retry: false, timeout: 5000 }) - .then((r: any) => r.ok ? r.json() : {}) - .then((data: any) => data[key as string] || []) + apiGet(ep as string, { retry: false, timeout: 5000 }) + .then((data) => data[key as string] || []) .catch((): any[] => [])), ]); return _buildItems(results, statesData); diff --git a/server/src/ledgrab/static/js/core/graph-connections.ts b/server/src/ledgrab/static/js/core/graph-connections.ts index e0e4be6..5f2890d 100644 --- a/server/src/ledgrab/static/js/core/graph-connections.ts +++ b/server/src/ledgrab/static/js/core/graph-connections.ts @@ -3,7 +3,7 @@ * Supports creating, changing, and detaching connections via the graph editor. */ -import { fetchWithAuth } from './api.ts'; +import { apiPut } from './api-client.ts'; import { streamsCache, colorStripSourcesCache, valueSourcesCache, audioSourcesCache, outputTargetsCache, automationsCacheObj, @@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie const body = { [field]: newSourceId }; try { - const resp = await fetchWithAuth(url, { - method: 'PUT', - body: JSON.stringify(body), - }); - if (!resp.ok) return false; + await apiPut(url, body); // Invalidate the relevant cache so data refreshes if (entry.cache) entry.cache.invalidate(); return true; diff --git a/server/src/ledgrab/static/js/core/process-picker.ts b/server/src/ledgrab/static/js/core/process-picker.ts index 5146d8f..0aa0024 100644 --- a/server/src/ledgrab/static/js/core/process-picker.ts +++ b/server/src/ledgrab/static/js/core/process-picker.ts @@ -22,7 +22,8 @@ * attachProcessPicker(container, textarea); */ -import { fetchWithAuth, escapeHtml } from './api.ts'; +import { escapeHtml } from './api.ts'; +import { apiGet } from './api-client.ts'; import { t } from './i18n.ts'; import { ICON_SEARCH } from './icons.ts'; @@ -241,16 +242,21 @@ class NamePalette { /* ─── fetch helpers ────────────────────────────────────────── */ async function _fetchProcesses(): Promise { - const resp = await fetchWithAuth('/system/processes'); - if (!resp || !resp.ok) return []; - const data = await resp.json(); - return data.processes || []; + try { + const data = await apiGet<{ processes?: string[] }>('/system/processes'); + return data.processes || []; + } catch { + return []; + } } async function _fetchNotificationApps(): Promise { - const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history'); - if (!resp || !resp.ok) return []; - const data = await resp.json(); + let data: { history?: any[] }; + try { + data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history'); + } catch { + return []; + } const history: any[] = data.history || []; // Deduplicate app names, preserving original case of first occurrence const seen = new Map(); diff --git a/server/src/ledgrab/static/js/core/tag-input.ts b/server/src/ledgrab/static/js/core/tag-input.ts index b9e9e1b..0d750ba 100644 --- a/server/src/ledgrab/static/js/core/tag-input.ts +++ b/server/src/ledgrab/static/js/core/tag-input.ts @@ -13,7 +13,7 @@ * Tags are stored lowercase, trimmed, deduplicated. */ -import { fetchWithAuth } from './api.ts'; +import { apiGet } from './api-client.ts'; let _allTagsCache: string[] | null = null; let _allTagsFetchPromise: Promise | null = null; @@ -22,8 +22,7 @@ let _allTagsFetchPromise: Promise | null = null; export async function fetchAllTags(): Promise { if (_allTagsCache) return _allTagsCache; if (_allTagsFetchPromise) return _allTagsFetchPromise; - _allTagsFetchPromise = fetchWithAuth('/tags') - .then(r => r.json()) + _allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags') .then(data => { _allTagsCache = data.tags || []; _allTagsFetchPromise = null; diff --git a/server/src/ledgrab/static/js/features/advanced-calibration.ts b/server/src/ledgrab/static/js/features/advanced-calibration.ts index b2fc05c..d83cfa0 100644 --- a/server/src/ledgrab/static/js/features/advanced-calibration.ts +++ b/server/src/ledgrab/static/js/features/advanced-calibration.ts @@ -5,7 +5,8 @@ * The canvas shows monitor rectangles that can be repositioned for visual clarity. */ -import { API_BASE, fetchWithAuth } from '../core/api.ts'; +import { API_BASE } from '../core/api.ts'; +import { apiGet, apiPut } from '../core/api-client.ts'; import { colorStripSourcesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; @@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal(); export async function showAdvancedCalibration(cssId: string): Promise { try { - const [cssSources, psResp] = await Promise.all([ + const [cssSources, psData] = await Promise.all([ colorStripSourcesCache.fetch(), - fetchWithAuth('/picture-sources'), + apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})), ]); const source = cssSources.find(s => s.id === cssId); if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } const calibration: Calibration = source.calibration || {} as Calibration; - const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; + const psList = psData.streams || []; _state.cssId = cssId; _state.sourceType = source.source_type || 'picture_advanced'; @@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise { }; try { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, { - method: 'PUT', - body: JSON.stringify({ source_type: _state.sourceType, calibration }), + await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, { + errorMessage: t('calibration.error.save_failed'), }); - - if (resp.ok) { - showToast(t('calibration.saved'), 'success'); - colorStripSourcesCache.invalidate(); - _modal.forceClose(); - } else { - const err = await resp.json().catch(() => ({})); - const detail = err.detail || err.message || ''; - const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail); - showToast(detailStr || t('calibration.error.save_failed'), 'error'); - } - } catch (error) { + showToast(t('calibration.saved'), 'success'); + colorStripSourcesCache.invalidate(); + _modal.forceClose(); + } catch (error: any) { if (error.isAuth) return; showToast(error.message || t('calibration.error.save_failed'), 'error'); } diff --git a/server/src/ledgrab/static/js/features/audio-processing-templates.ts b/server/src/ledgrab/static/js/features/audio-processing-templates.ts index 4f19507..be454e5 100644 --- a/server/src/ledgrab/static/js/features/audio-processing-templates.ts +++ b/server/src/ledgrab/static/js/features/audio-processing-templates.ts @@ -15,7 +15,8 @@ import { _cachedAudioFilterDefs, audioFilterDefsCache, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) { try { if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch(); - const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`); - if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); - const tmpl = await response.json(); + const tmpl = await apiGet(`/audio-processing-templates/${templateId}`); document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`; (document.getElementById('apt-id') as HTMLInputElement).value = templateId; @@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() { }; try { - const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates'; - const method = templateId ? 'PUT' : 'POST'; - const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to save template'); + if (templateId) { + await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') }); + } else { + await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') }); } showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success'); @@ -235,9 +231,7 @@ export async function saveAudioProcessingTemplate() { export async function cloneAudioProcessingTemplate(templateId: string) { try { - const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`); - if (!resp.ok) throw new Error('Failed to load template'); - const tmpl = await resp.json(); + const tmpl = await apiGet(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') }); await showAudioProcessingTemplateModal(tmpl); } catch (error: any) { if (error.isAuth) return; @@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) { if (!confirmed) return; try { - const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to delete template'); - } + await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') }); showToast(t('audio_processing.deleted'), 'success'); audioProcessingTemplatesCache.invalidate(); await loadPictureSources(); diff --git a/server/src/ledgrab/static/js/features/audio-sources.ts b/server/src/ledgrab/static/js/features/audio-sources.ts index 8c879c4..6c4e92f 100644 --- a/server/src/ledgrab/static/js/features/audio-sources.ts +++ b/server/src/ledgrab/static/js/features/audio-sources.ts @@ -11,7 +11,8 @@ */ import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { API_BASE, escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -178,16 +179,10 @@ export async function saveAudioSource() { } try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/audio-sources/${id}` : '/audio-sources'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/audio-sources/${id}`, payload); + } else { + await apiPost('/audio-sources', payload); } showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); audioSourceModal.forceClose(); @@ -203,9 +198,7 @@ export async function saveAudioSource() { export async function editAudioSource(sourceId: any) { try { - const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); - if (!resp.ok) throw new Error(t('audio_source.error.load')); - const data = await resp.json(); + const data = await apiGet(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') }); await showAudioSourceModal(data.source_type, data); } catch (e: any) { if (e.isAuth) return; @@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) { export async function cloneAudioSource(sourceId: any) { try { - const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); - if (!resp.ok) throw new Error(t('audio_source.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showAudioSourceModal(data.source_type, data); + const data = await apiGet(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') }); + const { id: _omit, ...rest } = data; + await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` }); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) { if (!confirmed) return; try { - const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/audio-sources/${sourceId}`); showToast(t('audio_source.deleted'), 'success'); audioSourcesCache.invalidate(); await loadPictureSources(); @@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {}; async function _loadAudioDevices() { try { - const resp = await fetchWithAuth('/audio-devices'); - if (!resp.ok) throw new Error('fetch failed'); - const data = await resp.json(); + const data = await apiGet<{ by_engine?: Record }>('/audio-devices'); _cachedDevicesByEngine = data.by_engine || {}; } catch { _cachedDevicesByEngine = {}; diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 83da412..13b16dd 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -10,7 +10,8 @@ import { } from '../core/state.ts'; import { prefetchHAEntities } from './home-assistant-sources.ts'; import { getHAEntityIcon } from '../core/icons.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -31,7 +32,7 @@ import { enhanceMiniSelects } from '../core/mini-select.ts'; import { attachProcessPicker } from '../core/process-picker.ts'; import { TreeNav } from '../core/tree-nav.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; -import type { Automation } from '../types.ts'; +import type { Automation, RuleType } from '../types.ts'; registerIconEntityType('automation', makeSimpleIconAdapter({ cache: automationsCacheObj, @@ -53,9 +54,7 @@ let _haRuleEntities: any[] = []; async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise { if (!haSourceId) { _haRuleEntities = []; return; } try { - const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); - if (!resp.ok) { _haRuleEntities = []; return; } - const data = await resp.json(); + const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`); _haRuleEntities = data.entities || []; // Mirror into the shared cache so automation/value-source card // chips pick up friendly names on the next render. @@ -134,10 +133,8 @@ const automationModal = new AutomationEditorModal(); // ── Bulk action handlers ── async function _bulkEnableAutomations(ids: any) { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' }) - )); - const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + const results = await Promise.allSettled(ids.map((id: string) => apiPost(`/automations/${id}/enable`))); + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning'); else showToast(t('automations.updated'), 'success'); automationsCacheObj.invalidate(); @@ -145,10 +142,8 @@ async function _bulkEnableAutomations(ids: any) { } async function _bulkDisableAutomations(ids: any) { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' }) - )); - const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + const results = await Promise.allSettled(ids.map((id: string) => apiPost(`/automations/${id}/disable`))); + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning'); else showToast(t('automations.updated'), 'success'); automationsCacheObj.invalidate(); @@ -156,10 +151,8 @@ async function _bulkDisableAutomations(ids: any) { } async function _bulkDeleteAutomations(ids: any) { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/automations/${id}`, { method: 'DELETE' }) - )); - const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + const results = await Promise.allSettled(ids.map((id: string) => apiDelete(`/automations/${id}`))); + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('automations.deleted'), 'success'); automationsCacheObj.invalidate(); @@ -314,7 +307,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts; icon + a tight, scannable label. Mirrors the AUTO card in the cards-redesign demo: rules read as a left-to-right chain leading into the scene activation. */ -const RULE_CHIP_RENDERERS: Record = { +const RULE_CHIP_RENDERERS: Record = { startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }), application: (c) => { const apps = (c.apps || []).join(', ') || '—'; @@ -584,9 +577,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any) if (automationId) { titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`; try { - const resp = await fetchWithAuth(`/automations/${automationId}`); - if (!resp.ok) throw new Error('Failed to load automation'); - const automation = await resp.json(); + const automation = await apiGet(`/automations/${automationId}`, { errorMessage: t('automations.error.load_failed') }); idInput.value = automation.id; nameInput.value = automation.name; @@ -749,7 +740,7 @@ export function addAutomationRule() { _autoGenerateAutomationName(); } -const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll']; +const RULE_TYPE_KEYS: RuleType[] = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll']; const RULE_TYPE_ICONS = { startup: P.power, application: P.smartphone, time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor, @@ -834,6 +825,357 @@ function _wireTimeRangePicker(container: HTMLElement) { sync(); } +// ===== Per-rule-type field renderers (registry) ===== +// +// Each function paints the editor fields for one rule type into the row's +// ``.rule-fields-container`` and wires any IconSelect/EntitySelect widgets. +// They close over nothing but their ``container`` argument — every other +// dependency (caches, helpers, widgets) is module-scoped — so they live at +// module level and are dispatched through ``RULE_FIELD_RENDERERS`` instead +// of the old if/elif ladder. Mirrors the backend +// ``AutomationEngine._RULE_HANDLERS`` shape (audit finding H8). + +type RuleFieldRenderer = (container: HTMLElement, data: any) => void; + +function _renderStartupFields(container: HTMLElement, _data: any): void { + container.innerHTML = `${t('automations.rule.startup.hint')}`; +} + +function _renderTimeOfDayFields(container: HTMLElement, data: any): void { + const startTime = data.start_time || '00:00'; + const endTime = data.end_time || '23:59'; + const [sh, sm] = startTime.split(':').map(Number); + const [eh, em] = endTime.split(':').map(Number); + const pad = (n: number) => String(n).padStart(2, '0'); + container.innerHTML = ` +
+ + +
+
+ ${t('automations.rule.time_of_day.start_time')} +
+ + : + +
+
+
+
+ ${t('automations.rule.time_of_day.end_time')} +
+ + : + +
+
+
+ ${t('automations.rule.time_of_day.overnight_hint')} +
`; + _wireTimeRangePicker(container); +} + +function _renderSystemIdleFields(container: HTMLElement, data: any): void { + const idleMinutes = data.idle_minutes ?? 5; + const whenIdle = data.when_idle ?? true; + container.innerHTML = ` +
+
+ + +
+
+ + +
+
`; + const idleSelect = container.querySelector('.rule-when-idle') as HTMLSelectElement; + new IconSelect({ + target: idleSelect, + items: [ + { value: 'true', icon: _icon(P.moon), label: t('automations.rule.system_idle.when_idle'), desc: t('automations.rule.system_idle.when_idle.desc') }, + { value: 'false', icon: _icon(P.activity), label: t('automations.rule.system_idle.when_active'), desc: t('automations.rule.system_idle.when_active.desc') }, + ], + columns: 2, + } as any); +} + +function _renderDisplayStateFields(container: HTMLElement, data: any): void { + const dState = data.state || 'on'; + container.innerHTML = ` +
+
+ + +
+
`; + enhanceMiniSelects(container, 'select.rule-display-state'); +} + +function _renderMqttFields(container: HTMLElement, data: any): void { + const topic = data.topic || ''; + const payload = data.payload || ''; + const matchMode = data.match_mode || 'exact'; + container.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+
`; + enhanceMiniSelects(container, 'select.rule-mqtt-match-mode'); +} + +function _renderHomeAssistantFields(container: HTMLElement, data: any): void { + const haSourceId = data.ha_source_id || ''; + const entityId = data.entity_id || ''; + const haState = data.state || ''; + const matchMode = data.match_mode || 'exact'; + const haOptions = _cachedHASources.map((s: any) => + `` + ).join(''); + container.innerHTML = ` +
+ ${t('automations.rule.home_assistant.hint')} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
`; + + // Wire HA source EntitySelect + const haSrcSelect = container.querySelector('.rule-ha-source-id') as HTMLSelectElement; + new EntitySelect({ + target: haSrcSelect, + getItems: () => _cachedHASources.map((s: any) => ({ + value: s.id, label: s.name, icon: _icon(P.home), + desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'), + })), + placeholder: t('palette.search'), + onChange: (newId: string) => _loadHAEntitiesForRule(newId, container), + }); + + // Wire entity EntitySelect + const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement; + const entityES = new EntitySelect({ + target: entitySelect, + getItems: () => _haRuleEntities.map((e: any) => ({ + value: e.entity_id, label: e.friendly_name || e.entity_id, + icon: getHAEntityIcon(e), desc: e.state || '', + })), + placeholder: t('ha_light.mapping.search_entity'), + }); + // Store ref so _loadHAEntitiesForRule can refresh the trigger display + (entitySelect as any)._entitySelect = entityES; + + // Wire match mode IconSelect + const matchSelect = container.querySelector('.rule-ha-match-mode') as HTMLSelectElement; + new IconSelect({ + target: matchSelect, + items: [ + { value: 'exact', icon: _icon(P.check), label: t('automations.rule.mqtt.match_mode.exact'), desc: t('automations.rule.ha.match_mode.exact.desc') }, + { value: 'contains', icon: _icon(P.search), label: t('automations.rule.mqtt.match_mode.contains'), desc: t('automations.rule.ha.match_mode.contains.desc') }, + { value: 'regex', icon: _icon(P.code), label: t('automations.rule.mqtt.match_mode.regex'), desc: t('automations.rule.ha.match_mode.regex.desc') }, + ], + columns: 1, + }); + + // Load entities if source is already selected + if (haSourceId) _loadHAEntitiesForRule(haSourceId, container); +} + +function _renderHttpPollFields(container: HTMLElement, data: any): void { + const vsId = data.value_source_id || ''; + const operator = data.operator || 'equals'; + const valueStr = data.value || ''; + + container.innerHTML = ` +
+ ${t('automations.rule.http_poll.hint')} +
+ + +
+
+ + +
+
+ + +
+
`; + + // Pull only HTTP value sources (source_type === 'http') + const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http'); + + // Wire EntitySelect for the value source picker + const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement; + // Pre-populate the option so EntitySelect can sync display text. + vsSelect.innerHTML = `` + + httpVs.map((v: any) => ``).join(''); + vsSelect.value = vsId || ''; + const vsEntitySelect = new EntitySelect({ + target: vsSelect, + getItems: () => (_cachedValueSources || []) + .filter((v: any) => v.source_type === 'http') + .map((v: any) => ({ + value: v.id, + label: v.name, + icon: _icon(P.globe), + desc: v.json_path || t('automations.rule.http_poll.raw_body'), + })), + placeholder: t('palette.search'), + }); + + // Wire IconSelect for operator + const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement; + const opItems = HTTP_OP_KEYS.map(k => ({ + value: k, + icon: _icon(_httpOpIconPath(k)), + label: t('automations.rule.http_poll.operator.' + k), + desc: t('automations.rule.http_poll.operator.' + k + '.desc'), + })); + const opIconSelect = new IconSelect({ + target: opSelect, + items: opItems, + columns: 3, + onChange: (newOp: string) => { + // Hide the value field when operator is 'exists' + const valField = container.querySelector('.rule-http-value-field') as HTMLElement; + if (valField) valField.style.display = newOp === 'exists' ? 'none' : ''; + }, + }); + // Sync initial visibility based on the operator we just loaded. + const valField = container.querySelector('.rule-http-value-field') as HTMLElement; + if (valField) valField.style.display = operator === 'exists' ? 'none' : ''; + + // Stash both widgets so they can be destroyed when the row's + // rule type changes (renderFields re-entry) or the row is + // removed (button.btn-remove-rule onclick — calls + // _disposeHTTPPollWidgets via the row's data hook). + (container as any)._httpPollWidgets = { + vsEntitySelect, + opIconSelect, + }; +} + +function _renderWebhookFields(container: HTMLElement, data: any): void { + if (data.token) { + const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; + container.innerHTML = ` +
+ ${t('automations.rule.webhook.hint')} +
+ +
+ + +
+
+ +
`; + } else { + container.innerHTML = ` +
+ ${t('automations.rule.webhook.hint')} +

${t('automations.rule.webhook.save_first')}

+
`; + } +} + +function _renderApplicationFields(container: HTMLElement, data: any): void { + const appsValue = (data.apps || []).join('\n'); + const matchType = data.match_type || 'running'; + container.innerHTML = ` +
+
+ + +
+
+
+ + +
+ +
+
+ `; + const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement; + attachProcessPicker(container, textarea); + + // Attach IconSelect to match type + const matchSel = container.querySelector('.rule-match-type'); + if (matchSel) { + new IconSelect({ + target: matchSel, + items: _buildMatchTypeItems(), + columns: 2, + } as any); + } +} + +const RULE_FIELD_RENDERERS: Record = { + startup: _renderStartupFields, + application: _renderApplicationFields, + time_of_day: _renderTimeOfDayFields, + system_idle: _renderSystemIdleFields, + display_state: _renderDisplayStateFields, + mqtt: _renderMqttFields, + webhook: _renderWebhookFields, + home_assistant: _renderHomeAssistantFields, + http_poll: _renderHttpPollFields, +}; + function addAutomationRuleRow(rule: any) { const list = document.getElementById('automation-rules-list'); const row = document.createElement('div'); @@ -882,336 +1224,12 @@ function addAutomationRuleRow(rule: any) { function renderFields(type: any, data: any) { // Tear down any widgets the previous renderFields call attached - // (EntitySelect/IconSelect portal overlays to document.body, so - // a bare ``container.innerHTML = …`` leaves them in the registry). + // (EntitySelect/IconSelect portal overlays to document.body, so a + // bare ``container.innerHTML = …`` leaves them in the registry) + // before the per-type renderer repaints the container. _disposeHTTPPollWidgets(container); - - if (type === 'startup') { - container.innerHTML = `${t('automations.rule.startup.hint')}`; - return; - } - if (type === 'time_of_day') { - const startTime = data.start_time || '00:00'; - const endTime = data.end_time || '23:59'; - const [sh, sm] = startTime.split(':').map(Number); - const [eh, em] = endTime.split(':').map(Number); - const pad = (n: number) => String(n).padStart(2, '0'); - container.innerHTML = ` -
- - -
-
- ${t('automations.rule.time_of_day.start_time')} -
- - : - -
-
-
-
- ${t('automations.rule.time_of_day.end_time')} -
- - : - -
-
-
- ${t('automations.rule.time_of_day.overnight_hint')} -
`; - _wireTimeRangePicker(container); - return; - } - if (type === 'system_idle') { - const idleMinutes = data.idle_minutes ?? 5; - const whenIdle = data.when_idle ?? true; - container.innerHTML = ` -
-
- - -
-
- - -
-
`; - const idleSelect = container.querySelector('.rule-when-idle') as HTMLSelectElement; - new IconSelect({ - target: idleSelect, - items: [ - { value: 'true', icon: _icon(P.moon), label: t('automations.rule.system_idle.when_idle'), desc: t('automations.rule.system_idle.when_idle.desc') }, - { value: 'false', icon: _icon(P.activity), label: t('automations.rule.system_idle.when_active'), desc: t('automations.rule.system_idle.when_active.desc') }, - ], - columns: 2, - } as any); - return; - } - if (type === 'display_state') { - const dState = data.state || 'on'; - container.innerHTML = ` -
-
- - -
-
`; - enhanceMiniSelects(container, 'select.rule-display-state'); - return; - } - if (type === 'mqtt') { - const topic = data.topic || ''; - const payload = data.payload || ''; - const matchMode = data.match_mode || 'exact'; - container.innerHTML = ` -
-
- - -
-
- - -
-
- - -
-
`; - enhanceMiniSelects(container, 'select.rule-mqtt-match-mode'); - return; - } - if (type === 'home_assistant') { - const haSourceId = data.ha_source_id || ''; - const entityId = data.entity_id || ''; - const haState = data.state || ''; - const matchMode = data.match_mode || 'exact'; - const haOptions = _cachedHASources.map((s: any) => - `` - ).join(''); - container.innerHTML = ` -
- ${t('automations.rule.home_assistant.hint')} -
- - -
-
- - -
-
- - -
-
- - -
-
`; - - // Wire HA source EntitySelect - const haSrcSelect = container.querySelector('.rule-ha-source-id') as HTMLSelectElement; - new EntitySelect({ - target: haSrcSelect, - getItems: () => _cachedHASources.map((s: any) => ({ - value: s.id, label: s.name, icon: _icon(P.home), - desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'), - })), - placeholder: t('palette.search'), - onChange: (newId: string) => _loadHAEntitiesForRule(newId, container), - }); - - // Wire entity EntitySelect - const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement; - const entityES = new EntitySelect({ - target: entitySelect, - getItems: () => _haRuleEntities.map((e: any) => ({ - value: e.entity_id, label: e.friendly_name || e.entity_id, - icon: getHAEntityIcon(e), desc: e.state || '', - })), - placeholder: t('ha_light.mapping.search_entity'), - }); - // Store ref so _loadHAEntitiesForRule can refresh the trigger display - (entitySelect as any)._entitySelect = entityES; - - // Wire match mode IconSelect - const matchSelect = container.querySelector('.rule-ha-match-mode') as HTMLSelectElement; - new IconSelect({ - target: matchSelect, - items: [ - { value: 'exact', icon: _icon(P.check), label: t('automations.rule.mqtt.match_mode.exact'), desc: t('automations.rule.ha.match_mode.exact.desc') }, - { value: 'contains', icon: _icon(P.search), label: t('automations.rule.mqtt.match_mode.contains'), desc: t('automations.rule.ha.match_mode.contains.desc') }, - { value: 'regex', icon: _icon(P.code), label: t('automations.rule.mqtt.match_mode.regex'), desc: t('automations.rule.ha.match_mode.regex.desc') }, - ], - columns: 1, - }); - - // Load entities if source is already selected - if (haSourceId) _loadHAEntitiesForRule(haSourceId, container); - - return; - } - if (type === 'http_poll') { - const vsId = data.value_source_id || ''; - const operator = data.operator || 'equals'; - const valueStr = data.value || ''; - - container.innerHTML = ` -
- ${t('automations.rule.http_poll.hint')} -
- - -
-
- - -
-
- - -
-
`; - - // Pull only HTTP value sources (source_type === 'http') - const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http'); - - // Wire EntitySelect for the value source picker - const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement; - // Pre-populate the option so EntitySelect can sync display text. - vsSelect.innerHTML = `` + - httpVs.map((v: any) => ``).join(''); - vsSelect.value = vsId || ''; - const vsEntitySelect = new EntitySelect({ - target: vsSelect, - getItems: () => (_cachedValueSources || []) - .filter((v: any) => v.source_type === 'http') - .map((v: any) => ({ - value: v.id, - label: v.name, - icon: _icon(P.globe), - desc: v.json_path || t('automations.rule.http_poll.raw_body'), - })), - placeholder: t('palette.search'), - }); - - // Wire IconSelect for operator - const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement; - const opItems = HTTP_OP_KEYS.map(k => ({ - value: k, - icon: _icon(_httpOpIconPath(k)), - label: t('automations.rule.http_poll.operator.' + k), - desc: t('automations.rule.http_poll.operator.' + k + '.desc'), - })); - const opIconSelect = new IconSelect({ - target: opSelect, - items: opItems, - columns: 3, - onChange: (newOp: string) => { - // Hide the value field when operator is 'exists' - const valField = container.querySelector('.rule-http-value-field') as HTMLElement; - if (valField) valField.style.display = newOp === 'exists' ? 'none' : ''; - }, - }); - // Sync initial visibility based on the operator we just loaded. - const valField = container.querySelector('.rule-http-value-field') as HTMLElement; - if (valField) valField.style.display = operator === 'exists' ? 'none' : ''; - - // Stash both widgets so they can be destroyed when the row's - // rule type changes (renderFields re-entry) or the row is - // removed (button.btn-remove-rule onclick — calls - // _disposeHTTPPollWidgets via the row's data hook). - (container as any)._httpPollWidgets = { - vsEntitySelect, - opIconSelect, - }; - - return; - } - if (type === 'webhook') { - if (data.token) { - const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; - container.innerHTML = ` -
- ${t('automations.rule.webhook.hint')} -
- -
- - -
-
- -
`; - } else { - container.innerHTML = ` -
- ${t('automations.rule.webhook.hint')} -

${t('automations.rule.webhook.save_first')}

-
`; - } - return; - } - const appsValue = (data.apps || []).join('\n'); - const matchType = data.match_type || 'running'; - container.innerHTML = ` -
-
- - -
-
-
- - -
- -
-
- `; - const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement; - attachProcessPicker(container, textarea); - - // Attach IconSelect to match type - const matchSel = container.querySelector('.rule-match-type'); - if (matchSel) { - new IconSelect({ - target: matchSel, - items: _buildMatchTypeItems(), - columns: 2, - } as any); - } + const renderer = RULE_FIELD_RENDERERS[type as RuleType] ?? RULE_FIELD_RENDERERS.application; + renderer(container, data); } renderFields(ruleType, rule); @@ -1224,69 +1242,101 @@ function addAutomationRuleRow(rule: any) { +// ===== Per-rule-type form collectors (registry) ===== +// +// Each collector reads one rule row's inputs back into the rule object the +// API expects. Dispatched through ``RULE_COLLECTORS`` — the serialise-side +// mirror of ``RULE_FIELD_RENDERERS`` and the backend ``_RULE_HANDLERS``. + +type RuleCollector = (row: Element) => Record; + +const RULE_COLLECTORS: Record = { + startup: () => ({ rule_type: 'startup' }), + time_of_day: (row) => ({ + rule_type: 'time_of_day', + start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', + end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', + }), + system_idle: (row) => ({ + rule_type: 'system_idle', + idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5, + when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true', + }), + display_state: (row) => ({ + rule_type: 'display_state', + state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on', + }), + mqtt: (row) => ({ + rule_type: 'mqtt', + topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(), + payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value, + match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact', + }), + webhook: (row) => { + const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement; + const r: any = { rule_type: 'webhook' }; + if (tokenInput && tokenInput.value) r.token = tokenInput.value; + return r; + }, + home_assistant: (row) => ({ + rule_type: 'home_assistant', + ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value, + entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(), + state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value, + match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact', + }), + http_poll: (row) => { + const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals'; + const r: any = { + rule_type: 'http_poll', + value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value, + operator: op, + }; + // The 'exists' operator has no comparison value. + if (op !== 'exists') { + r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value; + } + return r; + }, + application: (row) => { + const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; + const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); + const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; + return { rule_type: 'application', apps, match_type: matchType }; + }, +}; + +/** Every rule type listed in ``RULE_TYPE_KEYS`` must have a chip renderer, + * a field renderer, and a collector. Mirrors the backend's + * ``_assert_rule_handler_coverage`` (which raises at boot). On the + * frontend we log loudly instead of throwing — a thrown error at module + * import would brick the entire bundle, not just the automations editor. */ +function _assertRuleHandlerCoverage(): void { + const expected = new Set(RULE_TYPE_KEYS); + const registries: Array<[string, string[]]> = [ + ['RULE_CHIP_RENDERERS', Object.keys(RULE_CHIP_RENDERERS)], + ['RULE_FIELD_RENDERERS', Object.keys(RULE_FIELD_RENDERERS)], + ['RULE_COLLECTORS', Object.keys(RULE_COLLECTORS)], + ]; + for (const [name, keys] of registries) { + const have = new Set(keys); + const missing = [...expected].filter(k => !have.has(k)); + const extra = [...have].filter(k => !expected.has(k)); + if (missing.length || extra.length) { + console.error(`[automations] ${name} out of sync with RULE_TYPE_KEYS`, { missing, extra }); + } + } +} +_assertRuleHandlerCoverage(); + function getAutomationEditorRules() { const rows = document.querySelectorAll('#automation-rules-list .automation-rule-row'); const rules: any[] = []; rows.forEach(row => { const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement; - const ruleType = typeSelect ? typeSelect.value : 'application'; - if (ruleType === 'startup') { - rules.push({ rule_type: 'startup' }); - } else if (ruleType === 'time_of_day') { - rules.push({ - rule_type: 'time_of_day', - start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', - end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', - }); - } else if (ruleType === 'system_idle') { - rules.push({ - rule_type: 'system_idle', - idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5, - when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true', - }); - } else if (ruleType === 'display_state') { - rules.push({ - rule_type: 'display_state', - state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on', - }); - } else if (ruleType === 'mqtt') { - rules.push({ - rule_type: 'mqtt', - topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(), - payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value, - match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact', - }); - } else if (ruleType === 'webhook') { - const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement; - const r: any = { rule_type: 'webhook' }; - if (tokenInput && tokenInput.value) r.token = tokenInput.value; - rules.push(r); - } else if (ruleType === 'home_assistant') { - rules.push({ - rule_type: 'home_assistant', - ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value, - entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(), - state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value, - match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact', - }); - } else if (ruleType === 'http_poll') { - const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals'; - const r: any = { - rule_type: 'http_poll', - value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value, - operator: op, - }; - // The 'exists' operator has no comparison value. - if (op !== 'exists') { - r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value; - } - rules.push(r); - } else { - const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; - const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); - const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; - rules.push({ rule_type: 'application', apps, match_type: matchType }); - } + const ruleType = (typeSelect ? typeSelect.value : 'application') as RuleType; + const collect = RULE_COLLECTORS[ruleType] ?? RULE_COLLECTORS.application; + rules.push(collect(row)); }); return rules; } @@ -1320,14 +1370,10 @@ export async function saveAutomationEditor() { const isEdit = !!automationId; try { - const url = isEdit ? `/automations/${automationId}` : '/automations'; - const resp = await fetchWithAuth(url, { - method: isEdit ? 'PUT' : 'POST', - body: JSON.stringify(body), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || 'Failed to save automation'); + if (isEdit) { + await apiPut(`/automations/${automationId}`, body, { errorMessage: t('automations.error.save_failed') }); + } else { + await apiPost('/automations', body, { errorMessage: t('automations.error.save_failed') }); } automationModal.forceClose(); @@ -1343,18 +1389,14 @@ export async function saveAutomationEditor() { export async function toggleAutomationEnabled(automationId: any, enable: any) { try { const action = enable ? 'enable' : 'disable'; - const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, { - method: 'POST', + await apiPost(`/automations/${automationId}/${action}`, undefined, { + errorMessage: t('automations.error.toggle_failed'), }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `Failed to ${action} automation`); - } automationsCacheObj.invalidate(); loadAutomations(); } catch (e: any) { if (e.isAuth) return; - showToast(e.message, 'error'); + showToast(e.message || t('automations.error.toggle_failed'), 'error'); } } @@ -1377,9 +1419,7 @@ export function copyWebhookUrl(btn: any) { export async function cloneAutomation(automationId: any) { try { - const resp = await fetchWithAuth(`/automations/${automationId}`); - if (!resp.ok) throw new Error('Failed to load automation'); - const automation = await resp.json(); + const automation = await apiGet(`/automations/${automationId}`, { errorMessage: t('automations.error.load_failed') }); openAutomationEditor(null, automation); } catch (e: any) { if (e.isAuth) return; @@ -1393,18 +1433,12 @@ export async function deleteAutomation(automationId: any, automationName: any) { if (!confirmed) return; try { - const resp = await fetchWithAuth(`/automations/${automationId}`, { - method: 'DELETE', - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || 'Failed to delete automation'); - } + await apiDelete(`/automations/${automationId}`, { errorMessage: t('automations.error.delete_failed') }); showToast(t('automations.deleted'), 'success'); automationsCacheObj.invalidate(); loadAutomations(); } catch (e: any) { if (e.isAuth) return; - showToast(e.message, 'error'); + showToast(e.message || t('automations.error.delete_failed'), 'error'); } } diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index ec28860..8f82819 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -5,7 +5,8 @@ import { calibrationTestState, EDGE_TEST_COLORS, displaysCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts'; +import { API_BASE, getHeaders } from '../core/api.ts'; +import { apiGet, apiPut } from '../core/api-client.ts'; import { colorStripSourcesCache, devicesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; @@ -92,10 +93,7 @@ async function _clearCSSTestMode() { const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value; if (!testDeviceId) return; try { - await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { - method: 'PUT', - body: JSON.stringify({ device_id: testDeviceId, edges: {} }), - }); + await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} }); } catch (err) { console.error('Failed to clear CSS test mode:', err); } @@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) { async function _checkOverlayStatus(cssId: any) { try { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); - if (resp.ok) { - const data = await resp.json(); - _setOverlayBtnActive(data.active); - } + const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`); + _setOverlayBtnActive(!!data.active); } catch { /* ignore */ } } @@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() { const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value; if (!cssId) return; try { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); - if (!resp.ok) return; - const { active } = await resp.json(); + const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`); if (active) { await stopCSSOverlay(cssId); _setOverlayBtnActive(false); @@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() { export async function showCalibration(deviceId: any) { try { - const [response, displays] = await Promise.all([ - fetchWithAuth(`/devices/${deviceId}`), + const [device, displays] = await Promise.all([ + apiGet(`/devices/${deviceId}`), displaysCache.fetch().catch((): any[] => []), ]); - if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; } - - const device = await response.json(); const calibration = device.calibration; const preview = document.querySelector('.calibration-preview') as HTMLElement; @@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) { updateCalibrationPreview(); try { - const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { - method: 'PUT', - body: JSON.stringify({ device_id: testDeviceId, edges }), + await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, { + errorMessage: t('calibration.error.test_toggle_failed'), }); - if (!response.ok) { - const errorData = await response.json(); - const detail = errorData.detail || errorData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - error.textContent = detailStr || t('calibration.error.test_toggle_failed'); - error.style.display = 'block'; - } } catch (err: any) { if (err.isAuth) return; console.error('Failed to toggle CSS test edge:', err); @@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) { updateCalibrationPreview(); try { - const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, { - method: 'PUT', - body: JSON.stringify({ edges }) + await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, { + errorMessage: t('calibration.error.test_toggle_failed'), }); - if (!response.ok) { - const errorData = await response.json(); - const detail = errorData.detail || errorData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - error.textContent = detailStr || t('calibration.error.test_toggle_failed'); - error.style.display = 'block'; - } } catch (err: any) { if (err.isAuth) return; console.error('Failed to toggle test edge:', err); @@ -965,34 +939,23 @@ export async function saveCalibration() { }; try { - let response; if (cssMode) { const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture'; - response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { - method: 'PUT', - body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }), + await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, { + errorMessage: t('calibration.error.save_failed'), }); } else { - response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { - method: 'PUT', - body: JSON.stringify(calibration), + await apiPut(`/devices/${deviceId}/calibration`, calibration, { + errorMessage: t('calibration.error.save_failed'), }); } - if (response.ok) { - showToast(t('calibration.saved'), 'success'); - if (cssMode) colorStripSourcesCache.invalidate(); - calibModal.forceClose(); - if (cssMode) { - if (window.loadTargetsTab) window.loadTargetsTab(); - } else { - window.loadDevices(); - } + showToast(t('calibration.saved'), 'success'); + if (cssMode) colorStripSourcesCache.invalidate(); + calibModal.forceClose(); + if (cssMode) { + if (window.loadTargetsTab) window.loadTargetsTab(); } else { - const errorData = await response.json(); - const detail = errorData.detail || errorData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - error.textContent = detailStr || t('calibration.error.save_failed'); - error.style.display = 'block'; + window.loadDevices(); } } catch (err: any) { if (err.isAuth) return; diff --git a/server/src/ledgrab/static/js/features/card-modes.ts b/server/src/ledgrab/static/js/features/card-modes.ts index c503b50..4718ae7 100644 --- a/server/src/ledgrab/static/js/features/card-modes.ts +++ b/server/src/ledgrab/static/js/features/card-modes.ts @@ -18,7 +18,7 @@ * Surface keys are free-form strings — anything calling `setCardMode` is * implicitly registering that key. Defaults are returned for unknown keys. */ -import { fetchWithAuth } from '../core/api.ts'; +import { apiGet, apiPut } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; const LS_KEY = 'card_modes_v1'; @@ -131,10 +131,7 @@ function _scheduleServerPush(): void { async function _pushToServer(prefs: CardModePrefsV1): Promise { try { - await fetchWithAuth('/preferences/card-modes', { - method: 'PUT', - body: JSON.stringify(prefs), - }); + await apiPut('/preferences/card-modes', prefs); } catch (e) { console.warn('card-modes server PUT failed', e); } @@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 { export async function syncCardModesFromServer(): Promise { if (_serverSyncedOnce) return; try { - const resp = await fetchWithAuth('/preferences/card-modes'); - if (!resp || !resp.ok) return; - const data = await resp.json(); + const data = await apiGet('/preferences/card-modes'); if (data && typeof data === 'object' && (data as Record).version) { _current = _normalise(data); _persistLocal(); diff --git a/server/src/ledgrab/static/js/features/color-strips/gradient.ts b/server/src/ledgrab/static/js/features/color-strips/gradient.ts index 7cb9580..d7921fa 100644 --- a/server/src/ledgrab/static/js/features/color-strips/gradient.ts +++ b/server/src/ledgrab/static/js/features/color-strips/gradient.ts @@ -3,7 +3,7 @@ * Extracted from color-strips.ts to reduce file size. */ -import { fetchWithAuth } from '../../core/api.ts'; +import { apiPost, apiPut, apiDelete } from '../../core/api-client.ts'; import { gradientsCache, GradientEntity } from '../../core/state.ts'; import { t } from '../../core/i18n.ts'; import { showToast, showConfirm } from '../../core/ui.ts'; @@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() { color: s.color, })); try { - await fetchWithAuth('/gradients', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name.trim(), stops }), - }); + await apiPost('/gradients', { name: name.trim(), stops }); await gradientsCache.fetch({ force: true }); showToast(t('color_strip.gradient.preset.saved'), 'success'); } catch (e: any) { @@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() { export async function deleteAndRefreshGradientPreset(gradientId: any) { try { - await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); + await apiDelete(`/gradients/${gradientId}`); await gradientsCache.fetch({ force: true }); showToast(t('color_strip.gradient.preset.deleted'), 'success'); } catch (e: any) { @@ -221,12 +217,10 @@ export async function saveGradientEntity() { const payload: any = { name, stops, description, tags }; try { - const url = id ? `/gradients/${id}` : '/gradients'; - const method = id ? 'PUT' : 'POST'; - const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); - if (!res!.ok) { - const err = await res!.json(); - throw new Error(err.detail || 'Failed to save gradient'); + if (id) { + await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') }); + } else { + await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') }); } showToast(id ? t('gradient.updated') : t('gradient.created'), 'success'); @@ -256,7 +250,7 @@ export async function deleteGradient(gradientId: string) { const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name })); if (!ok) return; try { - await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); + await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') }); gradientsCache.invalidate(); showToast(t('gradient.deleted'), 'success'); if (window.loadPictureSources) await window.loadPictureSources(); diff --git a/server/src/ledgrab/static/js/features/color-strips/notification.ts b/server/src/ledgrab/static/js/features/color-strips/notification.ts index 1ee0fa3..fad8719 100644 --- a/server/src/ledgrab/static/js/features/color-strips/notification.ts +++ b/server/src/ledgrab/static/js/features/color-strips/notification.ts @@ -3,7 +3,8 @@ * Extracted from color-strips.ts to reduce file size. */ -import { fetchWithAuth, escapeHtml } from '../../core/api.ts'; +import { escapeHtml } from '../../core/api.ts'; +import { apiGet, apiPost } from '../../core/api-client.ts'; import { t } from '../../core/i18n.ts'; import { showToast } from '../../core/ui.ts'; import { @@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() { export async function testNotification(sourceId: string) { try { - const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!; - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - showToast(err.detail || t('color_strip.notification.test.error'), 'error'); - return; - } - const data = await resp.json(); - if (data.streams_notified > 0) { + const data = await apiPost<{ streams_notified?: number }>( + `/color-strip-sources/${sourceId}/notify`, undefined, + { errorMessage: t('color_strip.notification.test.error') }, + ); + if ((data.streams_notified ?? 0) > 0) { showToast(t('color_strip.notification.test.ok'), 'success'); } else { showToast(t('color_strip.notification.test.no_streams'), 'warning'); } - } catch { - showToast(t('color_strip.notification.test.error'), 'error'); + } catch (e: any) { + showToast(e?.message || t('color_strip.notification.test.error'), 'error'); } } @@ -355,9 +353,7 @@ async function _loadNotificationHistory() { if (!list) return; try { - const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!; - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiGet('/color-strip-sources/os-notifications/history'); if (!data.available) { list.innerHTML = ''; diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index 0ca8331..f174190 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -12,7 +12,7 @@ * not a closed enum. New cards can be added in v1.1+ (audio meters, alerts, * preview strips, etc.) without a schema bump or migration. */ -import { fetchWithAuth } from '../core/api.ts'; +import { apiGet, apiPut } from '../core/api-client.ts'; const LS_KEY = 'dashboard_layout_v1'; const SCHEMA_VERSION = 1; @@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 { export async function syncDashboardLayoutFromServer(): Promise { if (_serverSyncedOnce) return; try { - const resp = await fetchWithAuth('/preferences/dashboard-layout'); - if (!resp || !resp.ok) return; - const data = await resp.json(); + const data = await apiGet('/preferences/dashboard-layout'); if (data && typeof data === 'object' && data.version) { const merged = _mergeWithDefaults(data); _current = merged; @@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void { async function _pushToServer(layout: DashboardLayoutV1): Promise { try { - await fetchWithAuth('/preferences/dashboard-layout', { - method: 'PUT', - body: JSON.stringify(layout), - }); + await apiPut('/preferences/dashboard-layout', layout); } catch (e) { console.warn('dashboard layout PUT failed', e); } diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 7d9d17b..d405bef 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -7,7 +7,8 @@ import { _discoveryCache, set_discoveryCache, csptCache, } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; +import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost } from '../core/api-client.ts'; import { devicesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, desktopFocus } from '../core/ui.ts'; @@ -1036,20 +1037,11 @@ export async function scanForDevices(forceType?: any) { try { const scanTimeout = scanType === 'ble' ? 8 : 3; - const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`); + const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`); loading.style.display = 'none'; if (scanBtn) scanBtn.disabled = false; - if (!response.ok) { - if (!isSerialDevice(scanType)) { - empty.style.display = 'block'; - (empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error'); - } - return; - } - - const data = await response.json(); _discoveryCache[scanType] = data.devices || []; // Only render if the user is still on this type @@ -1267,36 +1259,25 @@ export async function handleAddDevice(event: any) { } } - const response = await fetchWithAuth('/devices', { - method: 'POST', - body: JSON.stringify(body) - }); - - if (response.ok) { - const result = await response.json(); - // result is logged by the API layer; no console.log here. - showToast(t('device_discovery.added'), 'success'); - devicesCache.invalidate(); - addDeviceModal.forceClose(); - if (typeof window.loadDevices === 'function') await window.loadDevices(); - if (!localStorage.getItem('deviceTutorialSeen')) { - localStorage.setItem('deviceTutorialSeen', '1'); - setTimeout(() => { - if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial(); - }, 300); - } - } else { - const errorData = await response.json(); - console.error('Failed to add device:', errorData); - const detail = errorData.detail || errorData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - error.textContent = detailStr || t('device_discovery.error.add_failed'); - error.style.display = 'block'; + await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') }); + showToast(t('device_discovery.added'), 'success'); + devicesCache.invalidate(); + addDeviceModal.forceClose(); + if (typeof window.loadDevices === 'function') await window.loadDevices(); + if (!localStorage.getItem('deviceTutorialSeen')) { + localStorage.setItem('deviceTutorialSeen', '1'); + setTimeout(() => { + if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial(); + }, 300); } } catch (err: any) { if (err.isAuth) return; console.error('Failed to add device:', err); - showToast(err.message || t('device_discovery.error.add_failed'), 'error'); + // Surface the message inline (HTTP errors carry the server detail, + // array-detail is joined by the api-client; network errors fall back + // to the localised default). + error.textContent = err.message || t('device_discovery.error.add_failed'); + error.style.display = 'block'; } } @@ -1315,17 +1296,15 @@ export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChec container.innerHTML = `${t('device.openrgb.zone.loading')}`; try { - const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - container.innerHTML = `${err.detail || t('device.openrgb.zone.error')}`; - return; - } - const data = await resp.json(); + const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, { + errorMessage: t('device.openrgb.zone.error'), + }); _renderZoneCheckboxes(container, data.zones, preChecked); } catch (err: any) { if (err.isAuth) return; - container.innerHTML = `${t('device.openrgb.zone.error')}`; + // HTTP errors carry the server detail in err.message; fall back to + // the localised generic on network errors. + container.innerHTML = `${escapeHtml(err.message || t('device.openrgb.zone.error'))}`; } } @@ -1733,9 +1712,7 @@ function _showGameSenseFields(show: boolean) { export async function cloneDevice(deviceId: any) { try { - const resp = await fetchWithAuth(`/devices/${deviceId}`); - if (!resp.ok) throw new Error('Failed to load device'); - const device = await resp.json(); + const device = await apiGet(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') }); showAddDevice(device.device_type || 'wled', device); } catch (error: any) { if (error.isAuth) return; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 67fb188..5f8643a 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -6,7 +6,8 @@ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { devicesCache } from '../core/state.ts'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts'; import { t } from '../core/i18n.ts'; @@ -363,19 +364,13 @@ export async function turnOffDevice(deviceId: any) { const confirmed = await showConfirm(t('confirm.turn_off_device')); if (!confirmed) return; try { - const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, { - method: 'PUT', - body: JSON.stringify({ power: false }) + await apiPut(`/devices/${deviceId}/power`, { power: false }, { + errorMessage: t('device.error.power_off_failed'), }); - if (setResp.ok) { - showToast(t('device.power.off_success'), 'success'); - } else { - const error = await setResp.json(); - showToast(error.detail || 'Failed', 'error'); - } + showToast(t('device.power.off_success'), 'success'); } catch (error: any) { if (error.isAuth) return; - showToast(t('device.error.power_off_failed'), 'error'); + showToast(error.message || t('device.error.power_off_failed'), 'error'); } } @@ -383,23 +378,19 @@ export async function pingDevice(deviceId: any) { const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null; if (btn) btn.classList.add('spinning'); try { - const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' }); - if (resp.ok) { - const data = await resp.json(); - const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?'; - showToast(data.device_online - ? t('device.ping.online', { ms }) - : t('device.ping.offline'), data.device_online ? 'success' : 'error'); - // Refresh device cards to update health dot - devicesCache.invalidate(); - await window.loadDevices(); - } else { - const err = await resp.json(); - showToast(err.detail || 'Ping failed', 'error'); - } + const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>( + `/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') }, + ); + const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?'; + showToast(data.device_online + ? t('device.ping.online', { ms }) + : t('device.ping.offline'), data.device_online ? 'success' : 'error'); + // Refresh device cards to update health dot + devicesCache.invalidate(); + await window.loadDevices(); } catch (error: any) { if (error.isAuth) return; - showToast(t('device.ping.error'), 'error'); + showToast(error.message || t('device.ping.error'), 'error'); } finally { if (btn) btn.classList.remove('spinning'); } @@ -414,30 +405,20 @@ export async function removeDevice(deviceId: any) { if (!confirmed) return; try { - const response = await fetchWithAuth(`/devices/${deviceId}`, { - method: 'DELETE', - }); - if (response.ok) { - showToast(t('device.removed'), 'success'); - devicesCache.invalidate(); - window.loadDevices(); - } else { - const error = await response.json(); - showToast(error.detail || t('device.error.remove_failed'), 'error'); - } + await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') }); + showToast(t('device.removed'), 'success'); + devicesCache.invalidate(); + window.loadDevices(); } catch (error: any) { if (error.isAuth) return; console.error('Failed to remove device:', error); - showToast(t('device.error.remove_failed'), 'error'); + showToast(error.message || t('device.error.remove_failed'), 'error'); } } export async function showSettings(deviceId: any) { try { - const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`); - if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; } - - const device = await deviceResponse.json(); + const device = await apiGet(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') }); const isAdalight = isSerialDevice(device.device_type); const caps = device.capabilities || []; @@ -934,18 +915,7 @@ export async function saveDeviceSettings() { } const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || ''; body.default_css_processing_template_id = csptId; - const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { - method: 'PUT', - body: JSON.stringify(body) - }); - - if (!deviceResponse.ok) { - const errorData = await deviceResponse.json(); - const detail = errorData.detail || errorData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - settingsModal.showError(detailStr || t('device.error.update')); - return; - } + await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') }); showToast(t('settings.saved'), 'success'); devicesCache.invalidate(); @@ -978,16 +948,9 @@ export async function saveCardBrightness(deviceId: any, value: any) { const bri = parseInt(value); updateDeviceBrightness(deviceId, bri); try { - const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, { - method: 'PUT', - body: JSON.stringify({ brightness: bri }) + await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, { + errorMessage: t('device.error.brightness'), }); - if (!resp.ok) { - const errData = await resp.json().catch(() => ({})); - const detail = errData.detail || errData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); - showToast(detailStr || t('device.error.brightness'), 'error'); - } } catch (err: any) { if (err.isAuth) return; showToast(err.message || t('device.error.brightness'), 'error'); @@ -999,9 +962,7 @@ export async function fetchDeviceBrightness(deviceId: any) { if (_brightnessFetchInFlight.has(deviceId)) return; _brightnessFetchInFlight.add(deviceId); try { - const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet(`/devices/${deviceId}/brightness`); updateDeviceBrightness(deviceId, data.brightness); const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null; if (slider) { @@ -1078,9 +1039,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) { try { const discoverType = settingsModal.deviceType || 'adalight'; - const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`); const devices = data.devices || []; select.innerHTML = ''; @@ -1154,11 +1113,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) { _zoneCountInFlight.add(baseUrl); try { - const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); const counts: any = {}; - for (const z of data.zones) { + for (const z of (data.zones || [])) { counts[z.name.toLowerCase()] = z.led_count; } _zoneCountCache[baseUrl] = counts; diff --git a/server/src/ledgrab/static/js/features/displays.ts b/server/src/ledgrab/static/js/features/displays.ts index 03f28ec..4a60b6d 100644 --- a/server/src/ledgrab/static/js/features/displays.ts +++ b/server/src/ledgrab/static/js/features/displays.ts @@ -9,7 +9,7 @@ import { availableEngines, } from '../core/state.ts'; import { t } from '../core/i18n.ts'; -import { fetchWithAuth } from '../core/api.ts'; +import { apiGet, apiPost } from '../core/api-client.ts'; import { showToast } from '../core/ui.ts'; import type { Display } from '../types.ts'; @@ -87,9 +87,7 @@ async function _fetchAndRenderEngineDisplays(engineType: string): Promise canvas.innerHTML = '
'; try { - const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`); - if (!resp.ok) throw new Error(`${resp.status}`); - const data = await resp.json(); + const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`); const displays = data.displays || []; // Store in cache so selectDisplay() can look them up @@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () { input.disabled = true; try { - const resp = await fetchWithAuth('/adb/connect', { - method: 'POST', - body: JSON.stringify({ address }), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || 'Connection failed'); - } + // No errorMessage option: the catch already prefixes the toast with + // the localised `displays.picker.adb_connect.error` label, and the + // server's `detail` (or `HTTP ` fallback) becomes the suffix. + await apiPost('/adb/connect', { address }); showToast(t('displays.picker.adb_connect.success'), 'success'); // Refresh the picker with updated device list diff --git a/server/src/ledgrab/static/js/features/game-integration.ts b/server/src/ledgrab/static/js/features/game-integration.ts index 111fe4f..a7f2ca6 100644 --- a/server/src/ledgrab/static/js/features/game-integration.ts +++ b/server/src/ledgrab/static/js/features/game-integration.ts @@ -6,7 +6,8 @@ import { gameIntegrationsCache, gameAdaptersCache, _cachedGameIntegrations, _cachedGameAdapters, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -48,10 +49,8 @@ const _icon = (d: string) => `${d}`; // ── Bulk actions ── function _bulkDeleteGameIntegrations(ids: string[]) { - return Promise.allSettled(ids.map(id => - fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' }) - )).then(results => { - const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => { + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('game_integration.deleted'), 'success'); gameIntegrationsCache.invalidate(); @@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() { } try { - const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' }); - if (!res || !res.ok) { - const err = await res!.json(); - showToast(err.detail || t('game_integration.auto_setup.failed'), 'error'); - return; - } - const data = await res.json(); + const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>( + `/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') }, + ); if (data.success) { let msg = t('game_integration.auto_setup.success'); if (data.file_path) msg += `\n${data.file_path}`; @@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = []; async function _loadPresets(): Promise { if (_cachedPresets.length > 0) return _cachedPresets; try { - const res = await fetchWithAuth('/game-integrations/presets'); - if (res && res.ok) { - const data = await res.json(); - _cachedPresets = data.presets || []; - } + const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets'); + _cachedPresets = data.presets || []; } catch { /* ignore */ } return _cachedPresets; } @@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) { const poll = async () => { try { - const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`); - if (!res || !res.ok) return; - const data = await res.json(); - const events: GameEventRecord[] = data.events || []; + const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`); + const events = data.events || []; if (events.length === 0) return; feed.innerHTML = events.slice(0, 20).map(ev => { const ts = new Date(ev.timestamp).toLocaleTimeString(); @@ -535,9 +525,7 @@ export function testGameConnection() { _connectionTestTimer = setInterval(async () => { attempts++; try { - const res = await fetchWithAuth(`/game-integrations/${id}/status`); - if (!res || !res.ok) return; - const status: GameIntegrationStatus = await res.json(); + const status = await apiGet(`/game-integrations/${id}/status`); if (status.event_count > 0) { clearInterval(_connectionTestTimer!); _connectionTestTimer = null; @@ -725,12 +713,10 @@ export async function saveGameIntegration() { }; try { - const url = id ? `/game-integrations/${id}` : '/game-integrations'; - const method = id ? 'PUT' : 'POST'; - const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); - if (!res || !res.ok) { - const err = await res!.json(); - throw new Error(err.detail || t('game_integration.error.save_failed')); + if (id) { + await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') }); + } else { + await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') }); } showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success'); gameIntegrationsCache.invalidate(); @@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) { const ok = await showConfirm(t('game_integration.confirm_delete')); if (!ok) return; try { - await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' }); + await apiDelete(`/game-integrations/${entityId}`, { errorMessage: t('game_integration.error.delete_failed') }); showToast(t('game_integration.deleted'), 'success'); gameIntegrationsCache.invalidate(); loadGameIntegrations(); diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts index 0fc6b9f..3ca26f6 100644 --- a/server/src/ledgrab/static/js/features/ha-light-targets.ts +++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts @@ -7,7 +7,8 @@ import { colorStripSourcesCache, outputTargetsCache, valueSourcesCache, getHAEntityFriendlyName, setHAEntityNames, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut } from '../core/api-client.ts'; import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; @@ -149,9 +150,7 @@ function _getEntityItems() { async function _fetchHAEntities(haSourceId: string): Promise { if (!haSourceId) { _cachedHAEntities = []; return; } try { - const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); - if (!resp.ok) { _cachedHAEntities = []; return; } - const data = await resp.json(); + const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`); _cachedHAEntities = data.entities || []; // Mirror into the shared cache so card chips/swatches across the // app pick up friendly names on the next render. @@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat if (isEdit) { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}`); - if (!resp.ok) throw new Error('Failed to load target'); - editData = await resp.json(); + editData = await apiGet(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') }); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise { payload.target_type = 'ha_light'; try { - let response; if (targetId) { - response = await fetchWithAuth(`/output-targets/${targetId}`, { - method: 'PUT', - body: JSON.stringify(payload), - }); + await apiPut(`/output-targets/${targetId}`, payload); } else { - response = await fetchWithAuth('/output-targets', { - method: 'POST', - body: JSON.stringify(payload), - }); - } - - if (!response.ok) { - const err = await response.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${response.status}`); + await apiPost('/output-targets', payload); } showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success'); @@ -579,12 +564,9 @@ export async function editHALightTarget(targetId: string): Promise { export async function cloneHALightTarget(targetId: string): Promise { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}`); - if (!resp.ok) throw new Error('Failed to load target'); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showHALightEditor(null, data); + const data = await apiGet(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') }); + const { id: _omit, ...rest } = data; + await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` }); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -834,8 +816,7 @@ const _haLightActions: Record void> = { async function _startStop(targetId: string, action: 'start' | 'stop'): Promise { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await apiPost(`/output-targets/${targetId}/${action}`); outputTargetsCache.invalidate(); if (window.loadTargetsTab) await window.loadTargetsTab(); } catch (e: any) { @@ -848,19 +829,13 @@ export async function turnOffHALightTarget(targetId: string): Promise { const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?'); if (!confirmed) return; try { - const resp = await fetchWithAuth( - `/output-targets/${targetId}/ha-light/turn-off`, - { method: 'POST' }, - ); - if (resp.ok) { - showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success'); - } else { - const err = await resp.json().catch(() => ({})); - showToast(err.detail || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error'); - } + await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, { + errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights', + }); + showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success'); } catch (e: any) { if (e.isAuth) return; - showToast(t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error'); + showToast(e.message || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error'); } } diff --git a/server/src/ledgrab/static/js/features/home-assistant-sources.ts b/server/src/ledgrab/static/js/features/home-assistant-sources.ts index a05fa36..8157b0c 100644 --- a/server/src/ledgrab/static/js/features/home-assistant-sources.ts +++ b/server/src/ledgrab/static/js/features/home-assistant-sources.ts @@ -6,7 +6,8 @@ import { _cachedHASources, haSourcesCache, _haEntityNamesCache, setHAEntityNames, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal(); export async function fetchHAEntities(haSourceId: string): Promise { if (!haSourceId) return; try { - const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`); setHAEntityNames(haSourceId, data.entities || []); } catch { - // Leave any existing cache entry intact. + // Leave any existing cache entry intact (any non-2xx or network error). } } @@ -174,16 +173,10 @@ export async function saveHASource(): Promise { if (token) payload.token = token; try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/home-assistant/sources/${id}`, payload); + } else { + await apiPost('/home-assistant/sources', payload); } showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success'); haSourceModal.forceClose(); @@ -199,9 +192,7 @@ export async function saveHASource(): Promise { export async function editHASource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); - if (!resp.ok) throw new Error(t('ha_source.error.load')); - const data = await resp.json(); + const data = await apiGet(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') }); await showHASourceModal(data); } catch (e: any) { if (e.isAuth) return; @@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise { export async function cloneHASource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); - if (!resp.ok) throw new Error(t('ha_source.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showHASourceModal(data); + const data = await apiGet(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') }); + const { id: _omit, ...rest } = data; + await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -227,11 +215,7 @@ export async function deleteHASource(sourceId: string): Promise { const confirmed = await showConfirm(t('ha_source.delete.confirm')); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/home-assistant/sources/${sourceId}`); showToast(t('ha_source.deleted'), 'success'); haSourcesCache.invalidate(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); @@ -251,9 +235,7 @@ export async function testHASource(): Promise { if (testBtn) testBtn.classList.add('loading'); try { - const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/home-assistant/sources/${id}/test`); if (data.success) { showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); } else { @@ -267,6 +249,14 @@ export async function testHASource(): Promise { } } +/** Shape returned by `POST /home-assistant/sources/{id}/test`. */ +interface HATestResult { + success: boolean; + ha_version?: string; + entity_count?: number; + error?: string; +} + // ── Card rendering ── export function createHASourceCard(source: HomeAssistantSource) { @@ -328,9 +318,7 @@ const _haSourceActions: Record void> = { async function _testHASourceFromCard(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/home-assistant/sources/${sourceId}/test`); if (data.success) { showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); } else { diff --git a/server/src/ledgrab/static/js/features/http-endpoints.ts b/server/src/ledgrab/static/js/features/http-endpoints.ts index c960930..cca785e 100644 --- a/server/src/ledgrab/static/js/features/http-endpoints.ts +++ b/server/src/ledgrab/static/js/features/http-endpoints.ts @@ -13,7 +13,8 @@ import { _cachedHTTPEndpoints, httpEndpointsCache, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise { } try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/http/endpoints/${id}` : '/http/endpoints'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/http/endpoints/${id}`, payload); + } else { + await apiPost('/http/endpoints', payload); } showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success'); httpEndpointModal.forceClose(); @@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise { export async function editHTTPEndpoint(endpointId: string): Promise { try { - const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); - if (!resp.ok) throw new Error(t('http_endpoint.error.load')); - const data: HTTPEndpoint = await resp.json(); + const data = await apiGet(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') }); await showHTTPEndpointModal(data); } catch (e: any) { if (e.isAuth) return; @@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise { export async function cloneHTTPEndpoint(endpointId: string): Promise { try { - const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); - if (!resp.ok) throw new Error(t('http_endpoint.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - // Cloning never reveals the token — user must re-enter if needed. - data.auth_token_set = false; - await showHTTPEndpointModal(data); + const data = await apiGet(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') }); + const { id: _omit, ...rest } = data; + await showHTTPEndpointModal({ + ...rest, + name: `${data.name} (copy)`, + // Cloning never reveals the token — user must re-enter if needed. + auth_token_set: false, + } as HTTPEndpoint); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -370,11 +363,7 @@ export async function deleteHTTPEndpoint(endpointId: string): Promise { const confirmed = await showConfirm(t('http_endpoint.delete.confirm')); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/http/endpoints/${endpointId}`); showToast(t('http_endpoint.deleted'), 'success'); httpEndpointsCache.invalidate(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); @@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise { `; try { - const resp = await fetchWithAuth('/http/endpoints/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }), - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data: HTTPTestResponse = await resp.json(); + const data = await apiPost('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s }); _renderTestResult(out, data); } catch (e: any) { if (e.isAuth) return; @@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) { async function _testHTTPEndpointFromCard(endpointId: string): Promise { try { - const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } - const data: HTTPTestResponse = await resp.json(); + const data = await apiPost(`/http/endpoints/${endpointId}/test`); if (data.success) { const status = data.status_code != null ? ` (${data.status_code})` : ''; showToast(`${t('http_endpoint.test.success')}${status}`, 'success'); diff --git a/server/src/ledgrab/static/js/features/icon-picker.ts b/server/src/ledgrab/static/js/features/icon-picker.ts index 5102bde..176a0b5 100644 --- a/server/src/ledgrab/static/js/features/icon-picker.ts +++ b/server/src/ledgrab/static/js/features/icon-picker.ts @@ -14,7 +14,8 @@ import { Modal } from '../core/modal.ts'; import { t } from '../core/i18n.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiPut } from '../core/api-client.ts'; import { showToast } from '../core/ui.ts'; import { devicesCache, outputTargetsCache } from '../core/state.ts'; import { @@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise ({})); - showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error'); - return; - } + await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') }); if (nextIconId) _pushRecent(nextIconId); showToast(t('device.icon.saved') || 'Icon saved', 'success'); await adapter.reload(); closeIconPicker(); } catch (error: any) { if (error?.isAuth) return; - showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error'); + showToast(error?.message || t('device.icon.error.save_failed') || 'Failed to save icon', 'error'); } } diff --git a/server/src/ledgrab/static/js/features/integrations.ts b/server/src/ledgrab/static/js/features/integrations.ts index bc1f9af..3452aaf 100644 --- a/server/src/ledgrab/static/js/features/integrations.ts +++ b/server/src/ledgrab/static/js/features/integrations.ts @@ -15,7 +15,7 @@ import { CardSection } from '../core/card-sections.ts'; import { TreeNav } from '../core/tree-nav.ts'; import { updateSubTabHash } from './tabs.ts'; import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts'; -import { fetchWithAuth } from '../core/api.ts'; +import { apiDelete } from '../core/api-client.ts'; import { showToast, setTabRefreshing } from '../core/ui.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts'; @@ -29,10 +29,8 @@ import * as P from '../core/icon-paths.ts'; function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) { return async (ids: string[]) => { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' }) - )); - const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`))); + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t(toast), 'success'); cache.invalidate(); diff --git a/server/src/ledgrab/static/js/features/mqtt-sources.ts b/server/src/ledgrab/static/js/features/mqtt-sources.ts index c766d98..2fdce24 100644 --- a/server/src/ledgrab/static/js/features/mqtt-sources.ts +++ b/server/src/ledgrab/static/js/features/mqtt-sources.ts @@ -3,7 +3,8 @@ */ import { mqttSourcesCache } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise { if (password) payload.password = password; try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/mqtt/sources/${id}`, payload); + } else { + await apiPost('/mqtt/sources', payload); } showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success'); mqttSourceModal.forceClose(); @@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise { export async function editMQTTSource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); - if (!resp.ok) throw new Error(t('mqtt_source.error.load')); - const data = await resp.json(); + const data = await apiGet(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') }); await showMQTTSourceModal(data); } catch (e: any) { if (e.isAuth) return; @@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise { export async function cloneMQTTSource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); - if (!resp.ok) throw new Error(t('mqtt_source.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showMQTTSourceModal(data); + const data = await apiGet(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') }); + const { id: _omit, ...rest } = data; + await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -196,11 +186,7 @@ export async function deleteMQTTSource(sourceId: string): Promise { const confirmed = await showConfirm(t('mqtt_source.delete.confirm')); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/mqtt/sources/${sourceId}`); showToast(t('mqtt_source.deleted'), 'success'); mqttSourcesCache.invalidate(); } catch (e: any) { @@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise { if (testBtn) testBtn.classList.add('loading'); try { - const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/mqtt/sources/${id}/test`); if (data.success) { showToast(t('mqtt_source.test.success'), 'success'); } else { @@ -235,11 +219,15 @@ export async function testMQTTSource(): Promise { } } +/** Shape returned by `POST /mqtt/sources/{id}/test`. */ +interface MQTTTestResult { + success: boolean; + error?: string; +} + async function _testMQTTSourceFromCard(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/mqtt/sources/${sourceId}/test`); if (data.success) { showToast(t('mqtt_source.test.success'), 'success'); } else { diff --git a/server/src/ledgrab/static/js/features/notifications-watcher.ts b/server/src/ledgrab/static/js/features/notifications-watcher.ts index d92f46f..47965ec 100644 --- a/server/src/ledgrab/static/js/features/notifications-watcher.ts +++ b/server/src/ledgrab/static/js/features/notifications-watcher.ts @@ -21,7 +21,7 @@ * settings. */ -import { fetchWithAuth } from '../core/api.ts'; +import { apiGet, apiPut } from '../core/api-client.ts'; import { showToast } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; import { logError } from '../core/log.ts'; @@ -102,9 +102,7 @@ export async function startNotificationsWatcher(): Promise { /** Pull the latest prefs from the server and cache them. */ export async function refreshNotificationPreferences(): Promise { try { - const resp = await fetchWithAuth('/preferences/notifications'); - if (!resp.ok) return _prefs; - const data = await resp.json(); + const data = await apiGet('/preferences/notifications'); _prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } }; } catch (err) { logError('notifications.fetch', err); @@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise { - const resp = await fetchWithAuth('/preferences/notifications', { - method: 'PUT', - body: JSON.stringify(next), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } - const saved = await resp.json(); + const saved = await apiPut('/preferences/notifications', next); _prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } }; return _prefs; } diff --git a/server/src/ledgrab/static/js/features/pattern-templates.ts b/server/src/ledgrab/static/js/features/pattern-templates.ts index 9b6f0e9..ebfd174 100644 --- a/server/src/ledgrab/static/js/features/pattern-templates.ts +++ b/server/src/ledgrab/static/js/features/pattern-templates.ts @@ -15,7 +15,8 @@ import { PATTERN_RECT_BORDERS, streamsCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts'; +import { apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { patternTemplatesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; @@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise { }; try { - let response; if (templateId) { - response = await fetchWithAuth(`/pattern-templates/${templateId}`, { - method: 'PUT', body: JSON.stringify(payload), - }); + await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') }); } else { - response = await fetchWithAuth('/pattern-templates', { - method: 'POST', body: JSON.stringify(payload), - }); - } - - if (!response.ok) { - const err = await response.json(); - throw new Error(err.detail || 'Failed to save'); + await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') }); } showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success'); @@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise { if (!confirmed) return; try { - const response = await fetchWithAuth(`/pattern-templates/${templateId}`, { - method: 'DELETE', - }); - if (response.ok) { - showToast(t('pattern.deleted'), 'success'); - patternTemplatesCache.invalidate(); - if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); - } else { - const error = await response.json(); - showToast(error.detail || t('pattern.error.delete_failed'), 'error'); - } + await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') }); + showToast(t('pattern.deleted'), 'success'); + patternTemplatesCache.invalidate(); + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } catch (error) { if (error.isAuth) return; - showToast(t('pattern.error.delete_failed'), 'error'); + showToast(error.message || t('pattern.error.delete_failed'), 'error'); } } diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index b1b2c57..5fb5398 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -8,7 +8,8 @@ * cheap for 120-sample lines. */ -import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts'; +import { fetchMetricsHistory } from '../core/api.ts'; +import { apiGet } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { dashboardPollInterval } from '../core/state.ts'; import { isActiveTab } from '../core/tab-registry.ts'; @@ -1102,9 +1103,7 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v async function _fetchPerformance(): Promise { try { - const resp = await fetchWithAuth('/system/performance'); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet('/system/performance'); _lastFetchData = data; _applyPerfDataToDom(data, /*pushHistory=*/true); } catch (err) { diff --git a/server/src/ledgrab/static/js/features/scene-presets.ts b/server/src/ledgrab/static/js/features/scene-presets.ts index bda9c9d..a630b58 100644 --- a/server/src/ledgrab/static/js/features/scene-presets.ts +++ b/server/src/ledgrab/static/js/features/scene-presets.ts @@ -3,7 +3,8 @@ * Rendered as a CardSection inside the Automations tab, plus dashboard compact cards. */ -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', { bulkActions: [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: async (ids) => { - const results = await Promise.allSettled(ids.map(id => - fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' }) - )); - const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length; + const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`))); + const failed = results.filter(r => r.status === 'rejected').length; if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); else showToast(t('scenes.deleted'), 'success'); scenePresetsCache.invalidate(); @@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise { const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : []; try { - let resp; + const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] + .map(el => (el as HTMLElement).dataset.targetId); + const body = { name, description, target_ids, tags }; if (_editingId) { - const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] - .map(el => (el as HTMLElement).dataset.targetId); - resp = await fetchWithAuth(`/scene-presets/${_editingId}`, { - method: 'PUT', - body: JSON.stringify({ name, description, target_ids, tags }), - }); + await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') }); } else { - const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] - .map(el => (el as HTMLElement).dataset.targetId); - resp = await fetchWithAuth('/scene-presets', { - method: 'POST', - body: JSON.stringify({ name, description, target_ids, tags }), - }); - } - - if (!resp.ok) { - const err = await resp.json(); - errorEl.textContent = err.detail || t('scenes.error.save_failed'); - errorEl.style.display = 'block'; - return; + await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') }); } scenePresetModal.forceClose(); showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success'); scenePresetsCache.invalidate(); _reloadScenesTab(); - } catch (error) { + } catch (error: any) { if (error.isAuth) return; - errorEl.textContent = t('scenes.error.save_failed'); + errorEl.textContent = error.message || t('scenes.error.save_failed'); errorEl.style.display = 'block'; } } @@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise { export async function activateScenePreset(presetId: string): Promise { try { - const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, { - method: 'POST', - }); - if (!resp.ok) { - const errData = await resp.json().catch(() => ({})); - const detail = errData.detail || errData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail); - showToast(detailStr || t('scenes.error.activate_failed'), 'error'); - return; - } - const result = await resp.json(); + const result = await apiPost<{ status: string; errors: any[] }>( + `/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') }, + ); if (result.status === 'activated') { showToast(t('scenes.activated'), 'success'); } else { @@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise { if (typeof window.loadDashboard === 'function') window.loadDashboard(true); } catch (error: any) { if (error.isAuth) return; - showToast(t('scenes.error.activate_failed'), 'error'); + showToast(error.message || t('scenes.error.activate_failed'), 'error'); } } @@ -520,20 +496,11 @@ export async function recaptureScenePreset(presetId: string): Promise { if (!confirmed) return; try { - const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, { - method: 'POST', - }); - if (resp.ok) { - showToast(t('scenes.recaptured'), 'success'); - scenePresetsCache.invalidate(); - _reloadScenesTab(); - } else { - const errData = await resp.json().catch(() => ({})); - const detail = errData.detail || errData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail); - showToast(detailStr || t('scenes.error.recapture_failed'), 'error'); - } - } catch (error) { + await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') }); + showToast(t('scenes.recaptured'), 'success'); + scenePresetsCache.invalidate(); + _reloadScenesTab(); + } catch (error: any) { if (error.isAuth) return; showToast(error.message || t('scenes.error.recapture_failed'), 'error'); } @@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise { if (!confirmed) return; try { - const resp = await fetchWithAuth(`/scene-presets/${presetId}`, { - method: 'DELETE', - }); - if (resp.ok) { - showToast(t('scenes.deleted'), 'success'); - scenePresetsCache.invalidate(); - _reloadScenesTab(); - } else { - const errData = await resp.json().catch(() => ({})); - const detail = errData.detail || errData.message || ''; - const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail); - showToast(detailStr || t('scenes.error.delete_failed'), 'error'); - } - } catch (error) { + await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') }); + showToast(t('scenes.deleted'), 'success'); + scenePresetsCache.invalidate(); + _reloadScenesTab(); + } catch (error: any) { if (error.isAuth) return; showToast(error.message || t('scenes.error.delete_failed'), 'error'); } diff --git a/server/src/ledgrab/static/js/features/streams-audio-templates.ts b/server/src/ledgrab/static/js/features/streams-audio-templates.ts index 983064d..08dd6b0 100644 --- a/server/src/ledgrab/static/js/features/streams-audio-templates.ts +++ b/server/src/ledgrab/static/js/features/streams-audio-templates.ts @@ -11,7 +11,8 @@ import { audioTemplatesCache, apiKey, } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { API_BASE, escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { logError } from '../core/log.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; @@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal(); async function loadAvailableAudioEngines() { try { - const response = await fetchWithAuth('/audio-engines'); - if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`); - const data = await response.json(); + const data = await apiGet<{ engines?: any[] }>('/audio-engines'); setAvailableAudioEngines(data.engines || []); const select = document.getElementById('audio-template-engine') as HTMLSelectElement; @@ -232,9 +231,7 @@ export async function showAddAudioTemplateModal(cloneData: any = null) { export async function editAudioTemplate(templateId: any) { try { - const response = await fetchWithAuth(`/audio-templates/${templateId}`); - if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`); - const template = await response.json(); + const template = await apiGet(`/audio-templates/${templateId}`); setCurrentEditingAudioTemplateId(templateId); document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`; @@ -284,16 +281,10 @@ export async function saveAudioTemplate() { const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] }; try { - let response; if (templateId) { - response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); + await apiPut(`/audio-templates/${templateId}`, payload, { errorMessage: t('audio_template.error.save_failed') }); } else { - response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to save audio template'); + await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') }); } showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success'); @@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) { if (!confirmed) return; try { - const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to delete audio template'); - } + await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') }); showToast(t('audio_template.deleted'), 'success'); audioTemplatesCache.invalidate(); await loadAudioTemplates(); @@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) { export async function cloneAudioTemplate(templateId: any) { try { - const resp = await fetchWithAuth(`/audio-templates/${templateId}`); - if (!resp.ok) throw new Error('Failed to load audio template'); - const tmpl = await resp.json(); + const tmpl = await apiGet(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') }); showAddAudioTemplateModal(tmpl); - } catch (error) { + } catch (error: any) { if (error.isAuth) return; console.error('Failed to clone audio template:', error); showToast(t('audio_template.error.clone_failed'), 'error'); @@ -364,21 +349,18 @@ export async function showTestAudioTemplateModal(templateId: any) { // Load audio devices for picker — filter by engine type const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement; try { - const resp = await fetchWithAuth('/audio-devices'); - if (resp.ok) { - const data = await resp.json(); - // Use engine-specific device list if available, fall back to flat list - const devices = (engineType && data.by_engine && data.by_engine[engineType]) - ? data.by_engine[engineType] - : (data.devices || []); - deviceSelect.innerHTML = devices.map(d => { - const label = d.name; - const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; - return ``; - }).join(''); - if (devices.length === 0) { - deviceSelect.innerHTML = ''; - } + const data = await apiGet<{ by_engine?: Record; devices?: any[] }>('/audio-devices'); + // Use engine-specific device list if available, fall back to flat list + const devices = (engineType && data.by_engine && data.by_engine[engineType]) + ? data.by_engine[engineType] + : (data.devices || []); + deviceSelect.innerHTML = devices.map(d => { + const label = d.name; + const val = `${d.index}:${d.is_loopback ? '1' : '0'}`; + return ``; + }).join(''); + if (devices.length === 0) { + deviceSelect.innerHTML = ''; } } catch { deviceSelect.innerHTML = ''; diff --git a/server/src/ledgrab/static/js/features/streams-capture-templates.ts b/server/src/ledgrab/static/js/features/streams-capture-templates.ts index 35e77f9..ebd4a35 100644 --- a/server/src/ledgrab/static/js/features/streams-capture-templates.ts +++ b/server/src/ledgrab/static/js/features/streams-capture-templates.ts @@ -12,7 +12,8 @@ import { captureTemplatesCache, displaysCache, enginesCache, apiKey, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts'; @@ -105,9 +106,7 @@ export async function showAddTemplateModal(cloneData: any = null) { export async function editTemplate(templateId: any) { try { - const response = await fetchWithAuth(`/capture-templates/${templateId}`); - if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); - const template = await response.json(); + const template = await apiGet(`/capture-templates/${templateId}`); setCurrentEditingTemplateId(templateId); document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`; @@ -414,9 +413,7 @@ async function loadDisplaysForTest() { // Always refetch for engines with own displays (devices may change); use cache for desktop if (!_cachedDisplays || engineHasOwnDisplays) { - const response = await fetchWithAuth(url); - if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); - const displaysData = await response.json(); + const displaysData = await apiGet<{ displays?: any[] }>(url); displaysCache.update(displaysData.displays || []); } @@ -607,16 +604,10 @@ export async function saveTemplate() { const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] }; try { - let response; if (templateId) { - response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); + await apiPut(`/capture-templates/${templateId}`, payload, { errorMessage: t('templates.error.save_failed') }); } else { - response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) }); - } - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to save template'); + await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') }); } showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); @@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) { if (!confirmed) return; try { - const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' }); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Failed to delete template'); - } + await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') }); showToast(t('templates.deleted'), 'success'); captureTemplatesCache.invalidate(); await loadCaptureTemplates(); diff --git a/server/src/ledgrab/static/js/features/sync-clocks.ts b/server/src/ledgrab/static/js/features/sync-clocks.ts index ccff4a8..732cf37 100644 --- a/server/src/ledgrab/static/js/features/sync-clocks.ts +++ b/server/src/ledgrab/static/js/features/sync-clocks.ts @@ -3,7 +3,8 @@ */ import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -124,16 +125,10 @@ export async function saveSyncClock(): Promise { const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] }; try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/sync-clocks/${id}` : '/sync-clocks'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/sync-clocks/${id}`, payload); + } else { + await apiPost('/sync-clocks', payload); } showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success'); syncClockModal.forceClose(); @@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise { export async function editSyncClock(clockId: string): Promise { try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}`); - if (!resp.ok) throw new Error(t('sync_clock.error.load')); - const data = await resp.json(); + const data = await apiGet(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') }); await showSyncClockModal(data); } catch (e) { if (e.isAuth) return; @@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise { export async function cloneSyncClock(clockId: string): Promise { try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}`); - if (!resp.ok) throw new Error(t('sync_clock.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showSyncClockModal(data); + const data = await apiGet(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') }); + const { id: _omit, ...rest } = data; + await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -177,11 +167,7 @@ export async function deleteSyncClock(clockId: string): Promise { const confirmed = await showConfirm(t('sync_clock.delete.confirm')); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/sync-clocks/${clockId}`); showToast(t('sync_clock.deleted'), 'success'); syncClocksCache.invalidate(); await loadPictureSources(); @@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise { export async function pauseSyncClock(clockId: string): Promise { try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await apiPost(`/sync-clocks/${clockId}/pause`); showToast(t('sync_clock.paused'), 'success'); syncClocksCache.invalidate(); await loadPictureSources(); @@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise { export async function resumeSyncClock(clockId: string): Promise { try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await apiPost(`/sync-clocks/${clockId}/resume`); showToast(t('sync_clock.resumed'), 'success'); syncClocksCache.invalidate(); await loadPictureSources(); @@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise { export async function resetSyncClock(clockId: string): Promise { try { - const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await apiPost(`/sync-clocks/${clockId}/reset`); showToast(t('sync_clock.reset_done'), 'success'); syncClocksCache.invalidate(); await loadPictureSources(); diff --git a/server/src/ledgrab/static/js/features/update.ts b/server/src/ledgrab/static/js/features/update.ts index 39105c1..10d6a50 100644 --- a/server/src/ledgrab/static/js/features/update.ts +++ b/server/src/ledgrab/static/js/features/update.ts @@ -2,7 +2,7 @@ * Auto-update — check for new releases, show banner, manage settings. */ -import { fetchWithAuth } from '../core/api.ts'; +import { apiGet, apiPost, apiPut } from '../core/api-client.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; import { IconSelect } from '../core/icon-select.ts'; @@ -129,10 +129,7 @@ export function dismissUpdate(): void { _hideBanner(); _setVersionBadgeUpdate(false); - fetchWithAuth('/system/update/dismiss', { - method: 'POST', - body: JSON.stringify({ version }), - }).catch(() => {}); + apiPost('/system/update/dismiss', { version }).catch(() => {}); } // ─── Apply update ─────────────────────────────────────────── @@ -151,14 +148,7 @@ export async function applyUpdate(): Promise { btns.forEach(b => (b as HTMLButtonElement).disabled = true); try { - const resp = await fetchWithAuth('/system/update/apply', { - method: 'POST', - timeout: 600000, // 10 min for download + apply - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + apply */ }); // Server will shut down — the frontend reconnect overlay handles the rest showToast(t('update.applying'), 'info'); } catch (err) { @@ -171,9 +161,7 @@ export async function applyUpdate(): Promise { export async function loadUpdateStatus(): Promise { try { - const resp = await fetchWithAuth('/system/update/status'); - if (!resp.ok) return; - const status: UpdateStatus = await resp.json(); + const status = await apiGet('/system/update/status'); _lastStatus = status; _applyStatus(status); } catch { @@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise { if (spinner) spinner.style.display = ''; try { - const resp = await fetchWithAuth('/system/update/check', { method: 'POST' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } - const status: UpdateStatus = await resp.json(); + const status = await apiPost('/system/update/check'); _lastStatus = status; _applyStatus(status); @@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void { export async function loadUpdateSettings(): Promise { try { - const resp = await fetchWithAuth('/system/update/settings'); - if (!resp.ok) return; - const data = await resp.json(); + const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings'); const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null; const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null; @@ -388,14 +369,7 @@ export async function saveUpdateSettings(): Promise { if (Number.isNaN(check_interval_hours)) return; try { - const resp = await fetchWithAuth('/system/update/settings', { - method: 'PUT', - body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease }); } catch (err) { showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error'); } diff --git a/server/src/ledgrab/static/js/features/weather-sources.ts b/server/src/ledgrab/static/js/features/weather-sources.ts index b7df921..6269687 100644 --- a/server/src/ledgrab/static/js/features/weather-sources.ts +++ b/server/src/ledgrab/static/js/features/weather-sources.ts @@ -3,7 +3,8 @@ */ import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; @@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise { }; try { - const method = id ? 'PUT' : 'POST'; - const url = id ? `/weather-sources/${id}` : '/weather-sources'; - const resp = await fetchWithAuth(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); + if (id) { + await apiPut(`/weather-sources/${id}`, payload); + } else { + await apiPost('/weather-sources', payload); } showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success'); weatherSourceModal.forceClose(); @@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise { export async function editWeatherSource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); - if (!resp.ok) throw new Error(t('weather_source.error.load')); - const data = await resp.json(); + const data = await apiGet(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') }); await showWeatherSourceModal(data); } catch (e: any) { if (e.isAuth) return; @@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise { export async function cloneWeatherSource(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); - if (!resp.ok) throw new Error(t('weather_source.error.load')); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showWeatherSourceModal(data); + const data = await apiGet(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') }); + const { id: _omit, ...rest } = data; + await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -219,11 +209,7 @@ export async function deleteWeatherSource(sourceId: string): Promise { const confirmed = await showConfirm(t('weather_source.delete.confirm')); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } + await apiDelete(`/weather-sources/${sourceId}`); showToast(t('weather_source.deleted'), 'success'); weatherSourcesCache.invalidate(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); @@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise { if (testBtn) testBtn.classList.add('loading'); try { - const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/weather-sources/${id}/test`); showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); } catch (e: any) { if (e.isAuth) return; @@ -255,6 +239,13 @@ export async function testWeatherSource(): Promise { } } +/** Shape returned by `POST /weather-sources/{id}/test`. */ +interface WeatherTestResult { + condition: string; + temperature: number; + wind_speed: number; +} + // ── Geolocation ── export function weatherSourceGeolocate(): void { @@ -336,9 +327,7 @@ const _weatherSourceActions: Record void> = { async function _testWeatherSourceFromCard(sourceId: string): Promise { try { - const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); + const data = await apiPost(`/weather-sources/${sourceId}/test`); showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); } catch (e: any) { if (e.isAuth) return; diff --git a/server/src/ledgrab/static/js/features/z2m-light-targets.ts b/server/src/ledgrab/static/js/features/z2m-light-targets.ts index 9602945..e154741 100644 --- a/server/src/ledgrab/static/js/features/z2m-light-targets.ts +++ b/server/src/ledgrab/static/js/features/z2m-light-targets.ts @@ -16,7 +16,8 @@ import { colorStripSourcesCache, mqttSourcesCache, outputTargetsCache, valueSourcesCache, } from '../core/state.ts'; -import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { escapeHtml } from '../core/api.ts'; +import { apiGet, apiPost, apiPut } from '../core/api-client.ts'; import { logError } from '../core/log.ts'; import { safeJsonParse } from '../core/storage.ts'; import { t } from '../core/i18n.ts'; @@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa let editData: any = null; if (isEdit) { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}`); - if (!resp.ok) throw new Error('Failed to load target'); - editData = await resp.json(); + editData = await apiGet(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') }); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise { }; try { - const response = targetId - ? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) }) - : await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) }); - - if (!response.ok) { - const err = await response.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${response.status}`); + if (targetId) { + await apiPut(`/output-targets/${targetId}`, payload); + } else { + await apiPost('/output-targets', payload); } showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success'); outputTargetsCache.invalidate(); @@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise { export async function cloneZ2MLightTarget(targetId: string): Promise { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}`); - if (!resp.ok) throw new Error('Failed to load target'); - const data = await resp.json(); - delete data.id; - data.name = data.name + ' (copy)'; - await showZ2MLightEditor(null, data); + const data = await apiGet(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') }); + const { id: _omit, ...rest } = data; + await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` }); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); @@ -659,8 +652,7 @@ const _z2mLightActions: Record void> = { async function _startStop(targetId: string, action: 'start' | 'stop'): Promise { try { - const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await apiPost(`/output-targets/${targetId}/${action}`); outputTargetsCache.invalidate(); if (window.loadTargetsTab) await window.loadTargetsTab(); } catch (e: any) { @@ -673,16 +665,13 @@ export async function turnOffZ2MLightTarget(targetId: string): Promise { const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?'); if (!confirmed) return; try { - const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' }); - if (resp.ok) { - showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success'); - } else { - const err = await resp.json().catch(() => ({})); - showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error'); - } + await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, { + errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', + }); + showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success'); } catch (e: any) { if (e.isAuth) return; - showToast(t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error'); + showToast(e.message || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error'); } } diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 1129b38..0f4b422 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -125,6 +125,8 @@ "templates.error.engines": "Failed to load engines", "templates.error.required": "Please fill in all required fields", "templates.error.delete": "Failed to delete template", + "templates.error.save_failed": "Failed to save template", + "templates.error.load_failed": "Failed to load template", "templates.test.title": "Test Capture", "templates.test.description": "Test this template before saving to see a capture preview and performance metrics.", "templates.test.display": "Display:", @@ -1283,6 +1285,10 @@ "automations.deleted": "Automation deleted", "automations.error.name_required": "Name is required", "automations.error.clone_failed": "Failed to clone automation", + "automations.error.load_failed": "Failed to load automation", + "automations.error.save_failed": "Failed to save automation", + "automations.error.delete_failed": "Failed to delete automation", + "automations.error.toggle_failed": "Failed to toggle automation", "scenes.title": "Scenes", "scenes.add": "Capture Scene", "scenes.edit": "Edit Scene", @@ -1824,6 +1830,8 @@ "audio_template.error.engines": "Failed to load audio engines", "audio_template.error.required": "Please fill in all required fields", "audio_template.error.delete": "Failed to delete audio template", + "audio_template.error.save_failed": "Failed to save audio template", + "audio_template.error.load_failed": "Failed to load audio template", "streams.group.value": "Value Sources", "streams.group.sync": "Sync Clocks", "streams.group.gradients": "Gradients", @@ -1843,6 +1851,7 @@ "gradient.error.name_required": "Name is required", "gradient.error.min_stops": "At least 2 color stops are required", "gradient.error.delete_failed": "Failed to delete gradient", + "gradient.error.save_failed": "Failed to save gradient", "gradient.create_name": "New gradient name:", "gradient.edit_name": "Rename gradient:", "gradient.confirm_delete": "Delete gradient \"{name}\"?", @@ -2208,6 +2217,7 @@ "device.error.update": "Failed to update device", "device.error.save": "Failed to save settings", "device.error.clone_failed": "Failed to clone device", + "device.error.load_failed": "Failed to load device", "device_discovery.error.fill_all_fields": "Please fill in all fields", "device_discovery.added": "Device added successfully", "device_discovery.error.add_failed": "Failed to add device", @@ -2249,6 +2259,7 @@ "target.error.stop_failed": "Failed to stop target", "target.error.clone_failed": "Failed to clone target", "target.error.delete_failed": "Failed to delete target", + "target.error.load_failed": "Failed to load target", "targets.stop_all.button": "Stop All", "targets.stop_all.none_running": "No targets are currently running", "targets.stop_all.stopped": "Stopped {count} target(s)", @@ -2263,6 +2274,7 @@ "pattern.error.clone_failed": "Failed to clone pattern template", "pattern.error.delete_failed": "Failed to delete pattern template", "pattern.error.capture_bg_failed": "Failed to capture background", + "pattern.error.save_failed": "Failed to save pattern template", "stream.error.clone_picture_failed": "Failed to clone picture source", "stream.error.clone_capture_failed": "Failed to clone capture template", "stream.error.clone_pp_failed": "Failed to clone postprocessing template", @@ -2953,6 +2965,7 @@ "audio_processing.error.load": "Error loading audio processing template", "audio_processing.error.delete": "Error deleting audio processing template", "audio_processing.error.clone_failed": "Failed to clone audio processing template", + "audio_processing.error.save_failed": "Failed to save audio processing template", "audio_processing.filter_count": "Filter count", "audio_processing.filters_label": "filters", "streams.group.audio_processing": "Audio Processing", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index b8ab239..24b5c36 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -180,6 +180,8 @@ "templates.error.engines": "Не удалось загрузить движки", "templates.error.required": "Пожалуйста, заполните все обязательные поля", "templates.error.delete": "Не удалось удалить шаблон", + "templates.error.save_failed": "Не удалось сохранить шаблон", + "templates.error.load_failed": "Не удалось загрузить шаблон", "templates.test.title": "Тест Захвата", "templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.", "templates.test.display": "Дисплей:", @@ -1317,6 +1319,10 @@ "automations.deleted": "Автоматизация удалена", "automations.error.name_required": "Введите название", "automations.error.clone_failed": "Не удалось клонировать автоматизацию", + "automations.error.load_failed": "Не удалось загрузить автоматизацию", + "automations.error.save_failed": "Не удалось сохранить автоматизацию", + "automations.error.delete_failed": "Не удалось удалить автоматизацию", + "automations.error.toggle_failed": "Не удалось переключить автоматизацию", "scenes.title": "Сцены", "scenes.add": "Захватить сцену", "scenes.edit": "Редактировать сцену", @@ -1789,6 +1795,10 @@ "audio_template.error.engines": "Не удалось загрузить аудиодвижки", "audio_template.error.required": "Пожалуйста, заполните все обязательные поля", "audio_template.error.delete": "Не удалось удалить аудиошаблон", + "audio_template.error.save_failed": "Не удалось сохранить аудиошаблон", + "audio_template.error.load_failed": "Не удалось загрузить аудиошаблон", + "gradient.error.save_failed": "Не удалось сохранить градиент", + "gradient.error.delete_failed": "Не удалось удалить градиент", "streams.group.value": "Источники значений", "streams.group.sync": "Часы синхронизации", "tree.group.picture": "Источники изображений", @@ -2067,6 +2077,7 @@ "device.error.update": "Не удалось обновить устройство", "device.error.save": "Не удалось сохранить настройки", "device.error.clone_failed": "Не удалось клонировать устройство", + "device.error.load_failed": "Не удалось загрузить устройство", "device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля", "device_discovery.added": "Устройство успешно добавлено", "device_discovery.error.add_failed": "Не удалось добавить устройство", @@ -2108,6 +2119,7 @@ "target.error.stop_failed": "Не удалось остановить цель", "target.error.clone_failed": "Не удалось клонировать цель", "target.error.delete_failed": "Не удалось удалить цель", + "target.error.load_failed": "Не удалось загрузить цель", "targets.stop_all.button": "Остановить все", "targets.stop_all.none_running": "Нет запущенных целей", "targets.stop_all.stopped": "Остановлено целей: {count}", @@ -2122,6 +2134,7 @@ "pattern.error.clone_failed": "Не удалось клонировать шаблон узоров", "pattern.error.delete_failed": "Не удалось удалить шаблон узоров", "pattern.error.capture_bg_failed": "Не удалось захватить фон", + "pattern.error.save_failed": "Не удалось сохранить шаблон узоров", "stream.error.clone_picture_failed": "Не удалось клонировать источник изображения", "stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата", "stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки", @@ -2634,6 +2647,7 @@ "audio_processing.error.load": "Ошибка загрузки шаблона обработки звука", "audio_processing.error.delete": "Ошибка удаления шаблона обработки звука", "audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука", + "audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука", "audio_processing.filter_count": "Количество фильтров", "audio_processing.filters_label": "фильтров", "streams.group.audio_processing": "Обработка звука", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 8310f1b..9a668e4 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -178,6 +178,8 @@ "templates.error.engines": "加载引擎失败", "templates.error.required": "请填写所有必填项", "templates.error.delete": "删除模板失败", + "templates.error.save_failed": "保存模板失败", + "templates.error.load_failed": "加载模板失败", "templates.test.title": "测试采集", "templates.test.description": "保存前测试此模板,查看采集预览和性能指标。", "templates.test.display": "显示器:", @@ -1313,6 +1315,10 @@ "automations.deleted": "自动化已删除", "automations.error.name_required": "名称为必填项", "automations.error.clone_failed": "克隆自动化失败", + "automations.error.load_failed": "加载自动化失败", + "automations.error.save_failed": "保存自动化失败", + "automations.error.delete_failed": "删除自动化失败", + "automations.error.toggle_failed": "切换自动化失败", "scenes.title": "场景", "scenes.add": "捕获场景", "scenes.edit": "编辑场景", @@ -1785,6 +1791,10 @@ "audio_template.error.engines": "加载音频引擎失败", "audio_template.error.required": "请填写所有必填项", "audio_template.error.delete": "删除音频模板失败", + "audio_template.error.save_failed": "保存音频模板失败", + "audio_template.error.load_failed": "加载音频模板失败", + "gradient.error.save_failed": "保存渐变失败", + "gradient.error.delete_failed": "删除渐变失败", "streams.group.value": "值源", "streams.group.sync": "同步时钟", "tree.group.picture": "图片源", @@ -2063,6 +2073,7 @@ "device.error.update": "更新设备失败", "device.error.save": "保存设置失败", "device.error.clone_failed": "克隆设备失败", + "device.error.load_failed": "加载设备失败", "device_discovery.error.fill_all_fields": "请填写所有字段", "device_discovery.added": "设备添加成功", "device_discovery.error.add_failed": "添加设备失败", @@ -2104,6 +2115,7 @@ "target.error.stop_failed": "停止目标失败", "target.error.clone_failed": "克隆目标失败", "target.error.delete_failed": "删除目标失败", + "target.error.load_failed": "加载目标失败", "targets.stop_all.button": "全部停止", "targets.stop_all.none_running": "当前没有运行中的目标", "targets.stop_all.stopped": "已停止 {count} 个目标", @@ -2118,6 +2130,7 @@ "pattern.error.clone_failed": "克隆图案模板失败", "pattern.error.delete_failed": "删除图案模板失败", "pattern.error.capture_bg_failed": "捕获背景失败", + "pattern.error.save_failed": "保存图案模板失败", "stream.error.clone_picture_failed": "克隆图片源失败", "stream.error.clone_capture_failed": "克隆捕获模板失败", "stream.error.clone_pp_failed": "克隆后处理模板失败", @@ -2628,6 +2641,7 @@ "audio_processing.error.load": "加载音频处理模板时出错", "audio_processing.error.delete": "删除音频处理模板时出错", "audio_processing.error.clone_failed": "克隆音频处理模板失败", + "audio_processing.error.save_failed": "保存音频处理模板失败", "audio_processing.filter_count": "过滤器数量", "audio_processing.filters_label": "个过滤器", "streams.group.audio_processing": "音频处理",