refactor(frontend): shared API client + automations registry (audit M7, H8)

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<RuleType, ...> 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 <status> } ... 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.
This commit is contained in:
2026-05-28 14:58:08 +03:00
parent 49c35a2ea0
commit bb3a316e35
39 changed files with 1133 additions and 1280 deletions
+92 -11
View File
@@ -18,6 +18,7 @@ context.
| `05f73ee` | H6 (bindable extraction only) | | `05f73ee` | H6 (bindable extraction only) |
| `3b8f00e` + `c1aa2eb` | C7 store-side | | `3b8f00e` + `c1aa2eb` | C7 store-side |
| `2f15fbb` | H3 | | `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 All commits have ≥1 code-review subagent pass with HIGH findings fixed
before commit. Tests pass on each commit; ruff clean; tsc + bundle build 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. **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 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. hand-rolled.
**Approach:** introduce a rule-type registry on the frontend matching **Done:** the two remaining hand-rolled dispatch ladders were converted
the backend's `_RULE_HANDLERS` shape. 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<RuleType, …>` (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 ### 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 **Estimated scope:** half a session. Low impact since the parallel-change
problem is already fixed. 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` **File:** every `static/js/features/*.ts`
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every `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 **Done:** `static/js/core/api-client.ts` now provides typed
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing, `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
error normalisation. Replace `fetchWithAuth` calls across features. `fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
toast are unchanged) and collapse the repeated
`if (!resp.ok) { detail || HTTP <status> } … 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: '<English>'`
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 #### M8 — Global `_cached*` `let` vars
@@ -262,7 +332,11 @@ always start before reading).
### Other frontend (severity in main list above) ### 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) - **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
- **H8** — `automations.ts` 1410 LOC (mirror H2) - **H8** — `automations.ts` 1410 LOC (mirror H2)
- **M7** — shared API client - **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 Critical to have typescript-reviewer feedback + manual UI testing after
each split. 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) ### Session B — Device redesign (1-2 sessions)
Address H4 alone. Touches device storage + provider classes; needs a Address H4 alone. Touches device storage + provider classes; needs a
@@ -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 `<T>`) 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 <status>`. 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 <status>`.
*/
async function unwrap<T>(resp: Response, opts?: ApiRequestOpts): Promise<T> {
if (!resp.ok) {
const body = await resp.json().catch(() => ({} as Record<string, unknown>));
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 <path>` → parsed JSON body of type `T`. */
export async function apiGet<T>(path: string, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('GET', undefined, opts));
return unwrap<T>(resp, opts);
}
/** `POST <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('POST', body, opts));
return unwrap<T>(resp, opts);
}
/** `PUT <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('PUT', body, opts));
return unwrap<T>(resp, opts);
}
/** `PATCH <path>` with a JSON body → parsed JSON body of type `T`. */
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('PATCH', body, opts));
return unwrap<T>(resp, opts);
}
/** `DELETE <path>` → parsed JSON body of type `T` (often `void`/204). */
export async function apiDelete<T = void>(path: string, opts?: ApiRequestOpts): Promise<T> {
const resp = await fetchWithAuth(path, buildOpts('DELETE', undefined, opts));
return unwrap<T>(resp, opts);
}
+7 -7
View File
@@ -2,7 +2,8 @@
* Reusable data cache with fetch deduplication, invalidation, and subscribers. * 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 // Server JSON is treated as `any` at the cache boundary because each
// extractor knows the endpoint-specific shape (e.g. `json.devices`). // extractor knows the endpoint-specific shape (e.g. `json.devices`).
@@ -66,19 +67,18 @@ export class DataCache<T = unknown> {
async _doFetch(): Promise<T> { async _doFetch(): Promise<T> {
try { try {
const resp = await fetchWithAuth(this._endpoint); const json = await apiGet<any>(this._endpoint);
if (!resp.ok) {
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
return this._data;
}
const json = await resp.json();
this._data = this._extractData(json); this._data = this._extractData(json);
this._fresh = true; this._fresh = true;
this._notify(); this._notify();
return this._data; return this._data;
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError && err.isAuth) return this._data; if (err instanceof ApiError && err.isAuth) return this._data;
if (err instanceof ApiError) {
console.error(`[DataCache] ${this._endpoint}: HTTP ${err.status}`);
} else {
console.error(`Cache fetch ${this._endpoint}:`, err); console.error(`Cache fetch ${this._endpoint}:`, err);
}
return this._data; return this._data;
} }
} }
@@ -2,7 +2,8 @@
* Command Palette — global search & navigation (Ctrl+K / Cmd+K). * 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 { t } from './i18n.ts';
import { navigateToCard } from './navigation.ts'; import { navigateToCard } from './navigation.ts';
import { import {
@@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) {
action: async () => { action: async () => {
const isRunning = actionItem._running; const isRunning = actionItem._running;
const endpoint = isRunning ? 'stop' : 'start'; const endpoint = isRunning ? 'stop' : 'start';
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' }); try {
if (resp.ok) { await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, {
errorMessage: t(`target.error.${endpoint}_failed`),
});
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success'); showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
actionItem._running = !isRunning; actionItem._running = !isRunning;
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start'); actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
actionItem.icon = !isRunning ? '■' : '▶'; actionItem.icon = !isRunning ? '■' : '▶';
_render(); _render();
} else { } catch (e: any) {
const err = await resp.json().catch(() => ({})); if (e.isAuth) return;
const d = err.detail || err.message || ''; showToast(e.message || t(`target.error.${endpoint}_failed`), 'error');
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
} }
}, },
}; };
@@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) {
action: async () => { action: async () => {
const isEnabled = autoItem._enabled; const isEnabled = autoItem._enabled;
const endpoint = isEnabled ? 'disable' : 'enable'; const endpoint = isEnabled ? 'disable' : 'enable';
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' }); try {
if (resp.ok) { await apiPost(`/automations/${a.id}/${endpoint}`, undefined, {
errorMessage: t('search.action.' + endpoint) + ' failed',
});
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success'); showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
autoItem._enabled = !isEnabled; autoItem._enabled = !isEnabled;
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable'); autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
_render(); _render();
} else { } catch (e: any) {
const err = await resp.json().catch(() => ({})); if (e.isAuth) return;
const d = err.detail || err.message || ''; showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error');
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
} }
}, },
}; };
@@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) {
items.push({ items.push({
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡', name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
action: async () => { action: async () => {
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' }); try {
if (resp.ok) { showToast(t('scenes.activated'), 'success'); } await apiPost(`/scene-presets/${sp.id}/activate`, undefined, {
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'); } 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() { async function _fetchAllEntities() {
const [statesData, ...results] = await Promise.all([ const [statesData, ...results] = await Promise.all([
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 }) apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 })
.then(r => r.ok ? r.json() : {}) .then((data) => data.states || {})
.then((data: any) => data.states || {})
.catch(() => ({})), .catch(() => ({})),
..._responseKeys.map(([ep, key]) => ..._responseKeys.map(([ep, key]) =>
fetchWithAuth(ep as string, { retry: false, timeout: 5000 }) apiGet<any>(ep as string, { retry: false, timeout: 5000 })
.then((r: any) => r.ok ? r.json() : {}) .then((data) => data[key as string] || [])
.then((data: any) => data[key as string] || [])
.catch((): any[] => [])), .catch((): any[] => [])),
]); ]);
return _buildItems(results, statesData); return _buildItems(results, statesData);
@@ -3,7 +3,7 @@
* Supports creating, changing, and detaching connections via the graph editor. * Supports creating, changing, and detaching connections via the graph editor.
*/ */
import { fetchWithAuth } from './api.ts'; import { apiPut } from './api-client.ts';
import { import {
streamsCache, colorStripSourcesCache, valueSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj, audioSourcesCache, outputTargetsCache, automationsCacheObj,
@@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
const body = { [field]: newSourceId }; const body = { [field]: newSourceId };
try { try {
const resp = await fetchWithAuth(url, { await apiPut(url, body);
method: 'PUT',
body: JSON.stringify(body),
});
if (!resp.ok) return false;
// Invalidate the relevant cache so data refreshes // Invalidate the relevant cache so data refreshes
if (entry.cache) entry.cache.invalidate(); if (entry.cache) entry.cache.invalidate();
return true; return true;
@@ -22,7 +22,8 @@
* attachProcessPicker(container, textarea); * 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 { t } from './i18n.ts';
import { ICON_SEARCH } from './icons.ts'; import { ICON_SEARCH } from './icons.ts';
@@ -241,16 +242,21 @@ class NamePalette {
/* ─── fetch helpers ────────────────────────────────────────── */ /* ─── fetch helpers ────────────────────────────────────────── */
async function _fetchProcesses(): Promise<string[]> { async function _fetchProcesses(): Promise<string[]> {
const resp = await fetchWithAuth('/system/processes'); try {
if (!resp || !resp.ok) return []; const data = await apiGet<{ processes?: string[] }>('/system/processes');
const data = await resp.json();
return data.processes || []; return data.processes || [];
} catch {
return [];
}
} }
async function _fetchNotificationApps(): Promise<string[]> { async function _fetchNotificationApps(): Promise<string[]> {
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history'); let data: { history?: any[] };
if (!resp || !resp.ok) return []; try {
const data = await resp.json(); data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history');
} catch {
return [];
}
const history: any[] = data.history || []; const history: any[] = data.history || [];
// Deduplicate app names, preserving original case of first occurrence // Deduplicate app names, preserving original case of first occurrence
const seen = new Map<string, string>(); const seen = new Map<string, string>();
@@ -13,7 +13,7 @@
* Tags are stored lowercase, trimmed, deduplicated. * Tags are stored lowercase, trimmed, deduplicated.
*/ */
import { fetchWithAuth } from './api.ts'; import { apiGet } from './api-client.ts';
let _allTagsCache: string[] | null = null; let _allTagsCache: string[] | null = null;
let _allTagsFetchPromise: Promise<string[]> | null = null; let _allTagsFetchPromise: Promise<string[]> | null = null;
@@ -22,8 +22,7 @@ let _allTagsFetchPromise: Promise<string[]> | null = null;
export async function fetchAllTags(): Promise<string[]> { export async function fetchAllTags(): Promise<string[]> {
if (_allTagsCache) return _allTagsCache; if (_allTagsCache) return _allTagsCache;
if (_allTagsFetchPromise) return _allTagsFetchPromise; if (_allTagsFetchPromise) return _allTagsFetchPromise;
_allTagsFetchPromise = fetchWithAuth('/tags') _allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags')
.then(r => r.json())
.then(data => { .then(data => {
_allTagsCache = data.tags || []; _allTagsCache = data.tags || [];
_allTagsFetchPromise = null; _allTagsFetchPromise = null;
@@ -5,7 +5,8 @@
* The canvas shows monitor rectangles that can be repositioned for visual clarity. * 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 { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts'; import { showToast } from '../core/ui.ts';
@@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal();
export async function showAdvancedCalibration(cssId: string): Promise<void> { export async function showAdvancedCalibration(cssId: string): Promise<void> {
try { try {
const [cssSources, psResp] = await Promise.all([ const [cssSources, psData] = await Promise.all([
colorStripSourcesCache.fetch(), colorStripSourcesCache.fetch(),
fetchWithAuth('/picture-sources'), apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})),
]); ]);
const source = cssSources.find(s => s.id === cssId); const source = cssSources.find(s => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration: Calibration = source.calibration || {} as Calibration; const calibration: Calibration = source.calibration || {} as Calibration;
const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; const psList = psData.streams || [];
_state.cssId = cssId; _state.cssId = cssId;
_state.sourceType = source.source_type || 'picture_advanced'; _state.sourceType = source.source_type || 'picture_advanced';
@@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise<void> {
}; };
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, { await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, {
method: 'PUT', errorMessage: t('calibration.error.save_failed'),
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
}); });
if (resp.ok) {
showToast(t('calibration.saved'), 'success'); showToast(t('calibration.saved'), 'success');
colorStripSourcesCache.invalidate(); colorStripSourcesCache.invalidate();
_modal.forceClose(); _modal.forceClose();
} else { } catch (error: any) {
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) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(error.message || t('calibration.error.save_failed'), 'error'); showToast(error.message || t('calibration.error.save_failed'), 'error');
} }
@@ -15,7 +15,8 @@ import {
_cachedAudioFilterDefs, _cachedAudioFilterDefs,
audioFilterDefsCache, audioFilterDefsCache,
} from '../core/state.ts'; } 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 { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
@@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) {
try { try {
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch(); if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`); const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`; document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
(document.getElementById('apt-id') as HTMLInputElement).value = templateId; (document.getElementById('apt-id') as HTMLInputElement).value = templateId;
@@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() {
}; };
try { try {
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates'; if (templateId) {
const method = templateId ? 'PUT' : 'POST'; await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') });
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); } else {
await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
} }
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success'); 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) { export async function cloneAudioProcessingTemplate(templateId: string) {
try { try {
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`); const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') });
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
await showAudioProcessingTemplateModal(tmpl); await showAudioProcessingTemplateModal(tmpl);
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' }); await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('audio_processing.deleted'), 'success'); showToast(t('audio_processing.deleted'), 'success');
audioProcessingTemplatesCache.invalidate(); audioProcessingTemplatesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -11,7 +11,8 @@
*/ */
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts'; 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 { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -178,16 +179,10 @@ export async function saveAudioSource() {
} }
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/audio-sources/${id}` : '/audio-sources'; await apiPut(`/audio-sources/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/audio-sources', payload);
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}`);
} }
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success'); showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose(); audioSourceModal.forceClose();
@@ -203,9 +198,7 @@ export async function saveAudioSource() {
export async function editAudioSource(sourceId: any) { export async function editAudioSource(sourceId: any) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json();
await showAudioSourceModal(data.source_type, data); await showAudioSourceModal(data.source_type, data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) {
export async function cloneAudioSource(sourceId: any) { export async function cloneAudioSource(sourceId: any) {
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`); const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
if (!resp.ok) throw new Error(t('audio_source.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` });
delete data.id;
data.name = data.name + ' (copy)';
await showAudioSourceModal(data.source_type, data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' }); await apiDelete(`/audio-sources/${sourceId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('audio_source.deleted'), 'success'); showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate(); audioSourcesCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {};
async function _loadAudioDevices() { async function _loadAudioDevices() {
try { try {
const resp = await fetchWithAuth('/audio-devices'); const data = await apiGet<{ by_engine?: Record<string, any[]> }>('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
_cachedDevicesByEngine = data.by_engine || {}; _cachedDevicesByEngine = data.by_engine || {};
} catch { } catch {
_cachedDevicesByEngine = {}; _cachedDevicesByEngine = {};
@@ -10,7 +10,8 @@ import {
} from '../core/state.ts'; } from '../core/state.ts';
import { prefetchHAEntities } from './home-assistant-sources.ts'; import { prefetchHAEntities } from './home-assistant-sources.ts';
import { getHAEntityIcon } from '../core/icons.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 { t } from '../core/i18n.ts';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts'; import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.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 { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.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<Automation>({ registerIconEntityType('automation', makeSimpleIconAdapter<Automation>({
cache: automationsCacheObj, cache: automationsCacheObj,
@@ -53,9 +54,7 @@ let _haRuleEntities: any[] = [];
async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise<void> { async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haRuleEntities = []; return; } if (!haSourceId) { _haRuleEntities = []; return; }
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haRuleEntities = []; return; }
const data = await resp.json();
_haRuleEntities = data.entities || []; _haRuleEntities = data.entities || [];
// Mirror into the shared cache so automation/value-source card // Mirror into the shared cache so automation/value-source card
// chips pick up friendly names on the next render. // chips pick up friendly names on the next render.
@@ -134,10 +133,8 @@ const automationModal = new AutomationEditorModal();
// ── Bulk action handlers ── // ── Bulk action handlers ──
async function _bulkEnableAutomations(ids: any) { async function _bulkEnableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id => const results = await Promise.allSettled(ids.map((id: string) => apiPost(`/automations/${id}/enable`)));
fetchWithAuth(`/automations/${id}/enable`, { method: 'POST' }) const failed = results.filter(r => r.status === 'rejected').length;
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} enabled`, 'warning');
else showToast(t('automations.updated'), 'success'); else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
@@ -145,10 +142,8 @@ async function _bulkEnableAutomations(ids: any) {
} }
async function _bulkDisableAutomations(ids: any) { async function _bulkDisableAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id => const results = await Promise.allSettled(ids.map((id: string) => apiPost(`/automations/${id}/disable`)));
fetchWithAuth(`/automations/${id}/disable`, { method: 'POST' }) const failed = results.filter(r => r.status === 'rejected').length;
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} disabled`, 'warning');
else showToast(t('automations.updated'), 'success'); else showToast(t('automations.updated'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
@@ -156,10 +151,8 @@ async function _bulkDisableAutomations(ids: any) {
} }
async function _bulkDeleteAutomations(ids: any) { async function _bulkDeleteAutomations(ids: any) {
const results = await Promise.allSettled(ids.map(id => const results = await Promise.allSettled(ids.map((id: string) => apiDelete(`/automations/${id}`)));
fetchWithAuth(`/automations/${id}`, { method: 'DELETE' }) const failed = results.filter(r => r.status === 'rejected').length;
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('automations.deleted'), 'success'); else showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
@@ -314,7 +307,7 @@ type RuleChipBuilder = (c: any) => ModChipOpts;
icon + a tight, scannable label. Mirrors the AUTO card in the icon + a tight, scannable label. Mirrors the AUTO card in the
cards-redesign demo: rules read as a left-to-right chain leading into cards-redesign demo: rules read as a left-to-right chain leading into
the scene activation. */ the scene activation. */
const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = { const RULE_CHIP_RENDERERS: Record<RuleType, RuleChipBuilder> = {
startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }), startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }),
application: (c) => { application: (c) => {
const apps = (c.apps || []).join(', ') || '—'; const apps = (c.apps || []).join(', ') || '—';
@@ -584,9 +577,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
if (automationId) { if (automationId) {
titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`; titleEl!.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
try { try {
const resp = await fetchWithAuth(`/automations/${automationId}`); const automation = await apiGet<any>(`/automations/${automationId}`, { errorMessage: t('automations.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
idInput.value = automation.id; idInput.value = automation.id;
nameInput.value = automation.name; nameInput.value = automation.name;
@@ -749,7 +740,7 @@ export function addAutomationRule() {
_autoGenerateAutomationName(); _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 = { const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone, startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor, time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
@@ -834,63 +825,23 @@ function _wireTimeRangePicker(container: HTMLElement) {
sync(); sync();
} }
function addAutomationRuleRow(rule: any) { // ===== Per-rule-type field renderers (registry) =====
const list = document.getElementById('automation-rules-list'); //
const row = document.createElement('div'); // Each function paints the editor fields for one rule type into the row's
row.className = 'automation-rule-row'; // ``.rule-fields-container`` and wires any IconSelect/EntitySelect widgets.
const ruleType = rule.rule_type || 'application'; // 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).
row.innerHTML = ` type RuleFieldRenderer = (container: HTMLElement, data: any) => void;
<div class="rule-header">
<span class="rule-collapse-chevron">&#9654;</span>
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
</div>
<div class="rule-fields-container" style="display:none"></div>
`;
// Wire collapse/expand toggle function _renderStartupFields(container: HTMLElement, _data: any): void {
const chevron = row.querySelector('.rule-collapse-chevron') as HTMLElement;
chevron.addEventListener('click', () => {
const fields = row.querySelector('.rule-fields-container') as HTMLElement;
const collapsed = fields.style.display === 'none';
fields.style.display = collapsed ? '' : 'none';
chevron.style.transform = collapsed ? 'rotate(90deg)' : '';
});
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Remove button — dispose any widgets the rule body stashed (portal
// overlays would otherwise leak) before pulling the row from the DOM.
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
removeBtn.addEventListener('click', () => {
_disposeHTTPPollWidgets(container);
row.remove();
const autoGen = window._autoGenerateAutomationName;
if (typeof autoGen === 'function') autoGen();
});
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildRuleTypeItems(),
columns: 3,
} as 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).
_disposeHTTPPollWidgets(container);
if (type === 'startup') {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`; container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return; }
}
if (type === 'time_of_day') { function _renderTimeOfDayFields(container: HTMLElement, data: any): void {
const startTime = data.start_time || '00:00'; const startTime = data.start_time || '00:00';
const endTime = data.end_time || '23:59'; const endTime = data.end_time || '23:59';
const [sh, sm] = startTime.split(':').map(Number); const [sh, sm] = startTime.split(':').map(Number);
@@ -922,9 +873,9 @@ function addAutomationRuleRow(rule: any) {
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small> <small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`; </div>`;
_wireTimeRangePicker(container); _wireTimeRangePicker(container);
return; }
}
if (type === 'system_idle') { function _renderSystemIdleFields(container: HTMLElement, data: any): void {
const idleMinutes = data.idle_minutes ?? 5; const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true; const whenIdle = data.when_idle ?? true;
container.innerHTML = ` container.innerHTML = `
@@ -950,9 +901,9 @@ function addAutomationRuleRow(rule: any) {
], ],
columns: 2, columns: 2,
} as any); } as any);
return; }
}
if (type === 'display_state') { function _renderDisplayStateFields(container: HTMLElement, data: any): void {
const dState = data.state || 'on'; const dState = data.state || 'on';
container.innerHTML = ` container.innerHTML = `
<div class="rule-fields"> <div class="rule-fields">
@@ -965,9 +916,9 @@ function addAutomationRuleRow(rule: any) {
</div> </div>
</div>`; </div>`;
enhanceMiniSelects(container, 'select.rule-display-state'); enhanceMiniSelects(container, 'select.rule-display-state');
return; }
}
if (type === 'mqtt') { function _renderMqttFields(container: HTMLElement, data: any): void {
const topic = data.topic || ''; const topic = data.topic || '';
const payload = data.payload || ''; const payload = data.payload || '';
const matchMode = data.match_mode || 'exact'; const matchMode = data.match_mode || 'exact';
@@ -991,9 +942,9 @@ function addAutomationRuleRow(rule: any) {
</div> </div>
</div>`; </div>`;
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode'); enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
return; }
}
if (type === 'home_assistant') { function _renderHomeAssistantFields(container: HTMLElement, data: any): void {
const haSourceId = data.ha_source_id || ''; const haSourceId = data.ha_source_id || '';
const entityId = data.entity_id || ''; const entityId = data.entity_id || '';
const haState = data.state || ''; const haState = data.state || '';
@@ -1070,10 +1021,9 @@ function addAutomationRuleRow(rule: any) {
// Load entities if source is already selected // Load entities if source is already selected
if (haSourceId) _loadHAEntitiesForRule(haSourceId, container); if (haSourceId) _loadHAEntitiesForRule(haSourceId, container);
}
return; function _renderHttpPollFields(container: HTMLElement, data: any): void {
}
if (type === 'http_poll') {
const vsId = data.value_source_id || ''; const vsId = data.value_source_id || '';
const operator = data.operator || 'equals'; const operator = data.operator || 'equals';
const valueStr = data.value || ''; const valueStr = data.value || '';
@@ -1151,10 +1101,9 @@ function addAutomationRuleRow(rule: any) {
vsEntitySelect, vsEntitySelect,
opIconSelect, opIconSelect,
}; };
}
return; function _renderWebhookFields(container: HTMLElement, data: any): void {
}
if (type === 'webhook') {
if (data.token) { if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
container.innerHTML = ` container.innerHTML = `
@@ -1176,8 +1125,9 @@ function addAutomationRuleRow(rule: any) {
<p class="webhook-save-hint">${t('automations.rule.webhook.save_first')}</p> <p class="webhook-save-hint">${t('automations.rule.webhook.save_first')}</p>
</div>`; </div>`;
} }
return; }
}
function _renderApplicationFields(container: HTMLElement, data: any): void {
const appsValue = (data.apps || []).join('\n'); const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running'; const matchType = data.match_type || 'running';
container.innerHTML = ` container.innerHTML = `
@@ -1212,6 +1162,74 @@ function addAutomationRuleRow(rule: any) {
columns: 2, columns: 2,
} as any); } as any);
} }
}
const RULE_FIELD_RENDERERS: Record<RuleType, RuleFieldRenderer> = {
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');
row.className = 'automation-rule-row';
const ruleType = rule.rule_type || 'application';
row.innerHTML = `
<div class="rule-header">
<span class="rule-collapse-chevron">&#9654;</span>
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
</div>
<div class="rule-fields-container" style="display:none"></div>
`;
// Wire collapse/expand toggle
const chevron = row.querySelector('.rule-collapse-chevron') as HTMLElement;
chevron.addEventListener('click', () => {
const fields = row.querySelector('.rule-fields-container') as HTMLElement;
const collapsed = fields.style.display === 'none';
fields.style.display = collapsed ? '' : 'none';
chevron.style.transform = collapsed ? 'rotate(90deg)' : '';
});
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Remove button — dispose any widgets the rule body stashed (portal
// overlays would otherwise leak) before pulling the row from the DOM.
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
removeBtn.addEventListener('click', () => {
_disposeHTTPPollWidgets(container);
row.remove();
const autoGen = window._autoGenerateAutomationName;
if (typeof autoGen === 'function') autoGen();
});
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildRuleTypeItems(),
columns: 3,
} as 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)
// before the per-type renderer repaints the container.
_disposeHTTPPollWidgets(container);
const renderer = RULE_FIELD_RENDERERS[type as RuleType] ?? RULE_FIELD_RENDERERS.application;
renderer(container, data);
} }
renderFields(ruleType, rule); renderFields(ruleType, rule);
@@ -1224,52 +1242,50 @@ function addAutomationRuleRow(rule: any) {
function getAutomationEditorRules() { // ===== Per-rule-type form collectors (registry) =====
const rows = document.querySelectorAll('#automation-rules-list .automation-rule-row'); //
const rules: any[] = []; // Each collector reads one rule row's inputs back into the rule object the
rows.forEach(row => { // API expects. Dispatched through ``RULE_COLLECTORS`` — the serialise-side
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement; // mirror of ``RULE_FIELD_RENDERERS`` and the backend ``_RULE_HANDLERS``.
const ruleType = typeSelect ? typeSelect.value : 'application';
if (ruleType === 'startup') { type RuleCollector = (row: Element) => Record<string, any>;
rules.push({ rule_type: 'startup' });
} else if (ruleType === 'time_of_day') { const RULE_COLLECTORS: Record<RuleType, RuleCollector> = {
rules.push({ startup: () => ({ rule_type: 'startup' }),
time_of_day: (row) => ({
rule_type: 'time_of_day', rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
}); }),
} else if (ruleType === 'system_idle') { system_idle: (row) => ({
rules.push({
rule_type: 'system_idle', rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5, idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true', when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true',
}); }),
} else if (ruleType === 'display_state') { display_state: (row) => ({
rules.push({
rule_type: 'display_state', rule_type: 'display_state',
state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on', state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on',
}); }),
} else if (ruleType === 'mqtt') { mqtt: (row) => ({
rules.push({
rule_type: 'mqtt', rule_type: 'mqtt',
topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(), topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value, payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact', match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
}); }),
} else if (ruleType === 'webhook') { webhook: (row) => {
const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement; const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement;
const r: any = { rule_type: 'webhook' }; const r: any = { rule_type: 'webhook' };
if (tokenInput && tokenInput.value) r.token = tokenInput.value; if (tokenInput && tokenInput.value) r.token = tokenInput.value;
rules.push(r); return r;
} else if (ruleType === 'home_assistant') { },
rules.push({ home_assistant: (row) => ({
rule_type: 'home_assistant', rule_type: 'home_assistant',
ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value, ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(), entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value, state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact', match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
}); }),
} else if (ruleType === 'http_poll') { http_poll: (row) => {
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals'; const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
const r: any = { const r: any = {
rule_type: 'http_poll', rule_type: 'http_poll',
@@ -1280,13 +1296,47 @@ function getAutomationEditorRules() {
if (op !== 'exists') { if (op !== 'exists') {
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value; r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
} }
rules.push(r); return r;
} else { },
application: (row) => {
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
rules.push({ rule_type: 'application', apps, match_type: matchType }); 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<string>(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') as RuleType;
const collect = RULE_COLLECTORS[ruleType] ?? RULE_COLLECTORS.application;
rules.push(collect(row));
}); });
return rules; return rules;
} }
@@ -1320,14 +1370,10 @@ export async function saveAutomationEditor() {
const isEdit = !!automationId; const isEdit = !!automationId;
try { try {
const url = isEdit ? `/automations/${automationId}` : '/automations'; if (isEdit) {
const resp = await fetchWithAuth(url, { await apiPut(`/automations/${automationId}`, body, { errorMessage: t('automations.error.save_failed') });
method: isEdit ? 'PUT' : 'POST', } else {
body: JSON.stringify(body), await apiPost('/automations', body, { errorMessage: t('automations.error.save_failed') });
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save automation');
} }
automationModal.forceClose(); automationModal.forceClose();
@@ -1343,18 +1389,14 @@ export async function saveAutomationEditor() {
export async function toggleAutomationEnabled(automationId: any, enable: any) { export async function toggleAutomationEnabled(automationId: any, enable: any) {
try { try {
const action = enable ? 'enable' : 'disable'; const action = enable ? 'enable' : 'disable';
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, { await apiPost(`/automations/${automationId}/${action}`, undefined, {
method: 'POST', 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(); automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; 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) { export async function cloneAutomation(automationId: any) {
try { try {
const resp = await fetchWithAuth(`/automations/${automationId}`); const automation = await apiGet<any>(`/automations/${automationId}`, { errorMessage: t('automations.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
openAutomationEditor(null, automation); openAutomationEditor(null, automation);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -1393,18 +1433,12 @@ export async function deleteAutomation(automationId: any, automationName: any) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/automations/${automationId}`, { await apiDelete(`/automations/${automationId}`, { errorMessage: t('automations.error.delete_failed') });
method: 'DELETE',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to delete automation');
}
showToast(t('automations.deleted'), 'success'); showToast(t('automations.deleted'), 'success');
automationsCacheObj.invalidate(); automationsCacheObj.invalidate();
loadAutomations(); loadAutomations();
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message || t('automations.error.delete_failed'), 'error');
} }
} }
@@ -5,7 +5,8 @@
import { import {
calibrationTestState, EDGE_TEST_COLORS, displaysCache, calibrationTestState, EDGE_TEST_COLORS, displaysCache,
} from '../core/state.ts'; } 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 { colorStripSourcesCache, devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts'; import { showToast } from '../core/ui.ts';
@@ -92,10 +93,7 @@ async function _clearCSSTestMode() {
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value; const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
if (!testDeviceId) return; if (!testDeviceId) return;
try { try {
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} });
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
});
} catch (err) { } catch (err) {
console.error('Failed to clear CSS test mode:', err); console.error('Failed to clear CSS test mode:', err);
} }
@@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) {
async function _checkOverlayStatus(cssId: any) { async function _checkOverlayStatus(cssId: any) {
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
if (resp.ok) { _setOverlayBtnActive(!!data.active);
const data = await resp.json();
_setOverlayBtnActive(data.active);
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() {
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
if (!cssId) return; if (!cssId) return;
try { try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
if (!resp.ok) return;
const { active } = await resp.json();
if (active) { if (active) {
await stopCSSOverlay(cssId); await stopCSSOverlay(cssId);
_setOverlayBtnActive(false); _setOverlayBtnActive(false);
@@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() {
export async function showCalibration(deviceId: any) { export async function showCalibration(deviceId: any) {
try { try {
const [response, displays] = await Promise.all([ const [device, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`), apiGet<any>(`/devices/${deviceId}`),
displaysCache.fetch().catch((): any[] => []), 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 calibration = device.calibration;
const preview = document.querySelector('.calibration-preview') as HTMLElement; const preview = document.querySelector('.calibration-preview') as HTMLElement;
@@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) {
updateCalibrationPreview(); updateCalibrationPreview();
try { try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, {
method: 'PUT', errorMessage: t('calibration.error.test_toggle_failed'),
body: JSON.stringify({ device_id: testDeviceId, edges }),
}); });
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) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err); console.error('Failed to toggle CSS test edge:', err);
@@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) {
updateCalibrationPreview(); updateCalibrationPreview();
try { try {
const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, { await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, {
method: 'PUT', errorMessage: t('calibration.error.test_toggle_failed'),
body: JSON.stringify({ edges })
}); });
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) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to toggle test edge:', err); console.error('Failed to toggle test edge:', err);
@@ -965,20 +939,16 @@ export async function saveCalibration() {
}; };
try { try {
let response;
if (cssMode) { if (cssMode) {
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture'; const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, {
method: 'PUT', errorMessage: t('calibration.error.save_failed'),
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
}); });
} else { } else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { await apiPut(`/devices/${deviceId}/calibration`, calibration, {
method: 'PUT', errorMessage: t('calibration.error.save_failed'),
body: JSON.stringify(calibration),
}); });
} }
if (response.ok) {
showToast(t('calibration.saved'), 'success'); showToast(t('calibration.saved'), 'success');
if (cssMode) colorStripSourcesCache.invalidate(); if (cssMode) colorStripSourcesCache.invalidate();
calibModal.forceClose(); calibModal.forceClose();
@@ -987,13 +957,6 @@ export async function saveCalibration() {
} else { } else {
window.loadDevices(); window.loadDevices();
} }
} 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';
}
} catch (err: any) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to save calibration:', err); console.error('Failed to save calibration:', err);
@@ -18,7 +18,7 @@
* Surface keys are free-form strings — anything calling `setCardMode` is * Surface keys are free-form strings — anything calling `setCardMode` is
* implicitly registering that key. Defaults are returned for unknown keys. * 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'; import { t } from '../core/i18n.ts';
const LS_KEY = 'card_modes_v1'; const LS_KEY = 'card_modes_v1';
@@ -131,10 +131,7 @@ function _scheduleServerPush(): void {
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> { async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
try { try {
await fetchWithAuth('/preferences/card-modes', { await apiPut('/preferences/card-modes', prefs);
method: 'PUT',
body: JSON.stringify(prefs),
});
} catch (e) { } catch (e) {
console.warn('card-modes server PUT failed', e); console.warn('card-modes server PUT failed', e);
} }
@@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 {
export async function syncCardModesFromServer(): Promise<void> { export async function syncCardModesFromServer(): Promise<void> {
if (_serverSyncedOnce) return; if (_serverSyncedOnce) return;
try { try {
const resp = await fetchWithAuth('/preferences/card-modes'); const data = await apiGet<any>('/preferences/card-modes');
if (!resp || !resp.ok) return;
const data = await resp.json();
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) { if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
_current = _normalise(data); _current = _normalise(data);
_persistLocal(); _persistLocal();
@@ -3,7 +3,7 @@
* Extracted from color-strips.ts to reduce file size. * 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 { gradientsCache, GradientEntity } from '../../core/state.ts';
import { t } from '../../core/i18n.ts'; import { t } from '../../core/i18n.ts';
import { showToast, showConfirm } from '../../core/ui.ts'; import { showToast, showConfirm } from '../../core/ui.ts';
@@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() {
color: s.color, color: s.color,
})); }));
try { try {
await fetchWithAuth('/gradients', { await apiPost('/gradients', { name: name.trim(), stops });
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), stops }),
});
await gradientsCache.fetch({ force: true }); await gradientsCache.fetch({ force: true });
showToast(t('color_strip.gradient.preset.saved'), 'success'); showToast(t('color_strip.gradient.preset.saved'), 'success');
} catch (e: any) { } catch (e: any) {
@@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() {
export async function deleteAndRefreshGradientPreset(gradientId: any) { export async function deleteAndRefreshGradientPreset(gradientId: any) {
try { try {
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); await apiDelete(`/gradients/${gradientId}`);
await gradientsCache.fetch({ force: true }); await gradientsCache.fetch({ force: true });
showToast(t('color_strip.gradient.preset.deleted'), 'success'); showToast(t('color_strip.gradient.preset.deleted'), 'success');
} catch (e: any) { } catch (e: any) {
@@ -221,12 +217,10 @@ export async function saveGradientEntity() {
const payload: any = { name, stops, description, tags }; const payload: any = { name, stops, description, tags };
try { try {
const url = id ? `/gradients/${id}` : '/gradients'; if (id) {
const method = id ? 'PUT' : 'POST'; await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') });
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); } else {
if (!res!.ok) { await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') });
const err = await res!.json();
throw new Error(err.detail || 'Failed to save gradient');
} }
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success'); 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 })); const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
if (!ok) return; if (!ok) return;
try { try {
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' }); await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') });
gradientsCache.invalidate(); gradientsCache.invalidate();
showToast(t('gradient.deleted'), 'success'); showToast(t('gradient.deleted'), 'success');
if (window.loadPictureSources) await window.loadPictureSources(); if (window.loadPictureSources) await window.loadPictureSources();
@@ -3,7 +3,8 @@
* Extracted from color-strips.ts to reduce file size. * 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 { t } from '../../core/i18n.ts';
import { showToast } from '../../core/ui.ts'; import { showToast } from '../../core/ui.ts';
import { import {
@@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() {
export async function testNotification(sourceId: string) { export async function testNotification(sourceId: string) {
try { try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!; const data = await apiPost<{ streams_notified?: number }>(
if (!resp.ok) { `/color-strip-sources/${sourceId}/notify`, undefined,
const err = await resp.json().catch(() => ({})); { errorMessage: t('color_strip.notification.test.error') },
showToast(err.detail || t('color_strip.notification.test.error'), 'error'); );
return; if ((data.streams_notified ?? 0) > 0) {
}
const data = await resp.json();
if (data.streams_notified > 0) {
showToast(t('color_strip.notification.test.ok'), 'success'); showToast(t('color_strip.notification.test.ok'), 'success');
} else { } else {
showToast(t('color_strip.notification.test.no_streams'), 'warning'); showToast(t('color_strip.notification.test.no_streams'), 'warning');
} }
} catch { } catch (e: any) {
showToast(t('color_strip.notification.test.error'), 'error'); showToast(e?.message || t('color_strip.notification.test.error'), 'error');
} }
} }
@@ -355,9 +353,7 @@ async function _loadNotificationHistory() {
if (!list) return; if (!list) return;
try { try {
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!; const data = await apiGet<any>('/color-strip-sources/os-notifications/history');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.available) { if (!data.available) {
list.innerHTML = ''; list.innerHTML = '';
@@ -12,7 +12,7 @@
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts, * not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
* preview strips, etc.) without a schema bump or migration. * 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 LS_KEY = 'dashboard_layout_v1';
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 1;
@@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 {
export async function syncDashboardLayoutFromServer(): Promise<void> { export async function syncDashboardLayoutFromServer(): Promise<void> {
if (_serverSyncedOnce) return; if (_serverSyncedOnce) return;
try { try {
const resp = await fetchWithAuth('/preferences/dashboard-layout'); const data = await apiGet<any>('/preferences/dashboard-layout');
if (!resp || !resp.ok) return;
const data = await resp.json();
if (data && typeof data === 'object' && data.version) { if (data && typeof data === 'object' && data.version) {
const merged = _mergeWithDefaults(data); const merged = _mergeWithDefaults(data);
_current = merged; _current = merged;
@@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void {
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> { async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
try { try {
await fetchWithAuth('/preferences/dashboard-layout', { await apiPut('/preferences/dashboard-layout', layout);
method: 'PUT',
body: JSON.stringify(layout),
});
} catch (e) { } catch (e) {
console.warn('dashboard layout PUT failed', e); console.warn('dashboard layout PUT failed', e);
} }
@@ -7,7 +7,8 @@ import {
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts'; import { showToast, desktopFocus } from '../core/ui.ts';
@@ -1036,20 +1037,11 @@ export async function scanForDevices(forceType?: any) {
try { try {
const scanTimeout = scanType === 'ble' ? 8 : 3; 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'; loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false; 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 || []; _discoveryCache[scanType] = data.devices || [];
// Only render if the user is still on this type // Only render if the user is still on this type
@@ -1267,14 +1259,7 @@ export async function handleAddDevice(event: any) {
} }
} }
const response = await fetchWithAuth('/devices', { await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') });
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'); showToast(t('device_discovery.added'), 'success');
devicesCache.invalidate(); devicesCache.invalidate();
addDeviceModal.forceClose(); addDeviceModal.forceClose();
@@ -1285,18 +1270,14 @@ export async function handleAddDevice(event: any) {
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial(); if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
}, 300); }, 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';
}
} catch (err: any) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
console.error('Failed to add device:', err); 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 = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`; container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
try { try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, {
if (!resp.ok) { errorMessage: t('device.openrgb.zone.error'),
const err = await resp.json().catch(() => ({})); });
container.innerHTML = `<span class="zone-error">${err.detail || t('device.openrgb.zone.error')}</span>`;
return;
}
const data = await resp.json();
_renderZoneCheckboxes(container, data.zones, preChecked); _renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err: any) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
container.innerHTML = `<span class="zone-error">${t('device.openrgb.zone.error')}</span>`; // HTTP errors carry the server detail in err.message; fall back to
// the localised generic on network errors.
container.innerHTML = `<span class="zone-error">${escapeHtml(err.message || t('device.openrgb.zone.error'))}</span>`;
} }
} }
@@ -1733,9 +1712,7 @@ function _showGameSenseFields(show: boolean) {
export async function cloneDevice(deviceId: any) { export async function cloneDevice(deviceId: any) {
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}`); const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load device');
const device = await resp.json();
showAddDevice(device.device_type || 'wled', device); showAddDevice(device.device_type || 'wled', device);
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
@@ -6,7 +6,8 @@ import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { 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 { _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'; 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')); const confirmed = await showConfirm(t('confirm.turn_off_device'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, { await apiPut(`/devices/${deviceId}/power`, { power: false }, {
method: 'PUT', errorMessage: t('device.error.power_off_failed'),
body: JSON.stringify({ power: false })
}); });
if (setResp.ok) {
showToast(t('device.power.off_success'), 'success'); showToast(t('device.power.off_success'), 'success');
} else {
const error = await setResp.json();
showToast(error.detail || 'Failed', 'error');
}
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('device.error.power_off_failed'), 'error'); showToast(error.message || t('device.error.power_off_failed'), 'error');
} }
} }
@@ -383,9 +378,9 @@ export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null; const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning'); if (btn) btn.classList.add('spinning');
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' }); const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>(
if (resp.ok) { `/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') },
const data = await resp.json(); );
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?'; const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
showToast(data.device_online showToast(data.device_online
? t('device.ping.online', { ms }) ? t('device.ping.online', { ms })
@@ -393,13 +388,9 @@ export async function pingDevice(deviceId: any) {
// Refresh device cards to update health dot // Refresh device cards to update health dot
devicesCache.invalidate(); devicesCache.invalidate();
await window.loadDevices(); await window.loadDevices();
} else {
const err = await resp.json();
showToast(err.detail || 'Ping failed', 'error');
}
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('device.ping.error'), 'error'); showToast(error.message || t('device.ping.error'), 'error');
} finally { } finally {
if (btn) btn.classList.remove('spinning'); if (btn) btn.classList.remove('spinning');
} }
@@ -414,30 +405,20 @@ export async function removeDevice(deviceId: any) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetchWithAuth(`/devices/${deviceId}`, { await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') });
method: 'DELETE',
});
if (response.ok) {
showToast(t('device.removed'), 'success'); showToast(t('device.removed'), 'success');
devicesCache.invalidate(); devicesCache.invalidate();
window.loadDevices(); window.loadDevices();
} else {
const error = await response.json();
showToast(error.detail || t('device.error.remove_failed'), 'error');
}
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to remove device:', error); 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) { export async function showSettings(deviceId: any) {
try { try {
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`); const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') });
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
const device = await deviceResponse.json();
const isAdalight = isSerialDevice(device.device_type); const isAdalight = isSerialDevice(device.device_type);
const caps = device.capabilities || []; const caps = device.capabilities || [];
@@ -934,18 +915,7 @@ export async function saveDeviceSettings() {
} }
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || ''; const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
body.default_css_processing_template_id = csptId; body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, { await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') });
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;
}
showToast(t('settings.saved'), 'success'); showToast(t('settings.saved'), 'success');
devicesCache.invalidate(); devicesCache.invalidate();
@@ -978,16 +948,9 @@ export async function saveCardBrightness(deviceId: any, value: any) {
const bri = parseInt(value); const bri = parseInt(value);
updateDeviceBrightness(deviceId, bri); updateDeviceBrightness(deviceId, bri);
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, { await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, {
method: 'PUT', errorMessage: t('device.error.brightness'),
body: JSON.stringify({ brightness: bri })
}); });
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) { } catch (err: any) {
if (err.isAuth) return; if (err.isAuth) return;
showToast(err.message || t('device.error.brightness'), 'error'); showToast(err.message || t('device.error.brightness'), 'error');
@@ -999,9 +962,7 @@ export async function fetchDeviceBrightness(deviceId: any) {
if (_brightnessFetchInFlight.has(deviceId)) return; if (_brightnessFetchInFlight.has(deviceId)) return;
_brightnessFetchInFlight.add(deviceId); _brightnessFetchInFlight.add(deviceId);
try { try {
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`); const data = await apiGet<any>(`/devices/${deviceId}/brightness`);
if (!resp.ok) return;
const data = await resp.json();
updateDeviceBrightness(deviceId, data.brightness); updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null; const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) { if (slider) {
@@ -1078,9 +1039,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) {
try { try {
const discoverType = settingsModal.deviceType || 'adalight'; const discoverType = settingsModal.deviceType || 'adalight';
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`); const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
if (!resp.ok) return;
const data = await resp.json();
const devices = data.devices || []; const devices = data.devices || [];
select.innerHTML = ''; select.innerHTML = '';
@@ -1154,11 +1113,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
_zoneCountInFlight.add(baseUrl); _zoneCountInFlight.add(baseUrl);
try { try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`); const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) return;
const data = await resp.json();
const counts: any = {}; const counts: any = {};
for (const z of data.zones) { for (const z of (data.zones || [])) {
counts[z.name.toLowerCase()] = z.led_count; counts[z.name.toLowerCase()] = z.led_count;
} }
_zoneCountCache[baseUrl] = counts; _zoneCountCache[baseUrl] = counts;
@@ -9,7 +9,7 @@ import {
availableEngines, availableEngines,
} from '../core/state.ts'; } from '../core/state.ts';
import { t } from '../core/i18n.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 { showToast } from '../core/ui.ts';
import type { Display } from '../types.ts'; import type { Display } from '../types.ts';
@@ -87,9 +87,7 @@ async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void>
canvas.innerHTML = '<div class="loading-spinner"></div>'; canvas.innerHTML = '<div class="loading-spinner"></div>';
try { try {
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`); const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`);
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
const displays = data.displays || []; const displays = data.displays || [];
// Store in cache so selectDisplay() can look them up // Store in cache so selectDisplay() can look them up
@@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () {
input.disabled = true; input.disabled = true;
try { try {
const resp = await fetchWithAuth('/adb/connect', { // No errorMessage option: the catch already prefixes the toast with
method: 'POST', // the localised `displays.picker.adb_connect.error` label, and the
body: JSON.stringify({ address }), // server's `detail` (or `HTTP <status>` fallback) becomes the suffix.
}); await apiPost('/adb/connect', { address });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Connection failed');
}
showToast(t('displays.picker.adb_connect.success'), 'success'); showToast(t('displays.picker.adb_connect.success'), 'success');
// Refresh the picker with updated device list // Refresh the picker with updated device list
@@ -6,7 +6,8 @@ import {
gameIntegrationsCache, gameAdaptersCache, gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters, _cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts'; } 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -48,10 +49,8 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ── // ── Bulk actions ──
function _bulkDeleteGameIntegrations(ids: string[]) { function _bulkDeleteGameIntegrations(ids: string[]) {
return Promise.allSettled(ids.map(id => return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => {
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' }) const failed = results.filter(r => r.status === 'rejected').length;
)).then(results => {
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('game_integration.deleted'), 'success'); else showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate(); gameIntegrationsCache.invalidate();
@@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() {
} }
try { try {
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' }); const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>(
if (!res || !res.ok) { `/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') },
const err = await res!.json(); );
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
return;
}
const data = await res.json();
if (data.success) { if (data.success) {
let msg = t('game_integration.auto_setup.success'); let msg = t('game_integration.auto_setup.success');
if (data.file_path) msg += `\n${data.file_path}`; if (data.file_path) msg += `\n${data.file_path}`;
@@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = [];
async function _loadPresets(): Promise<EffectPreset[]> { async function _loadPresets(): Promise<EffectPreset[]> {
if (_cachedPresets.length > 0) return _cachedPresets; if (_cachedPresets.length > 0) return _cachedPresets;
try { try {
const res = await fetchWithAuth('/game-integrations/presets'); const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets');
if (res && res.ok) {
const data = await res.json();
_cachedPresets = data.presets || []; _cachedPresets = data.presets || [];
}
} catch { /* ignore */ } } catch { /* ignore */ }
return _cachedPresets; return _cachedPresets;
} }
@@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) {
const poll = async () => { const poll = async () => {
try { try {
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`); const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`);
if (!res || !res.ok) return; const events = data.events || [];
const data = await res.json();
const events: GameEventRecord[] = data.events || [];
if (events.length === 0) return; if (events.length === 0) return;
feed.innerHTML = events.slice(0, 20).map(ev => { feed.innerHTML = events.slice(0, 20).map(ev => {
const ts = new Date(ev.timestamp).toLocaleTimeString(); const ts = new Date(ev.timestamp).toLocaleTimeString();
@@ -535,9 +525,7 @@ export function testGameConnection() {
_connectionTestTimer = setInterval(async () => { _connectionTestTimer = setInterval(async () => {
attempts++; attempts++;
try { try {
const res = await fetchWithAuth(`/game-integrations/${id}/status`); const status = await apiGet<GameIntegrationStatus>(`/game-integrations/${id}/status`);
if (!res || !res.ok) return;
const status: GameIntegrationStatus = await res.json();
if (status.event_count > 0) { if (status.event_count > 0) {
clearInterval(_connectionTestTimer!); clearInterval(_connectionTestTimer!);
_connectionTestTimer = null; _connectionTestTimer = null;
@@ -725,12 +713,10 @@ export async function saveGameIntegration() {
}; };
try { try {
const url = id ? `/game-integrations/${id}` : '/game-integrations'; if (id) {
const method = id ? 'PUT' : 'POST'; await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') });
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); } else {
if (!res || !res.ok) { await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') });
const err = await res!.json();
throw new Error(err.detail || t('game_integration.error.save_failed'));
} }
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success'); showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
gameIntegrationsCache.invalidate(); gameIntegrationsCache.invalidate();
@@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) {
const ok = await showConfirm(t('game_integration.confirm_delete')); const ok = await showConfirm(t('game_integration.confirm_delete'));
if (!ok) return; if (!ok) return;
try { 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'); showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate(); gameIntegrationsCache.invalidate();
loadGameIntegrations(); loadGameIntegrations();
@@ -7,7 +7,8 @@ import {
colorStripSourcesCache, outputTargetsCache, valueSourcesCache, colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames, getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts'; } 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 { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
@@ -149,9 +150,7 @@ function _getEntityItems() {
async function _fetchHAEntities(haSourceId: string): Promise<void> { async function _fetchHAEntities(haSourceId: string): Promise<void> {
if (!haSourceId) { _cachedHAEntities = []; return; } if (!haSourceId) { _cachedHAEntities = []; return; }
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _cachedHAEntities = []; return; }
const data = await resp.json();
_cachedHAEntities = data.entities || []; _cachedHAEntities = data.entities || [];
// Mirror into the shared cache so card chips/swatches across the // Mirror into the shared cache so card chips/swatches across the
// app pick up friendly names on the next render. // app pick up friendly names on the next render.
@@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
if (isEdit) { if (isEdit) {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`); editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise<void> {
payload.target_type = 'ha_light'; payload.target_type = 'ha_light';
try { try {
let response;
if (targetId) { if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, { await apiPut(`/output-targets/${targetId}`, payload);
method: 'PUT',
body: JSON.stringify(payload),
});
} else { } else {
response = await fetchWithAuth('/output-targets', { await apiPost('/output-targets', payload);
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
} }
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success'); showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
@@ -579,12 +564,9 @@ export async function editHALightTarget(targetId: string): Promise<void> {
export async function cloneHALightTarget(targetId: string): Promise<void> { export async function cloneHALightTarget(targetId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`); const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load target'); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` });
delete data.id;
data.name = data.name + ' (copy)';
await showHALightEditor(null, data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -834,8 +816,7 @@ const _haLightActions: Record<string, (id: string) => void> = {
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> { async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); await apiPost(`/output-targets/${targetId}/${action}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
outputTargetsCache.invalidate(); outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) { } catch (e: any) {
@@ -848,19 +829,13 @@ export async function turnOffHALightTarget(targetId: string): Promise<void> {
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?'); const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth( await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, {
`/output-targets/${targetId}/ha-light/turn-off`, errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights',
{ method: 'POST' }, });
);
if (resp.ok) {
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success'); 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');
}
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; 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');
} }
} }
@@ -6,7 +6,8 @@ import {
_cachedHASources, haSourcesCache, _cachedHASources, haSourcesCache,
_haEntityNamesCache, setHAEntityNames, _haEntityNamesCache, setHAEntityNames,
} from '../core/state.ts'; } 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal();
export async function fetchHAEntities(haSourceId: string): Promise<void> { export async function fetchHAEntities(haSourceId: string): Promise<void> {
if (!haSourceId) return; if (!haSourceId) return;
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`); const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) return;
const data = await resp.json();
setHAEntityNames(haSourceId, data.entities || []); setHAEntityNames(haSourceId, data.entities || []);
} catch { } 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<void> {
if (token) payload.token = token; if (token) payload.token = token;
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources'; await apiPut(`/home-assistant/sources/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/home-assistant/sources', payload);
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}`);
} }
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success'); showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
haSourceModal.forceClose(); haSourceModal.forceClose();
@@ -199,9 +192,7 @@ export async function saveHASource(): Promise<void> {
export async function editHASource(sourceId: string): Promise<void> { export async function editHASource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
if (!resp.ok) throw new Error(t('ha_source.error.load'));
const data = await resp.json();
await showHASourceModal(data); await showHASourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise<void> {
export async function cloneHASource(sourceId: string): Promise<void> { export async function cloneHASource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`); const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
if (!resp.ok) throw new Error(t('ha_source.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
delete data.id;
data.name = data.name + ' (copy)';
await showHASourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -227,11 +215,7 @@ export async function deleteHASource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('ha_source.delete.confirm')); const confirmed = await showConfirm(t('ha_source.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' }); await apiDelete(`/home-assistant/sources/${sourceId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('ha_source.deleted'), 'success'); showToast(t('ha_source.deleted'), 'success');
haSourcesCache.invalidate(); haSourcesCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -251,9 +235,7 @@ export async function testHASource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading'); if (testBtn) testBtn.classList.add('loading');
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' }); const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.success) { if (data.success) {
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
} else { } else {
@@ -267,6 +249,14 @@ export async function testHASource(): Promise<void> {
} }
} }
/** Shape returned by `POST /home-assistant/sources/{id}/test`. */
interface HATestResult {
success: boolean;
ha_version?: string;
entity_count?: number;
error?: string;
}
// ── Card rendering ── // ── Card rendering ──
export function createHASourceCard(source: HomeAssistantSource) { export function createHASourceCard(source: HomeAssistantSource) {
@@ -328,9 +318,7 @@ const _haSourceActions: Record<string, (id: string) => void> = {
async function _testHASourceFromCard(sourceId: string): Promise<void> { async function _testHASourceFromCard(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' }); const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.success) { if (data.success) {
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success'); showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
} else { } else {
@@ -13,7 +13,8 @@
import { import {
_cachedHTTPEndpoints, httpEndpointsCache, _cachedHTTPEndpoints, httpEndpointsCache,
} from '../core/state.ts'; } 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise<void> {
} }
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/http/endpoints/${id}` : '/http/endpoints'; await apiPut(`/http/endpoints/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/http/endpoints', payload);
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}`);
} }
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success'); showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
httpEndpointModal.forceClose(); httpEndpointModal.forceClose();
@@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise<void> {
export async function editHTTPEndpoint(endpointId: string): Promise<void> { export async function editHTTPEndpoint(endpointId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
const data: HTTPEndpoint = await resp.json();
await showHTTPEndpointModal(data); await showHTTPEndpointModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise<void> {
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> { export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
if (!resp.ok) throw new Error(t('http_endpoint.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showHTTPEndpointModal({
delete data.id; ...rest,
data.name = data.name + ' (copy)'; name: `${data.name} (copy)`,
// Cloning never reveals the token — user must re-enter if needed. // Cloning never reveals the token — user must re-enter if needed.
data.auth_token_set = false; auth_token_set: false,
await showHTTPEndpointModal(data); } as HTTPEndpoint);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -370,11 +363,7 @@ export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
const confirmed = await showConfirm(t('http_endpoint.delete.confirm')); const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' }); await apiDelete(`/http/endpoints/${endpointId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('http_endpoint.deleted'), 'success'); showToast(t('http_endpoint.deleted'), 'success');
httpEndpointsCache.invalidate(); httpEndpointsCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise<void> {
</div>`; </div>`;
try { try {
const resp = await fetchWithAuth('/http/endpoints/test', { const data = await apiPost<HTTPTestResponse>('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s });
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();
_renderTestResult(out, data); _renderTestResult(out, data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> { async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' }); const data = await apiPost<HTTPTestResponse>(`/http/endpoints/${endpointId}/test`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data: HTTPTestResponse = await resp.json();
if (data.success) { if (data.success) {
const status = data.status_code != null ? ` (${data.status_code})` : ''; const status = data.status_code != null ? ` (${data.status_code})` : '';
showToast(`${t('http_endpoint.test.success')}${status}`, 'success'); showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
@@ -14,7 +14,8 @@
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { t } from '../core/i18n.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 { showToast } from '../core/ui.ts';
import { devicesCache, outputTargetsCache } from '../core/state.ts'; import { devicesCache, outputTargetsCache } from '../core/state.ts';
import { import {
@@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
if (adapter.bodyExtras) { if (adapter.bodyExtras) {
Object.assign(body, adapter.bodyExtras(entityId)); Object.assign(body, adapter.bodyExtras(entityId));
} }
const resp = await fetchWithAuth(adapter.endpoint(entityId), { await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') });
method: 'PUT',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error');
return;
}
if (nextIconId) _pushRecent(nextIconId); if (nextIconId) _pushRecent(nextIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success'); showToast(t('device.icon.saved') || 'Icon saved', 'success');
await adapter.reload(); await adapter.reload();
closeIconPicker(); closeIconPicker();
} catch (error: any) { } catch (error: any) {
if (error?.isAuth) return; 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');
} }
} }
@@ -15,7 +15,7 @@ import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash } from './tabs.ts'; import { updateSubTabHash } from './tabs.ts';
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.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 { showToast, setTabRefreshing } from '../core/ui.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createHASourceCard, initHASourceDelegation } from './home-assistant-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) { function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
return async (ids: string[]) => { return async (ids: string[]) => {
const results = await Promise.allSettled(ids.map(id => const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`)));
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' }) const failed = results.filter(r => r.status === 'rejected').length;
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t(toast), 'success'); else showToast(t(toast), 'success');
cache.invalidate(); cache.invalidate();
@@ -3,7 +3,8 @@
*/ */
import { mqttSourcesCache } from '../core/state.ts'; 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise<void> {
if (password) payload.password = password; if (password) payload.password = password;
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources'; await apiPut(`/mqtt/sources/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/mqtt/sources', payload);
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}`);
} }
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success'); showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
mqttSourceModal.forceClose(); mqttSourceModal.forceClose();
@@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise<void> {
export async function editMQTTSource(sourceId: string): Promise<void> { export async function editMQTTSource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
const data = await resp.json();
await showMQTTSourceModal(data); await showMQTTSourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise<void> {
export async function cloneMQTTSource(sourceId: string): Promise<void> { export async function cloneMQTTSource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
if (!resp.ok) throw new Error(t('mqtt_source.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource);
delete data.id;
data.name = data.name + ' (copy)';
await showMQTTSourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -196,11 +186,7 @@ export async function deleteMQTTSource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('mqtt_source.delete.confirm')); const confirmed = await showConfirm(t('mqtt_source.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' }); await apiDelete(`/mqtt/sources/${sourceId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('mqtt_source.deleted'), 'success'); showToast(t('mqtt_source.deleted'), 'success');
mqttSourcesCache.invalidate(); mqttSourcesCache.invalidate();
} catch (e: any) { } catch (e: any) {
@@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading'); if (testBtn) testBtn.classList.add('loading');
try { try {
const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' }); const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${id}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.success) { if (data.success) {
showToast(t('mqtt_source.test.success'), 'success'); showToast(t('mqtt_source.test.success'), 'success');
} else { } else {
@@ -235,11 +219,15 @@ export async function testMQTTSource(): Promise<void> {
} }
} }
/** Shape returned by `POST /mqtt/sources/{id}/test`. */
interface MQTTTestResult {
success: boolean;
error?: string;
}
async function _testMQTTSourceFromCard(sourceId: string): Promise<void> { async function _testMQTTSourceFromCard(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' }); const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${sourceId}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.success) { if (data.success) {
showToast(t('mqtt_source.test.success'), 'success'); showToast(t('mqtt_source.test.success'), 'success');
} else { } else {
@@ -21,7 +21,7 @@
* settings. * settings.
*/ */
import { fetchWithAuth } from '../core/api.ts'; import { apiGet, apiPut } from '../core/api-client.ts';
import { showToast } from '../core/ui.ts'; import { showToast } from '../core/ui.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { logError } from '../core/log.ts'; import { logError } from '../core/log.ts';
@@ -102,9 +102,7 @@ export async function startNotificationsWatcher(): Promise<void> {
/** Pull the latest prefs from the server and cache them. */ /** Pull the latest prefs from the server and cache them. */
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> { export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
try { try {
const resp = await fetchWithAuth('/preferences/notifications'); const data = await apiGet<any>('/preferences/notifications');
if (!resp.ok) return _prefs;
const data = await resp.json();
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } }; _prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
} catch (err) { } catch (err) {
logError('notifications.fetch', err); logError('notifications.fetch', err);
@@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise<NotificationPref
export async function saveNotificationPreferences( export async function saveNotificationPreferences(
next: NotificationPreferences, next: NotificationPreferences,
): Promise<NotificationPreferences> { ): Promise<NotificationPreferences> {
const resp = await fetchWithAuth('/preferences/notifications', { const saved = await apiPut<any>('/preferences/notifications', next);
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();
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } }; _prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
return _prefs; return _prefs;
} }
@@ -15,7 +15,8 @@ import {
PATTERN_RECT_BORDERS, PATTERN_RECT_BORDERS,
streamsCache, streamsCache,
} from '../core/state.ts'; } 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 { patternTemplatesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
@@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise<void> {
}; };
try { try {
let response;
if (templateId) { if (templateId) {
response = await fetchWithAuth(`/pattern-templates/${templateId}`, { await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') });
method: 'PUT', body: JSON.stringify(payload),
});
} else { } else {
response = await fetchWithAuth('/pattern-templates', { await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') });
method: 'POST', body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
} }
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success'); showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
@@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise<void> {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, { await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') });
method: 'DELETE',
});
if (response.ok) {
showToast(t('pattern.deleted'), 'success'); showToast(t('pattern.deleted'), 'success');
patternTemplatesCache.invalidate(); patternTemplatesCache.invalidate();
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {
const error = await response.json();
showToast(error.detail || t('pattern.error.delete_failed'), 'error');
}
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('pattern.error.delete_failed'), 'error'); showToast(error.message || t('pattern.error.delete_failed'), 'error');
} }
} }
@@ -8,7 +8,8 @@
* cheap for 120-sample lines. * 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 { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts'; import { dashboardPollInterval } from '../core/state.ts';
import { isActiveTab } from '../core/tab-registry.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<void> { async function _fetchPerformance(): Promise<void> {
try { try {
const resp = await fetchWithAuth('/system/performance'); const data = await apiGet<any>('/system/performance');
if (!resp.ok) return;
const data = await resp.json();
_lastFetchData = data; _lastFetchData = data;
_applyPerfDataToDom(data, /*pushHistory=*/true); _applyPerfDataToDom(data, /*pushHistory=*/true);
} catch (err) { } catch (err) {
@@ -3,7 +3,8 @@
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards. * 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 { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
@@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', {
bulkActions: [{ bulkActions: [{
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
handler: async (ids) => { handler: async (ids) => {
const results = await Promise.allSettled(ids.map(id => const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`)));
fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' }) const failed = results.filter(r => r.status === 'rejected').length;
));
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('scenes.deleted'), 'success'); else showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate(); scenePresetsCache.invalidate();
@@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise<void> {
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : []; const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
try { 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) { if (_editingId) {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') });
.map(el => (el as HTMLElement).dataset.targetId);
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
method: 'PUT',
body: JSON.stringify({ name, description, target_ids, tags }),
});
} else { } else {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') });
.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;
} }
scenePresetModal.forceClose(); scenePresetModal.forceClose();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success'); showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
scenePresetsCache.invalidate(); scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
errorEl.textContent = t('scenes.error.save_failed'); errorEl.textContent = error.message || t('scenes.error.save_failed');
errorEl.style.display = 'block'; errorEl.style.display = 'block';
} }
} }
@@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise<void> {
export async function activateScenePreset(presetId: string): Promise<void> { export async function activateScenePreset(presetId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, { const result = await apiPost<{ status: string; errors: any[] }>(
method: 'POST', `/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') },
}); );
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();
if (result.status === 'activated') { if (result.status === 'activated') {
showToast(t('scenes.activated'), 'success'); showToast(t('scenes.activated'), 'success');
} else { } else {
@@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise<void> {
if (typeof window.loadDashboard === 'function') window.loadDashboard(true); if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
} catch (error: any) { } catch (error: any) {
if (error.isAuth) return; 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<void> {
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, { await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') });
method: 'POST',
});
if (resp.ok) {
showToast(t('scenes.recaptured'), 'success'); showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate(); scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } catch (error: any) {
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) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(error.message || t('scenes.error.recapture_failed'), 'error'); showToast(error.message || t('scenes.error.recapture_failed'), 'error');
} }
@@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, { await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') });
method: 'DELETE',
});
if (resp.ok) {
showToast(t('scenes.deleted'), 'success'); showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate(); scenePresetsCache.invalidate();
_reloadScenesTab(); _reloadScenesTab();
} else { } catch (error: any) {
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) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(error.message || t('scenes.error.delete_failed'), 'error'); showToast(error.message || t('scenes.error.delete_failed'), 'error');
} }
@@ -11,7 +11,8 @@ import {
audioTemplatesCache, audioTemplatesCache,
apiKey, apiKey,
} from '../core/state.ts'; } 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 { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
@@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal();
async function loadAvailableAudioEngines() { async function loadAvailableAudioEngines() {
try { try {
const response = await fetchWithAuth('/audio-engines'); const data = await apiGet<{ engines?: any[] }>('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
setAvailableAudioEngines(data.engines || []); setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement; 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) { export async function editAudioTemplate(templateId: any) {
try { try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`); const template = await apiGet<any>(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
setCurrentEditingAudioTemplateId(templateId); setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`; 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() : [] }; const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try { try {
let response;
if (templateId) { 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 { } else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) }); await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
} }
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success'); showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
@@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' }); await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
showToast(t('audio_template.deleted'), 'success'); showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate(); audioTemplatesCache.invalidate();
await loadAudioTemplates(); await loadAudioTemplates();
@@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) {
export async function cloneAudioTemplate(templateId: any) { export async function cloneAudioTemplate(templateId: any) {
try { try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`); const tmpl = await apiGet<any>(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
showAddAudioTemplateModal(tmpl); showAddAudioTemplateModal(tmpl);
} catch (error) { } catch (error: any) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to clone audio template:', error); console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error'); showToast(t('audio_template.error.clone_failed'), 'error');
@@ -364,9 +349,7 @@ export async function showTestAudioTemplateModal(templateId: any) {
// Load audio devices for picker — filter by engine type // Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement; const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try { try {
const resp = await fetchWithAuth('/audio-devices'); const data = await apiGet<{ by_engine?: Record<string, any[]>; devices?: any[] }>('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list // Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType]) const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType] ? data.by_engine[engineType]
@@ -379,7 +362,6 @@ export async function showTestAudioTemplateModal(templateId: any) {
if (devices.length === 0) { if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>'; deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
} }
}
} catch { } catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>'; deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
} }
@@ -12,7 +12,8 @@ import {
captureTemplatesCache, displaysCache, enginesCache, captureTemplatesCache, displaysCache, enginesCache,
apiKey, apiKey,
} from '../core/state.ts'; } 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.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) { export async function editTemplate(templateId: any) {
try { try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`); const template = await apiGet<any>(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId); setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`; 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 // Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) { if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url); const displaysData = await apiGet<{ displays?: any[] }>(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
displaysCache.update(displaysData.displays || []); 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() : [] }; const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try { try {
let response;
if (templateId) { 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 { } else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) }); await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
} }
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
@@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) {
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' }); await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success'); showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate(); captureTemplatesCache.invalidate();
await loadCaptureTemplates(); await loadCaptureTemplates();
@@ -3,7 +3,8 @@
*/ */
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts'; 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -124,16 +125,10 @@ export async function saveSyncClock(): Promise<void> {
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] }; const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/sync-clocks/${id}` : '/sync-clocks'; await apiPut(`/sync-clocks/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/sync-clocks', payload);
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}`);
} }
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success'); showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose(); syncClockModal.forceClose();
@@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise<void> {
export async function editSyncClock(clockId: string): Promise<void> { export async function editSyncClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`); const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
await showSyncClockModal(data); await showSyncClockModal(data);
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise<void> {
export async function cloneSyncClock(clockId: string): Promise<void> { export async function cloneSyncClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`); const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
if (!resp.ok) throw new Error(t('sync_clock.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock);
delete data.id;
data.name = data.name + ' (copy)';
await showSyncClockModal(data);
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -177,11 +167,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
const confirmed = await showConfirm(t('sync_clock.delete.confirm')); const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' }); await apiDelete(`/sync-clocks/${clockId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('sync_clock.deleted'), 'success'); showToast(t('sync_clock.deleted'), 'success');
syncClocksCache.invalidate(); syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
export async function pauseSyncClock(clockId: string): Promise<void> { export async function pauseSyncClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' }); await apiPost(`/sync-clocks/${clockId}/pause`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success'); showToast(t('sync_clock.paused'), 'success');
syncClocksCache.invalidate(); syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise<void> {
export async function resumeSyncClock(clockId: string): Promise<void> { export async function resumeSyncClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' }); await apiPost(`/sync-clocks/${clockId}/resume`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success'); showToast(t('sync_clock.resumed'), 'success');
syncClocksCache.invalidate(); syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise<void> {
export async function resetSyncClock(clockId: string): Promise<void> { export async function resetSyncClock(clockId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' }); await apiPost(`/sync-clocks/${clockId}/reset`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success'); showToast(t('sync_clock.reset_done'), 'success');
syncClocksCache.invalidate(); syncClocksCache.invalidate();
await loadPictureSources(); await loadPictureSources();
@@ -2,7 +2,7 @@
* Auto-update check for new releases, show banner, manage settings. * 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 { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
@@ -129,10 +129,7 @@ export function dismissUpdate(): void {
_hideBanner(); _hideBanner();
_setVersionBadgeUpdate(false); _setVersionBadgeUpdate(false);
fetchWithAuth('/system/update/dismiss', { apiPost('/system/update/dismiss', { version }).catch(() => {});
method: 'POST',
body: JSON.stringify({ version }),
}).catch(() => {});
} }
// ─── Apply update ─────────────────────────────────────────── // ─── Apply update ───────────────────────────────────────────
@@ -151,14 +148,7 @@ export async function applyUpdate(): Promise<void> {
btns.forEach(b => (b as HTMLButtonElement).disabled = true); btns.forEach(b => (b as HTMLButtonElement).disabled = true);
try { try {
const resp = await fetchWithAuth('/system/update/apply', { await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + 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}`);
}
// Server will shut down — the frontend reconnect overlay handles the rest // Server will shut down — the frontend reconnect overlay handles the rest
showToast(t('update.applying'), 'info'); showToast(t('update.applying'), 'info');
} catch (err) { } catch (err) {
@@ -171,9 +161,7 @@ export async function applyUpdate(): Promise<void> {
export async function loadUpdateStatus(): Promise<void> { export async function loadUpdateStatus(): Promise<void> {
try { try {
const resp = await fetchWithAuth('/system/update/status'); const status = await apiGet<UpdateStatus>('/system/update/status');
if (!resp.ok) return;
const status: UpdateStatus = await resp.json();
_lastStatus = status; _lastStatus = status;
_applyStatus(status); _applyStatus(status);
} catch { } catch {
@@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise<void> {
if (spinner) spinner.style.display = ''; if (spinner) spinner.style.display = '';
try { try {
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' }); const status = await apiPost<UpdateStatus>('/system/update/check');
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const status: UpdateStatus = await resp.json();
_lastStatus = status; _lastStatus = status;
_applyStatus(status); _applyStatus(status);
@@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void {
export async function loadUpdateSettings(): Promise<void> { export async function loadUpdateSettings(): Promise<void> {
try { try {
const resp = await fetchWithAuth('/system/update/settings'); const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings');
if (!resp.ok) return;
const data = await resp.json();
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null; const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null; const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
@@ -388,14 +369,7 @@ export async function saveUpdateSettings(): Promise<void> {
if (Number.isNaN(check_interval_hours)) return; if (Number.isNaN(check_interval_hours)) return;
try { try {
const resp = await fetchWithAuth('/system/update/settings', { await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease });
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}`);
}
} catch (err) { } catch (err) {
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error'); showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
} }
@@ -3,7 +3,8 @@
*/ */
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts'; 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 { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts'; import { showToast, showConfirm } from '../core/ui.ts';
@@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise<void> {
}; };
try { try {
const method = id ? 'PUT' : 'POST'; if (id) {
const url = id ? `/weather-sources/${id}` : '/weather-sources'; await apiPut(`/weather-sources/${id}`, payload);
const resp = await fetchWithAuth(url, { } else {
method, await apiPost('/weather-sources', payload);
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}`);
} }
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success'); showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
weatherSourceModal.forceClose(); weatherSourceModal.forceClose();
@@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise<void> {
export async function editWeatherSource(sourceId: string): Promise<void> { export async function editWeatherSource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
if (!resp.ok) throw new Error(t('weather_source.error.load'));
const data = await resp.json();
await showWeatherSourceModal(data); await showWeatherSourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise<void> {
export async function cloneWeatherSource(sourceId: string): Promise<void> { export async function cloneWeatherSource(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`); const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
if (!resp.ok) throw new Error(t('weather_source.error.load')); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource);
delete data.id;
data.name = data.name + ' (copy)';
await showWeatherSourceModal(data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -219,11 +209,7 @@ export async function deleteWeatherSource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('weather_source.delete.confirm')); const confirmed = await showConfirm(t('weather_source.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' }); await apiDelete(`/weather-sources/${sourceId}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('weather_source.deleted'), 'success'); showToast(t('weather_source.deleted'), 'success');
weatherSourcesCache.invalidate(); weatherSourcesCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading'); if (testBtn) testBtn.classList.add('loading');
try { try {
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' }); const data = await apiPost<WeatherTestResult>(`/weather-sources/${id}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -255,6 +239,13 @@ export async function testWeatherSource(): Promise<void> {
} }
} }
/** Shape returned by `POST /weather-sources/{id}/test`. */
interface WeatherTestResult {
condition: string;
temperature: number;
wind_speed: number;
}
// ── Geolocation ── // ── Geolocation ──
export function weatherSourceGeolocate(): void { export function weatherSourceGeolocate(): void {
@@ -336,9 +327,7 @@ const _weatherSourceActions: Record<string, (id: string) => void> = {
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> { async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' }); const data = await apiPost<WeatherTestResult>(`/weather-sources/${sourceId}/test`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success'); showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
@@ -16,7 +16,8 @@ import {
colorStripSourcesCache, mqttSourcesCache, colorStripSourcesCache, mqttSourcesCache,
outputTargetsCache, valueSourcesCache, outputTargetsCache, valueSourcesCache,
} from '../core/state.ts'; } 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 { logError } from '../core/log.ts';
import { safeJsonParse } from '../core/storage.ts'; import { safeJsonParse } from '../core/storage.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
@@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa
let editData: any = null; let editData: any = null;
if (isEdit) { if (isEdit) {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`); editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise<void> {
}; };
try { try {
const response = targetId if (targetId) {
? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) }) await apiPut(`/output-targets/${targetId}`, payload);
: await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) }); } else {
await apiPost('/output-targets', payload);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
} }
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success'); showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
outputTargetsCache.invalidate(); outputTargetsCache.invalidate();
@@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise<void> {
export async function cloneZ2MLightTarget(targetId: string): Promise<void> { export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`); const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
if (!resp.ok) throw new Error('Failed to load target'); const { id: _omit, ...rest } = data;
const data = await resp.json(); await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` });
delete data.id;
data.name = data.name + ' (copy)';
await showZ2MLightEditor(null, data);
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -659,8 +652,7 @@ const _z2mLightActions: Record<string, (id: string) => void> = {
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> { async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' }); await apiPost(`/output-targets/${targetId}/${action}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
outputTargetsCache.invalidate(); outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab(); if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) { } catch (e: any) {
@@ -673,16 +665,13 @@ export async function turnOffZ2MLightTarget(targetId: string): Promise<void> {
const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?'); const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?');
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' }); await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, {
if (resp.ok) { errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs',
});
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success'); 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');
}
} catch (e: any) { } catch (e: any) {
if (e.isAuth) return; 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');
} }
} }
+13
View File
@@ -125,6 +125,8 @@
"templates.error.engines": "Failed to load engines", "templates.error.engines": "Failed to load engines",
"templates.error.required": "Please fill in all required fields", "templates.error.required": "Please fill in all required fields",
"templates.error.delete": "Failed to delete template", "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.title": "Test Capture",
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.", "templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:", "templates.test.display": "Display:",
@@ -1283,6 +1285,10 @@
"automations.deleted": "Automation deleted", "automations.deleted": "Automation deleted",
"automations.error.name_required": "Name is required", "automations.error.name_required": "Name is required",
"automations.error.clone_failed": "Failed to clone automation", "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.title": "Scenes",
"scenes.add": "Capture Scene", "scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene", "scenes.edit": "Edit Scene",
@@ -1824,6 +1830,8 @@
"audio_template.error.engines": "Failed to load audio engines", "audio_template.error.engines": "Failed to load audio engines",
"audio_template.error.required": "Please fill in all required fields", "audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template", "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.value": "Value Sources",
"streams.group.sync": "Sync Clocks", "streams.group.sync": "Sync Clocks",
"streams.group.gradients": "Gradients", "streams.group.gradients": "Gradients",
@@ -1843,6 +1851,7 @@
"gradient.error.name_required": "Name is required", "gradient.error.name_required": "Name is required",
"gradient.error.min_stops": "At least 2 color stops are required", "gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.delete_failed": "Failed to delete gradient", "gradient.error.delete_failed": "Failed to delete gradient",
"gradient.error.save_failed": "Failed to save gradient",
"gradient.create_name": "New gradient name:", "gradient.create_name": "New gradient name:",
"gradient.edit_name": "Rename gradient:", "gradient.edit_name": "Rename gradient:",
"gradient.confirm_delete": "Delete gradient \"{name}\"?", "gradient.confirm_delete": "Delete gradient \"{name}\"?",
@@ -2208,6 +2217,7 @@
"device.error.update": "Failed to update device", "device.error.update": "Failed to update device",
"device.error.save": "Failed to save settings", "device.error.save": "Failed to save settings",
"device.error.clone_failed": "Failed to clone device", "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.error.fill_all_fields": "Please fill in all fields",
"device_discovery.added": "Device added successfully", "device_discovery.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device", "device_discovery.error.add_failed": "Failed to add device",
@@ -2249,6 +2259,7 @@
"target.error.stop_failed": "Failed to stop target", "target.error.stop_failed": "Failed to stop target",
"target.error.clone_failed": "Failed to clone target", "target.error.clone_failed": "Failed to clone target",
"target.error.delete_failed": "Failed to delete 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.button": "Stop All",
"targets.stop_all.none_running": "No targets are currently running", "targets.stop_all.none_running": "No targets are currently running",
"targets.stop_all.stopped": "Stopped {count} target(s)", "targets.stop_all.stopped": "Stopped {count} target(s)",
@@ -2263,6 +2274,7 @@
"pattern.error.clone_failed": "Failed to clone pattern template", "pattern.error.clone_failed": "Failed to clone pattern template",
"pattern.error.delete_failed": "Failed to delete pattern template", "pattern.error.delete_failed": "Failed to delete pattern template",
"pattern.error.capture_bg_failed": "Failed to capture background", "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_picture_failed": "Failed to clone picture source",
"stream.error.clone_capture_failed": "Failed to clone capture template", "stream.error.clone_capture_failed": "Failed to clone capture template",
"stream.error.clone_pp_failed": "Failed to clone postprocessing 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.load": "Error loading audio processing template",
"audio_processing.error.delete": "Error deleting 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.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.filter_count": "Filter count",
"audio_processing.filters_label": "filters", "audio_processing.filters_label": "filters",
"streams.group.audio_processing": "Audio Processing", "streams.group.audio_processing": "Audio Processing",
+14
View File
@@ -180,6 +180,8 @@
"templates.error.engines": "Не удалось загрузить движки", "templates.error.engines": "Не удалось загрузить движки",
"templates.error.required": "Пожалуйста, заполните все обязательные поля", "templates.error.required": "Пожалуйста, заполните все обязательные поля",
"templates.error.delete": "Не удалось удалить шаблон", "templates.error.delete": "Не удалось удалить шаблон",
"templates.error.save_failed": "Не удалось сохранить шаблон",
"templates.error.load_failed": "Не удалось загрузить шаблон",
"templates.test.title": "Тест Захвата", "templates.test.title": "Тест Захвата",
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.", "templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:", "templates.test.display": "Дисплей:",
@@ -1317,6 +1319,10 @@
"automations.deleted": "Автоматизация удалена", "automations.deleted": "Автоматизация удалена",
"automations.error.name_required": "Введите название", "automations.error.name_required": "Введите название",
"automations.error.clone_failed": "Не удалось клонировать автоматизацию", "automations.error.clone_failed": "Не удалось клонировать автоматизацию",
"automations.error.load_failed": "Не удалось загрузить автоматизацию",
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.delete_failed": "Не удалось удалить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"scenes.title": "Сцены", "scenes.title": "Сцены",
"scenes.add": "Захватить сцену", "scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену", "scenes.edit": "Редактировать сцену",
@@ -1789,6 +1795,10 @@
"audio_template.error.engines": "Не удалось загрузить аудиодвижки", "audio_template.error.engines": "Не удалось загрузить аудиодвижки",
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля", "audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон", "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.value": "Источники значений",
"streams.group.sync": "Часы синхронизации", "streams.group.sync": "Часы синхронизации",
"tree.group.picture": "Источники изображений", "tree.group.picture": "Источники изображений",
@@ -2067,6 +2077,7 @@
"device.error.update": "Не удалось обновить устройство", "device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки", "device.error.save": "Не удалось сохранить настройки",
"device.error.clone_failed": "Не удалось клонировать устройство", "device.error.clone_failed": "Не удалось клонировать устройство",
"device.error.load_failed": "Не удалось загрузить устройство",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля", "device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено", "device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство", "device_discovery.error.add_failed": "Не удалось добавить устройство",
@@ -2108,6 +2119,7 @@
"target.error.stop_failed": "Не удалось остановить цель", "target.error.stop_failed": "Не удалось остановить цель",
"target.error.clone_failed": "Не удалось клонировать цель", "target.error.clone_failed": "Не удалось клонировать цель",
"target.error.delete_failed": "Не удалось удалить цель", "target.error.delete_failed": "Не удалось удалить цель",
"target.error.load_failed": "Не удалось загрузить цель",
"targets.stop_all.button": "Остановить все", "targets.stop_all.button": "Остановить все",
"targets.stop_all.none_running": "Нет запущенных целей", "targets.stop_all.none_running": "Нет запущенных целей",
"targets.stop_all.stopped": "Остановлено целей: {count}", "targets.stop_all.stopped": "Остановлено целей: {count}",
@@ -2122,6 +2134,7 @@
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров", "pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров", "pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
"pattern.error.capture_bg_failed": "Не удалось захватить фон", "pattern.error.capture_bg_failed": "Не удалось захватить фон",
"pattern.error.save_failed": "Не удалось сохранить шаблон узоров",
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения", "stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата", "stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки", "stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
@@ -2634,6 +2647,7 @@
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука", "audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука", "audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука", "audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
"audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука",
"audio_processing.filter_count": "Количество фильтров", "audio_processing.filter_count": "Количество фильтров",
"audio_processing.filters_label": "фильтров", "audio_processing.filters_label": "фильтров",
"streams.group.audio_processing": "Обработка звука", "streams.group.audio_processing": "Обработка звука",
+14
View File
@@ -178,6 +178,8 @@
"templates.error.engines": "加载引擎失败", "templates.error.engines": "加载引擎失败",
"templates.error.required": "请填写所有必填项", "templates.error.required": "请填写所有必填项",
"templates.error.delete": "删除模板失败", "templates.error.delete": "删除模板失败",
"templates.error.save_failed": "保存模板失败",
"templates.error.load_failed": "加载模板失败",
"templates.test.title": "测试采集", "templates.test.title": "测试采集",
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。", "templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
"templates.test.display": "显示器:", "templates.test.display": "显示器:",
@@ -1313,6 +1315,10 @@
"automations.deleted": "自动化已删除", "automations.deleted": "自动化已删除",
"automations.error.name_required": "名称为必填项", "automations.error.name_required": "名称为必填项",
"automations.error.clone_failed": "克隆自动化失败", "automations.error.clone_failed": "克隆自动化失败",
"automations.error.load_failed": "加载自动化失败",
"automations.error.save_failed": "保存自动化失败",
"automations.error.delete_failed": "删除自动化失败",
"automations.error.toggle_failed": "切换自动化失败",
"scenes.title": "场景", "scenes.title": "场景",
"scenes.add": "捕获场景", "scenes.add": "捕获场景",
"scenes.edit": "编辑场景", "scenes.edit": "编辑场景",
@@ -1785,6 +1791,10 @@
"audio_template.error.engines": "加载音频引擎失败", "audio_template.error.engines": "加载音频引擎失败",
"audio_template.error.required": "请填写所有必填项", "audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败", "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.value": "值源",
"streams.group.sync": "同步时钟", "streams.group.sync": "同步时钟",
"tree.group.picture": "图片源", "tree.group.picture": "图片源",
@@ -2063,6 +2073,7 @@
"device.error.update": "更新设备失败", "device.error.update": "更新设备失败",
"device.error.save": "保存设置失败", "device.error.save": "保存设置失败",
"device.error.clone_failed": "克隆设备失败", "device.error.clone_failed": "克隆设备失败",
"device.error.load_failed": "加载设备失败",
"device_discovery.error.fill_all_fields": "请填写所有字段", "device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功", "device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败", "device_discovery.error.add_failed": "添加设备失败",
@@ -2104,6 +2115,7 @@
"target.error.stop_failed": "停止目标失败", "target.error.stop_failed": "停止目标失败",
"target.error.clone_failed": "克隆目标失败", "target.error.clone_failed": "克隆目标失败",
"target.error.delete_failed": "删除目标失败", "target.error.delete_failed": "删除目标失败",
"target.error.load_failed": "加载目标失败",
"targets.stop_all.button": "全部停止", "targets.stop_all.button": "全部停止",
"targets.stop_all.none_running": "当前没有运行中的目标", "targets.stop_all.none_running": "当前没有运行中的目标",
"targets.stop_all.stopped": "已停止 {count} 个目标", "targets.stop_all.stopped": "已停止 {count} 个目标",
@@ -2118,6 +2130,7 @@
"pattern.error.clone_failed": "克隆图案模板失败", "pattern.error.clone_failed": "克隆图案模板失败",
"pattern.error.delete_failed": "删除图案模板失败", "pattern.error.delete_failed": "删除图案模板失败",
"pattern.error.capture_bg_failed": "捕获背景失败", "pattern.error.capture_bg_failed": "捕获背景失败",
"pattern.error.save_failed": "保存图案模板失败",
"stream.error.clone_picture_failed": "克隆图片源失败", "stream.error.clone_picture_failed": "克隆图片源失败",
"stream.error.clone_capture_failed": "克隆捕获模板失败", "stream.error.clone_capture_failed": "克隆捕获模板失败",
"stream.error.clone_pp_failed": "克隆后处理模板失败", "stream.error.clone_pp_failed": "克隆后处理模板失败",
@@ -2628,6 +2641,7 @@
"audio_processing.error.load": "加载音频处理模板时出错", "audio_processing.error.load": "加载音频处理模板时出错",
"audio_processing.error.delete": "删除音频处理模板时出错", "audio_processing.error.delete": "删除音频处理模板时出错",
"audio_processing.error.clone_failed": "克隆音频处理模板失败", "audio_processing.error.clone_failed": "克隆音频处理模板失败",
"audio_processing.error.save_failed": "保存音频处理模板失败",
"audio_processing.filter_count": "过滤器数量", "audio_processing.filter_count": "过滤器数量",
"audio_processing.filters_label": "个过滤器", "audio_processing.filters_label": "个过滤器",
"streams.group.audio_processing": "音频处理", "streams.group.audio_processing": "音频处理",