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) |
| `3b8f00e` + `c1aa2eb` | C7 store-side |
| `2f15fbb` | H3 |
| _uncommitted (2026-05-27 autonomous pass)_ | H6-rest, H8, M7 (foundation + 3 reference files) |
All commits have ≥1 code-review subagent pass with HIGH findings fixed
before commit. Tests pass on each commit; ruff clean; tsc + bundle build
@@ -100,16 +101,35 @@ registry.
**Estimated scope:** 1-2 sessions; coupled to H4.
#### H8 — `automations.ts` 1410 LOC
#### H8 — `automations.ts` 1410 LOC — ✅ DONE (uncommitted, 2026-05-27)
Frontend mirror of H2 (rule polymorphism). Already addressed on the
backend in `98fb61d`; the frontend dispatch on `RuleType` is still
backend in `98fb61d`; the frontend dispatch on `RuleType` was
hand-rolled.
**Approach:** introduce a rule-type registry on the frontend matching
the backend's `_RULE_HANDLERS` shape.
**Done:** the two remaining hand-rolled dispatch ladders were converted
to registries keyed by `RuleType`, alongside the pre-existing
`RULE_CHIP_RENDERERS`:
- `RULE_FIELD_RENDERERS` — the `renderFields` if/elif ladder was
extracted into module-level `_renderXxxFields(container, data)`
functions (they only ever closed over `container`); the in-row
`renderFields` is now a 3-line dispatcher.
- `RULE_COLLECTORS` — the `getAutomationEditorRules` if/elif ladder
became per-type collectors; the loop is now a registry lookup.
- All three registries are typed `Record<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
@@ -161,16 +181,66 @@ extract the frame loop into a separate `PreviewFrameLoop` class.
**Estimated scope:** half a session. Low impact since the parallel-change
problem is already fixed.
#### M7 — No shared frontend API client
#### M7 — No shared frontend API client — 🟡 FOUNDATION DONE (uncommitted, 2026-05-27)
**File:** every `static/js/features/*.ts`
`fetchWithAuth(...)` + bespoke error-unwrapping is copy-pasted in every
feature's save / load function. ~25 files.
feature's save / load function. ~45 files, ~243 call sites.
**Approach:** introduce `static/js/core/api-client.ts` with typed
methods (`get`, `post`, `put`, `delete`) that handle auth, JSON parsing,
error normalisation. Replace `fetchWithAuth` calls across features.
**Done:** `static/js/core/api-client.ts` now provides typed
`apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` that wrap
`fetchWithAuth` (so auth, 401-relogin, retry, timeout, and the offline
toast are unchanged) and collapse the repeated
`if (!resp.ok) { detail || HTTP <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
@@ -262,7 +332,11 @@ always start before reading).
### Other frontend (severity in main list above)
- **H6 rest** — split remaining ~1100 LOC of `types.ts` into per-entity files
- **H6 rest** — ✅ DONE (uncommitted, 2026-05-27): `types.ts` (1140 LOC)
split into 18 per-entity files under `types/` (joining the existing
`bindable.ts`); `types.ts` is now a ~200-line pure re-export barrel, so
every `import { … } from '../types.ts'` still resolves. Reviewer
confirmed all 102 exported symbols preserved, none renamed.
- **H7** — `device-discovery.ts` 1745 LOC (couple with H4)
- **H8** — `automations.ts` 1410 LOC (mirror H2)
- **M7** — shared API client
@@ -299,6 +373,13 @@ Address H6-rest, C8, C9, C10, H7, H8, M7-M11, L1. See order above.
Critical to have typescript-reviewer feedback + manual UI testing after
each split.
> **Progress (2026-05-27, uncommitted):** steps 1 & 2 of the order above
> are done — H6-rest (`types.ts` split) and M7-foundation (`api-client.ts`
> + 3 reference migrations). H8 (automations registry) also landed. Still
> open: C8, C9, C10, H7, the remaining ~40 M7 file migrations, M8-M11, L1.
> Next per the order: introduce the API client everywhere (finish M7),
> then split `value-sources.ts` (C8).
### Session B — Device redesign (1-2 sessions)
Address H4 alone. Touches device storage + provider classes; needs a
@@ -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);
}
+8 -8
View File
@@ -2,7 +2,8 @@
* Reusable data cache with fetch deduplication, invalidation, and subscribers.
*/
import { fetchWithAuth, ApiError } from './api.ts';
import { ApiError } from './api.ts';
import { apiGet } from './api-client.ts';
// Server JSON is treated as `any` at the cache boundary because each
// extractor knows the endpoint-specific shape (e.g. `json.devices`).
@@ -66,19 +67,18 @@ export class DataCache<T = unknown> {
async _doFetch(): Promise<T> {
try {
const resp = await fetchWithAuth(this._endpoint);
if (!resp.ok) {
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
return this._data;
}
const json = await resp.json();
const json = await apiGet<any>(this._endpoint);
this._data = this._extractData(json);
this._fresh = true;
this._notify();
return this._data;
} catch (err: unknown) {
if (err instanceof ApiError && err.isAuth) return this._data;
console.error(`Cache fetch ${this._endpoint}:`, err);
if (err instanceof ApiError) {
console.error(`[DataCache] ${this._endpoint}: HTTP ${err.status}`);
} else {
console.error(`Cache fetch ${this._endpoint}:`, err);
}
return this._data;
}
}
@@ -2,7 +2,8 @@
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
*/
import { fetchWithAuth, escapeHtml } from './api.ts';
import { escapeHtml } from './api.ts';
import { apiGet, apiPost } from './api-client.ts';
import { t } from './i18n.ts';
import { navigateToCard } from './navigation.ts';
import {
@@ -73,18 +74,18 @@ function _buildItems(results: any[], states: any = {}) {
action: async () => {
const isRunning = actionItem._running;
const endpoint = isRunning ? 'stop' : 'start';
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/${endpoint}`, { method: 'POST' });
if (resp.ok) {
try {
await apiPost(`/output-targets/${tgt.id}/${endpoint}`, undefined, {
errorMessage: t(`target.error.${endpoint}_failed`),
});
showToast(t(isRunning ? 'device.stopped' : 'device.started'), 'success');
actionItem._running = !isRunning;
actionItem.detail = !isRunning ? t('search.action.stop') : t('search.action.start');
actionItem.icon = !isRunning ? '■' : '▶';
_render();
} else {
const err = await resp.json().catch(() => ({}));
const d = err.detail || err.message || '';
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
showToast(ds || t(`target.error.${endpoint}_failed`), 'error');
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t(`target.error.${endpoint}_failed`), 'error');
}
},
};
@@ -108,17 +109,17 @@ function _buildItems(results: any[], states: any = {}) {
action: async () => {
const isEnabled = autoItem._enabled;
const endpoint = isEnabled ? 'disable' : 'enable';
const resp = await fetchWithAuth(`/automations/${a.id}/${endpoint}`, { method: 'POST' });
if (resp.ok) {
try {
await apiPost(`/automations/${a.id}/${endpoint}`, undefined, {
errorMessage: t('search.action.' + endpoint) + ' failed',
});
showToast(t('search.action.' + endpoint) + ': ' + a.name, 'success');
autoItem._enabled = !isEnabled;
autoItem.detail = !isEnabled ? t('search.action.disable') : t('search.action.enable');
_render();
} else {
const err = await resp.json().catch(() => ({}));
const d = err.detail || err.message || '';
const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d);
showToast(ds || (t('search.action.' + endpoint) + ' failed'), 'error');
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || (t('search.action.' + endpoint) + ' failed'), 'error');
}
},
};
@@ -170,9 +171,15 @@ function _buildItems(results: any[], states: any = {}) {
items.push({
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
action: async () => {
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
if (resp.ok) { showToast(t('scenes.activated'), 'success'); }
else { const err = await resp.json().catch(() => ({})); const d = err.detail || err.message || ''; const ds = Array.isArray(d) ? d.map(x => x.msg || x).join('; ') : String(d); showToast(ds || t('scenes.error.activate_failed'), 'error'); }
try {
await apiPost(`/scene-presets/${sp.id}/activate`, undefined, {
errorMessage: t('scenes.error.activate_failed'),
});
showToast(t('scenes.activated'), 'success');
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('scenes.error.activate_failed'), 'error');
}
},
});
});
@@ -209,14 +216,12 @@ const _responseKeys = [
async function _fetchAllEntities() {
const [statesData, ...results] = await Promise.all([
fetchWithAuth('/output-targets/batch/states', { retry: false, timeout: 5000 })
.then(r => r.ok ? r.json() : {})
.then((data: any) => data.states || {})
apiGet<{ states?: any }>('/output-targets/batch/states', { retry: false, timeout: 5000 })
.then((data) => data.states || {})
.catch(() => ({})),
..._responseKeys.map(([ep, key]) =>
fetchWithAuth(ep as string, { retry: false, timeout: 5000 })
.then((r: any) => r.ok ? r.json() : {})
.then((data: any) => data[key as string] || [])
apiGet<any>(ep as string, { retry: false, timeout: 5000 })
.then((data) => data[key as string] || [])
.catch((): any[] => [])),
]);
return _buildItems(results, statesData);
@@ -3,7 +3,7 @@
* Supports creating, changing, and detaching connections via the graph editor.
*/
import { fetchWithAuth } from './api.ts';
import { apiPut } from './api-client.ts';
import {
streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj,
@@ -151,11 +151,7 @@ export async function updateConnection(targetId: string, targetKind: string, fie
const body = { [field]: newSourceId };
try {
const resp = await fetchWithAuth(url, {
method: 'PUT',
body: JSON.stringify(body),
});
if (!resp.ok) return false;
await apiPut(url, body);
// Invalidate the relevant cache so data refreshes
if (entry.cache) entry.cache.invalidate();
return true;
@@ -22,7 +22,8 @@
* attachProcessPicker(container, textarea);
*/
import { fetchWithAuth, escapeHtml } from './api.ts';
import { escapeHtml } from './api.ts';
import { apiGet } from './api-client.ts';
import { t } from './i18n.ts';
import { ICON_SEARCH } from './icons.ts';
@@ -241,16 +242,21 @@ class NamePalette {
/* ─── fetch helpers ────────────────────────────────────────── */
async function _fetchProcesses(): Promise<string[]> {
const resp = await fetchWithAuth('/system/processes');
if (!resp || !resp.ok) return [];
const data = await resp.json();
return data.processes || [];
try {
const data = await apiGet<{ processes?: string[] }>('/system/processes');
return data.processes || [];
} catch {
return [];
}
}
async function _fetchNotificationApps(): Promise<string[]> {
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
if (!resp || !resp.ok) return [];
const data = await resp.json();
let data: { history?: any[] };
try {
data = await apiGet<{ history?: any[] }>('/color-strip-sources/os-notifications/history');
} catch {
return [];
}
const history: any[] = data.history || [];
// Deduplicate app names, preserving original case of first occurrence
const seen = new Map<string, string>();
@@ -13,7 +13,7 @@
* Tags are stored lowercase, trimmed, deduplicated.
*/
import { fetchWithAuth } from './api.ts';
import { apiGet } from './api-client.ts';
let _allTagsCache: string[] | null = null;
let _allTagsFetchPromise: Promise<string[]> | null = null;
@@ -22,8 +22,7 @@ let _allTagsFetchPromise: Promise<string[]> | null = null;
export async function fetchAllTags(): Promise<string[]> {
if (_allTagsCache) return _allTagsCache;
if (_allTagsFetchPromise) return _allTagsFetchPromise;
_allTagsFetchPromise = fetchWithAuth('/tags')
.then(r => r.json())
_allTagsFetchPromise = apiGet<{ tags?: string[] }>('/tags')
.then(data => {
_allTagsCache = data.tags || [];
_allTagsFetchPromise = null;
@@ -5,7 +5,8 @@
* The canvas shows monitor rectangles that can be repositioned for visual clarity.
*/
import { API_BASE, fetchWithAuth } from '../core/api.ts';
import { API_BASE } from '../core/api.ts';
import { apiGet, apiPut } from '../core/api-client.ts';
import { colorStripSourcesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
@@ -137,14 +138,14 @@ const _modal = new AdvancedCalibrationModal();
export async function showAdvancedCalibration(cssId: string): Promise<void> {
try {
const [cssSources, psResp] = await Promise.all([
const [cssSources, psData] = await Promise.all([
colorStripSourcesCache.fetch(),
fetchWithAuth('/picture-sources'),
apiGet<{ streams?: PictureSource[] }>('/picture-sources').catch((): { streams?: PictureSource[] } => ({})),
]);
const source = cssSources.find(s => s.id === cssId);
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const calibration: Calibration = source.calibration || {} as Calibration;
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
const psList = psData.streams || [];
_state.cssId = cssId;
_state.sourceType = source.source_type || 'picture_advanced';
@@ -223,22 +224,13 @@ export async function saveAdvancedCalibration(): Promise<void> {
};
try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ source_type: _state.sourceType, calibration }),
await apiPut(`/color-strip-sources/${cssId}`, { source_type: _state.sourceType, calibration }, {
errorMessage: t('calibration.error.save_failed'),
});
if (resp.ok) {
showToast(t('calibration.saved'), 'success');
colorStripSourcesCache.invalidate();
_modal.forceClose();
} else {
const err = await resp.json().catch(() => ({}));
const detail = err.detail || err.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('calibration.error.save_failed'), 'error');
}
} catch (error) {
showToast(t('calibration.saved'), 'success');
colorStripSourcesCache.invalidate();
_modal.forceClose();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('calibration.error.save_failed'), 'error');
}
@@ -15,7 +15,8 @@ import {
_cachedAudioFilterDefs,
audioFilterDefsCache,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -158,9 +159,7 @@ export async function editAudioProcessingTemplate(templateId: string) {
try {
if (_cachedAudioFilterDefs.length === 0) await audioFilterDefsCache.fetch();
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`);
document.getElementById('apt-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_processing.edit')}`;
(document.getElementById('apt-id') as HTMLInputElement).value = templateId;
@@ -212,13 +211,10 @@ export async function saveAudioProcessingTemplate() {
};
try {
const url = templateId ? `/audio-processing-templates/${templateId}` : '/audio-processing-templates';
const method = templateId ? 'PUT' : 'POST';
const response = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
if (templateId) {
await apiPut(`/audio-processing-templates/${templateId}`, payload, { errorMessage: t('audio_processing.error.save_failed') });
} else {
await apiPost('/audio-processing-templates', payload, { errorMessage: t('audio_processing.error.save_failed') });
}
showToast(templateId ? t('audio_processing.updated') : t('audio_processing.created'), 'success');
@@ -235,9 +231,7 @@ export async function saveAudioProcessingTemplate() {
export async function cloneAudioProcessingTemplate(templateId: string) {
try {
const resp = await fetchWithAuth(`/audio-processing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
const tmpl = await apiGet<any>(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.load') });
await showAudioProcessingTemplateModal(tmpl);
} catch (error: any) {
if (error.isAuth) return;
@@ -252,11 +246,7 @@ export async function deleteAudioProcessingTemplate(templateId: string) {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-processing-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
await apiDelete(`/audio-processing-templates/${templateId}`, { errorMessage: t('audio_processing.error.delete') });
showToast(t('audio_processing.deleted'), 'success');
audioProcessingTemplatesCache.invalidate();
await loadPictureSources();
@@ -11,7 +11,8 @@
*/
import { _cachedAudioSources, _cachedAudioTemplates, _cachedAudioProcessingTemplates, audioProcessingTemplatesCache, apiKey, audioSourcesCache } from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { API_BASE, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -178,16 +179,10 @@ export async function saveAudioSource() {
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/audio-sources/${id}` : '/audio-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/audio-sources/${id}`, payload);
} else {
await apiPost('/audio-sources', payload);
}
showToast(t(id ? 'audio_source.updated' : 'audio_source.created'), 'success');
audioSourceModal.forceClose();
@@ -203,9 +198,7 @@ export async function saveAudioSource() {
export async function editAudioSource(sourceId: any) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json();
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
await showAudioSourceModal(data.source_type, data);
} catch (e: any) {
if (e.isAuth) return;
@@ -217,12 +210,9 @@ export async function editAudioSource(sourceId: any) {
export async function cloneAudioSource(sourceId: any) {
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('audio_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showAudioSourceModal(data.source_type, data);
const data = await apiGet<any>(`/audio-sources/${sourceId}`, { errorMessage: t('audio_source.error.load') });
const { id: _omit, ...rest } = data;
await showAudioSourceModal(data.source_type, { ...rest, name: `${data.name} (copy)` });
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -236,11 +226,7 @@ export async function deleteAudioSource(sourceId: any) {
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/audio-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/audio-sources/${sourceId}`);
showToast(t('audio_source.deleted'), 'success');
audioSourcesCache.invalidate();
await loadPictureSources();
@@ -267,9 +253,7 @@ let _cachedDevicesByEngine = {};
async function _loadAudioDevices() {
try {
const resp = await fetchWithAuth('/audio-devices');
if (!resp.ok) throw new Error('fetch failed');
const data = await resp.json();
const data = await apiGet<{ by_engine?: Record<string, any[]> }>('/audio-devices');
_cachedDevicesByEngine = data.by_engine || {};
} catch {
_cachedDevicesByEngine = {};
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,8 @@
import {
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts';
import { API_BASE, getHeaders } from '../core/api.ts';
import { apiGet, apiPut } from '../core/api-client.ts';
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
@@ -92,10 +93,7 @@ async function _clearCSSTestMode() {
const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value;
if (!testDeviceId) return;
try {
await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges: {} }),
});
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges: {} });
} catch (err) {
console.error('Failed to clear CSS test mode:', err);
}
@@ -109,11 +107,8 @@ function _setOverlayBtnActive(active: any) {
async function _checkOverlayStatus(cssId: any) {
try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
if (resp.ok) {
const data = await resp.json();
_setOverlayBtnActive(data.active);
}
const data = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
_setOverlayBtnActive(!!data.active);
} catch { /* ignore */ }
}
@@ -121,9 +116,7 @@ export async function toggleCalibrationOverlay() {
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value;
if (!cssId) return;
try {
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`);
if (!resp.ok) return;
const { active } = await resp.json();
const { active } = await apiGet<{ active?: boolean }>(`/color-strip-sources/${cssId}/overlay/status`);
if (active) {
await stopCSSOverlay(cssId);
_setOverlayBtnActive(false);
@@ -143,14 +136,11 @@ export async function toggleCalibrationOverlay() {
export async function showCalibration(deviceId: any) {
try {
const [response, displays] = await Promise.all([
fetchWithAuth(`/devices/${deviceId}`),
const [device, displays] = await Promise.all([
apiGet<any>(`/devices/${deviceId}`),
displaysCache.fetch().catch((): any[] => []),
]);
if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; }
const device = await response.json();
const calibration = device.calibration;
const preview = document.querySelector('.calibration-preview') as HTMLElement;
@@ -843,17 +833,9 @@ export async function toggleTestEdge(edge: any) {
updateCalibrationPreview();
try {
const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ device_id: testDeviceId, edges }),
await apiPut(`/color-strip-sources/${cssId}/calibration/test`, { device_id: testDeviceId, edges }, {
errorMessage: t('calibration.error.test_toggle_failed'),
});
if (!response.ok) {
const errorData = await response.json();
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
} catch (err: any) {
if (err.isAuth) return;
console.error('Failed to toggle CSS test edge:', err);
@@ -875,17 +857,9 @@ export async function toggleTestEdge(edge: any) {
updateCalibrationPreview();
try {
const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, {
method: 'PUT',
body: JSON.stringify({ edges })
await apiPut(`/devices/${deviceId}/calibration/test`, { edges }, {
errorMessage: t('calibration.error.test_toggle_failed'),
});
if (!response.ok) {
const errorData = await response.json();
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.test_toggle_failed');
error.style.display = 'block';
}
} catch (err: any) {
if (err.isAuth) return;
console.error('Failed to toggle test edge:', err);
@@ -965,34 +939,23 @@ export async function saveCalibration() {
};
try {
let response;
if (cssMode) {
const cssSourceType = (document.getElementById('calibration-css-source-type') as HTMLInputElement).value || 'picture';
response = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
method: 'PUT',
body: JSON.stringify({ source_type: cssSourceType, calibration, led_count: declaredLedCount }),
await apiPut(`/color-strip-sources/${cssId}`, { source_type: cssSourceType, calibration, led_count: declaredLedCount }, {
errorMessage: t('calibration.error.save_failed'),
});
} else {
response = await fetchWithAuth(`/devices/${deviceId}/calibration`, {
method: 'PUT',
body: JSON.stringify(calibration),
await apiPut(`/devices/${deviceId}/calibration`, calibration, {
errorMessage: t('calibration.error.save_failed'),
});
}
if (response.ok) {
showToast(t('calibration.saved'), 'success');
if (cssMode) colorStripSourcesCache.invalidate();
calibModal.forceClose();
if (cssMode) {
if (window.loadTargetsTab) window.loadTargetsTab();
} else {
window.loadDevices();
}
showToast(t('calibration.saved'), 'success');
if (cssMode) colorStripSourcesCache.invalidate();
calibModal.forceClose();
if (cssMode) {
if (window.loadTargetsTab) window.loadTargetsTab();
} else {
const errorData = await response.json();
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('calibration.error.save_failed');
error.style.display = 'block';
window.loadDevices();
}
} catch (err: any) {
if (err.isAuth) return;
@@ -18,7 +18,7 @@
* Surface keys are free-form strings — anything calling `setCardMode` is
* implicitly registering that key. Defaults are returned for unknown keys.
*/
import { fetchWithAuth } from '../core/api.ts';
import { apiGet, apiPut } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
const LS_KEY = 'card_modes_v1';
@@ -131,10 +131,7 @@ function _scheduleServerPush(): void {
async function _pushToServer(prefs: CardModePrefsV1): Promise<void> {
try {
await fetchWithAuth('/preferences/card-modes', {
method: 'PUT',
body: JSON.stringify(prefs),
});
await apiPut('/preferences/card-modes', prefs);
} catch (e) {
console.warn('card-modes server PUT failed', e);
}
@@ -160,9 +157,7 @@ export function hydrateCardModesFromCache(): CardModePrefsV1 {
export async function syncCardModesFromServer(): Promise<void> {
if (_serverSyncedOnce) return;
try {
const resp = await fetchWithAuth('/preferences/card-modes');
if (!resp || !resp.ok) return;
const data = await resp.json();
const data = await apiGet<any>('/preferences/card-modes');
if (data && typeof data === 'object' && (data as Record<string, unknown>).version) {
_current = _normalise(data);
_persistLocal();
@@ -3,7 +3,7 @@
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth } from '../../core/api.ts';
import { apiPost, apiPut, apiDelete } from '../../core/api-client.ts';
import { gradientsCache, GradientEntity } from '../../core/state.ts';
import { t } from '../../core/i18n.ts';
import { showToast, showConfirm } from '../../core/ui.ts';
@@ -98,11 +98,7 @@ export async function promptAndSaveGradientPreset() {
color: s.color,
}));
try {
await fetchWithAuth('/gradients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), stops }),
});
await apiPost('/gradients', { name: name.trim(), stops });
await gradientsCache.fetch({ force: true });
showToast(t('color_strip.gradient.preset.saved'), 'success');
} catch (e: any) {
@@ -112,7 +108,7 @@ export async function promptAndSaveGradientPreset() {
export async function deleteAndRefreshGradientPreset(gradientId: any) {
try {
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
await apiDelete(`/gradients/${gradientId}`);
await gradientsCache.fetch({ force: true });
showToast(t('color_strip.gradient.preset.deleted'), 'success');
} catch (e: any) {
@@ -221,12 +217,10 @@ export async function saveGradientEntity() {
const payload: any = { name, stops, description, tags };
try {
const url = id ? `/gradients/${id}` : '/gradients';
const method = id ? 'PUT' : 'POST';
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (!res!.ok) {
const err = await res!.json();
throw new Error(err.detail || 'Failed to save gradient');
if (id) {
await apiPut(`/gradients/${id}`, payload, { errorMessage: t('gradient.error.save_failed') });
} else {
await apiPost('/gradients', payload, { errorMessage: t('gradient.error.save_failed') });
}
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
@@ -256,7 +250,7 @@ export async function deleteGradient(gradientId: string) {
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
if (!ok) return;
try {
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
await apiDelete(`/gradients/${gradientId}`, { errorMessage: t('gradient.error.delete_failed') });
gradientsCache.invalidate();
showToast(t('gradient.deleted'), 'success');
if (window.loadPictureSources) await window.loadPictureSources();
@@ -3,7 +3,8 @@
* Extracted from color-strips.ts to reduce file size.
*/
import { fetchWithAuth, escapeHtml } from '../../core/api.ts';
import { escapeHtml } from '../../core/api.ts';
import { apiGet, apiPost } from '../../core/api-client.ts';
import { t } from '../../core/i18n.ts';
import { showToast } from '../../core/ui.ts';
import {
@@ -313,20 +314,17 @@ export function ensureNotifSoundEntitySelect() {
export async function testNotification(sourceId: string) {
try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('color_strip.notification.test.error'), 'error');
return;
}
const data = await resp.json();
if (data.streams_notified > 0) {
const data = await apiPost<{ streams_notified?: number }>(
`/color-strip-sources/${sourceId}/notify`, undefined,
{ errorMessage: t('color_strip.notification.test.error') },
);
if ((data.streams_notified ?? 0) > 0) {
showToast(t('color_strip.notification.test.ok'), 'success');
} else {
showToast(t('color_strip.notification.test.no_streams'), 'warning');
}
} catch {
showToast(t('color_strip.notification.test.error'), 'error');
} catch (e: any) {
showToast(e?.message || t('color_strip.notification.test.error'), 'error');
}
}
@@ -355,9 +353,7 @@ async function _loadNotificationHistory() {
if (!list) return;
try {
const resp = (await fetchWithAuth('/color-strip-sources/os-notifications/history'))!;
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiGet<any>('/color-strip-sources/os-notifications/history');
if (!data.available) {
list.innerHTML = '';
@@ -12,7 +12,7 @@
* not a closed enum. New cards can be added in v1.1+ (audio meters, alerts,
* preview strips, etc.) without a schema bump or migration.
*/
import { fetchWithAuth } from '../core/api.ts';
import { apiGet, apiPut } from '../core/api-client.ts';
const LS_KEY = 'dashboard_layout_v1';
const SCHEMA_VERSION = 1;
@@ -397,9 +397,7 @@ export function hydrateDashboardLayoutFromCache(): DashboardLayoutV1 {
export async function syncDashboardLayoutFromServer(): Promise<void> {
if (_serverSyncedOnce) return;
try {
const resp = await fetchWithAuth('/preferences/dashboard-layout');
if (!resp || !resp.ok) return;
const data = await resp.json();
const data = await apiGet<any>('/preferences/dashboard-layout');
if (data && typeof data === 'object' && data.version) {
const merged = _mergeWithDefaults(data);
_current = merged;
@@ -431,10 +429,7 @@ export function saveDashboardLayout(next: DashboardLayoutV1): void {
async function _pushToServer(layout: DashboardLayoutV1): Promise<void> {
try {
await fetchWithAuth('/preferences/dashboard-layout', {
method: 'PUT',
body: JSON.stringify(layout),
});
await apiPut('/preferences/dashboard-layout', layout);
} catch (e) {
console.warn('dashboard layout PUT failed', e);
}
@@ -7,7 +7,8 @@ import {
_discoveryCache, set_discoveryCache,
csptCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { API_BASE, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost } from '../core/api-client.ts';
import { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts';
@@ -1036,20 +1037,11 @@ export async function scanForDevices(forceType?: any) {
try {
const scanTimeout = scanType === 'ble' ? 8 : 3;
const response = await fetchWithAuth(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=${scanTimeout}&device_type=${encodeURIComponent(scanType)}`);
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
if (!response.ok) {
if (!isSerialDevice(scanType)) {
empty.style.display = 'block';
(empty.querySelector('small') as HTMLElement).textContent = t('device.scan.error');
}
return;
}
const data = await response.json();
_discoveryCache[scanType] = data.devices || [];
// Only render if the user is still on this type
@@ -1267,36 +1259,25 @@ export async function handleAddDevice(event: any) {
}
}
const response = await fetchWithAuth('/devices', {
method: 'POST',
body: JSON.stringify(body)
});
if (response.ok) {
const result = await response.json();
// result is logged by the API layer; no console.log here.
showToast(t('device_discovery.added'), 'success');
devicesCache.invalidate();
addDeviceModal.forceClose();
if (typeof window.loadDevices === 'function') await window.loadDevices();
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => {
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
}, 300);
}
} else {
const errorData = await response.json();
console.error('Failed to add device:', errorData);
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
error.textContent = detailStr || t('device_discovery.error.add_failed');
error.style.display = 'block';
await apiPost('/devices', body, { errorMessage: t('device_discovery.error.add_failed') });
showToast(t('device_discovery.added'), 'success');
devicesCache.invalidate();
addDeviceModal.forceClose();
if (typeof window.loadDevices === 'function') await window.loadDevices();
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => {
if (typeof window.startDeviceTutorial === 'function') window.startDeviceTutorial();
}, 300);
}
} catch (err: any) {
if (err.isAuth) return;
console.error('Failed to add device:', err);
showToast(err.message || t('device_discovery.error.add_failed'), 'error');
// Surface the message inline (HTTP errors carry the server detail,
// array-detail is joined by the api-client; network errors fall back
// to the localised default).
error.textContent = err.message || t('device_discovery.error.add_failed');
error.style.display = 'block';
}
}
@@ -1315,17 +1296,15 @@ export async function _fetchOpenrgbZones(baseUrl: any, containerId: any, preChec
container.innerHTML = `<span class="zone-loading">${t('device.openrgb.zone.loading')}</span>`;
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) {
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();
const data = await apiGet<{ zones?: any[] }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`, {
errorMessage: t('device.openrgb.zone.error'),
});
_renderZoneCheckboxes(container, data.zones, preChecked);
} catch (err: any) {
if (err.isAuth) return;
container.innerHTML = `<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) {
try {
const resp = await fetchWithAuth(`/devices/${deviceId}`);
if (!resp.ok) throw new Error('Failed to load device');
const device = await resp.json();
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.load_failed') });
showAddDevice(device.device_type || 'wled', device);
} catch (error: any) {
if (error.isAuth) return;
@@ -6,7 +6,8 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { API_BASE, getHeaders, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { devicesCache } from '../core/state.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
import { t } from '../core/i18n.ts';
@@ -363,19 +364,13 @@ export async function turnOffDevice(deviceId: any) {
const confirmed = await showConfirm(t('confirm.turn_off_device'));
if (!confirmed) return;
try {
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
method: 'PUT',
body: JSON.stringify({ power: false })
await apiPut(`/devices/${deviceId}/power`, { power: false }, {
errorMessage: t('device.error.power_off_failed'),
});
if (setResp.ok) {
showToast(t('device.power.off_success'), 'success');
} else {
const error = await setResp.json();
showToast(error.detail || 'Failed', 'error');
}
showToast(t('device.power.off_success'), 'success');
} catch (error: any) {
if (error.isAuth) return;
showToast(t('device.error.power_off_failed'), 'error');
showToast(error.message || t('device.error.power_off_failed'), 'error');
}
}
@@ -383,23 +378,19 @@ export async function pingDevice(deviceId: any) {
const btn = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"] .card-ping-btn`) as HTMLElement | null;
if (btn) btn.classList.add('spinning');
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/ping`, { method: 'POST' });
if (resp.ok) {
const data = await resp.json();
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
showToast(data.device_online
? t('device.ping.online', { ms })
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
// Refresh device cards to update health dot
devicesCache.invalidate();
await window.loadDevices();
} else {
const err = await resp.json();
showToast(err.detail || 'Ping failed', 'error');
}
const data = await apiPost<{ device_online?: boolean; device_latency_ms?: number }>(
`/devices/${deviceId}/ping`, undefined, { errorMessage: t('device.ping.error') },
);
const ms = data.device_latency_ms != null ? data.device_latency_ms.toFixed(0) : '?';
showToast(data.device_online
? t('device.ping.online', { ms })
: t('device.ping.offline'), data.device_online ? 'success' : 'error');
// Refresh device cards to update health dot
devicesCache.invalidate();
await window.loadDevices();
} catch (error: any) {
if (error.isAuth) return;
showToast(t('device.ping.error'), 'error');
showToast(error.message || t('device.ping.error'), 'error');
} finally {
if (btn) btn.classList.remove('spinning');
}
@@ -414,30 +405,20 @@ export async function removeDevice(deviceId: any) {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('device.removed'), 'success');
devicesCache.invalidate();
window.loadDevices();
} else {
const error = await response.json();
showToast(error.detail || t('device.error.remove_failed'), 'error');
}
await apiDelete(`/devices/${deviceId}`, { errorMessage: t('device.error.remove_failed') });
showToast(t('device.removed'), 'success');
devicesCache.invalidate();
window.loadDevices();
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to remove device:', error);
showToast(t('device.error.remove_failed'), 'error');
showToast(error.message || t('device.error.remove_failed'), 'error');
}
}
export async function showSettings(deviceId: any) {
try {
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`);
if (!deviceResponse.ok) { showToast(t('device.error.settings_load_failed'), 'error'); return; }
const device = await deviceResponse.json();
const device = await apiGet<any>(`/devices/${deviceId}`, { errorMessage: t('device.error.settings_load_failed') });
const isAdalight = isSerialDevice(device.device_type);
const caps = device.capabilities || [];
@@ -934,18 +915,7 @@ export async function saveDeviceSettings() {
}
const csptId = (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '';
body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify(body)
});
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
const detail = errorData.detail || errorData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
settingsModal.showError(detailStr || t('device.error.update'));
return;
}
await apiPut(`/devices/${deviceId}`, body, { errorMessage: t('device.error.update') });
showToast(t('settings.saved'), 'success');
devicesCache.invalidate();
@@ -978,16 +948,9 @@ export async function saveCardBrightness(deviceId: any, value: any) {
const bri = parseInt(value);
updateDeviceBrightness(deviceId, bri);
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`, {
method: 'PUT',
body: JSON.stringify({ brightness: bri })
await apiPut(`/devices/${deviceId}/brightness`, { brightness: bri }, {
errorMessage: t('device.error.brightness'),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('device.error.brightness'), 'error');
}
} catch (err: any) {
if (err.isAuth) return;
showToast(err.message || t('device.error.brightness'), 'error');
@@ -999,9 +962,7 @@ export async function fetchDeviceBrightness(deviceId: any) {
if (_brightnessFetchInFlight.has(deviceId)) return;
_brightnessFetchInFlight.add(deviceId);
try {
const resp = await fetchWithAuth(`/devices/${deviceId}/brightness`);
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<any>(`/devices/${deviceId}/brightness`);
updateDeviceBrightness(deviceId, data.brightness);
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
@@ -1078,9 +1039,7 @@ async function _populateSettingsSerialPorts(currentUrl: any) {
try {
const discoverType = settingsModal.deviceType || 'adalight';
const resp = await fetchWithAuth(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<{ devices?: any[] }>(`/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`);
const devices = data.devices || [];
select.innerHTML = '';
@@ -1154,11 +1113,9 @@ export async function enrichOpenrgbZoneBadges(deviceId: any, deviceUrl: any) {
_zoneCountInFlight.add(baseUrl);
try {
const resp = await fetchWithAuth(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<{ zones?: Array<{ name: string; led_count: number }> }>(`/devices/openrgb-zones?url=${encodeURIComponent(baseUrl)}`);
const counts: any = {};
for (const z of data.zones) {
for (const z of (data.zones || [])) {
counts[z.name.toLowerCase()] = z.led_count;
}
_zoneCountCache[baseUrl] = counts;
@@ -9,7 +9,7 @@ import {
availableEngines,
} from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth } from '../core/api.ts';
import { apiGet, apiPost } from '../core/api-client.ts';
import { showToast } from '../core/ui.ts';
import type { Display } from '../types.ts';
@@ -87,9 +87,7 @@ async function _fetchAndRenderEngineDisplays(engineType: string): Promise<void>
canvas.innerHTML = '<div class="loading-spinner"></div>';
try {
const resp = await fetchWithAuth(`/config/displays?engine_type=${engineType}`);
if (!resp.ok) throw new Error(`${resp.status}`);
const data = await resp.json();
const data = await apiGet<{ displays?: Display[] }>(`/config/displays?engine_type=${engineType}`);
const displays = data.displays || [];
// Store in cache so selectDisplay() can look them up
@@ -137,14 +135,10 @@ window._adbConnectFromPicker = async function () {
input.disabled = true;
try {
const resp = await fetchWithAuth('/adb/connect', {
method: 'POST',
body: JSON.stringify({ address }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Connection failed');
}
// No errorMessage option: the catch already prefixes the toast with
// the localised `displays.picker.adb_connect.error` label, and the
// server's `detail` (or `HTTP <status>` fallback) becomes the suffix.
await apiPost('/adb/connect', { address });
showToast(t('displays.picker.adb_connect.success'), 'success');
// Refresh the picker with updated device list
@@ -6,7 +6,8 @@ import {
gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -48,10 +49,8 @@ const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ──
function _bulkDeleteGameIntegrations(ids: string[]) {
return Promise.allSettled(ids.map(id =>
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
)).then(results => {
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
return Promise.allSettled(ids.map(id => apiDelete(`/game-integrations/${id}`))).then(results => {
const failed = results.filter(r => r.status === 'rejected').length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
@@ -192,13 +191,9 @@ export async function autoSetupGameIntegration() {
}
try {
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
if (!res || !res.ok) {
const err = await res!.json();
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
return;
}
const data = await res.json();
const data = await apiPost<{ success: boolean; file_path?: string; token_generated?: boolean; message?: string }>(
`/game-integrations/${id}/auto-setup`, undefined, { errorMessage: t('game_integration.auto_setup.failed') },
);
if (data.success) {
let msg = t('game_integration.auto_setup.success');
if (data.file_path) msg += `\n${data.file_path}`;
@@ -424,11 +419,8 @@ let _cachedPresets: EffectPreset[] = [];
async function _loadPresets(): Promise<EffectPreset[]> {
if (_cachedPresets.length > 0) return _cachedPresets;
try {
const res = await fetchWithAuth('/game-integrations/presets');
if (res && res.ok) {
const data = await res.json();
_cachedPresets = data.presets || [];
}
const data = await apiGet<{ presets?: EffectPreset[] }>('/game-integrations/presets');
_cachedPresets = data.presets || [];
} catch { /* ignore */ }
return _cachedPresets;
}
@@ -494,10 +486,8 @@ function _startEventMonitor(integrationId: string) {
const poll = async () => {
try {
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
if (!res || !res.ok) return;
const data = await res.json();
const events: GameEventRecord[] = data.events || [];
const data = await apiGet<{ events?: GameEventRecord[] }>(`/game-integrations/${integrationId}/events`);
const events = data.events || [];
if (events.length === 0) return;
feed.innerHTML = events.slice(0, 20).map(ev => {
const ts = new Date(ev.timestamp).toLocaleTimeString();
@@ -535,9 +525,7 @@ export function testGameConnection() {
_connectionTestTimer = setInterval(async () => {
attempts++;
try {
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
if (!res || !res.ok) return;
const status: GameIntegrationStatus = await res.json();
const status = await apiGet<GameIntegrationStatus>(`/game-integrations/${id}/status`);
if (status.event_count > 0) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
@@ -725,12 +713,10 @@ export async function saveGameIntegration() {
};
try {
const url = id ? `/game-integrations/${id}` : '/game-integrations';
const method = id ? 'PUT' : 'POST';
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (!res || !res.ok) {
const err = await res!.json();
throw new Error(err.detail || t('game_integration.error.save_failed'));
if (id) {
await apiPut(`/game-integrations/${id}`, payload, { errorMessage: t('game_integration.error.save_failed') });
} else {
await apiPost('/game-integrations', payload, { errorMessage: t('game_integration.error.save_failed') });
}
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
gameIntegrationsCache.invalidate();
@@ -746,7 +732,7 @@ export async function deleteGameIntegration(entityId: string) {
const ok = await showConfirm(t('game_integration.confirm_delete'));
if (!ok) return;
try {
await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' });
await apiDelete(`/game-integrations/${entityId}`, { errorMessage: t('game_integration.error.delete_failed') });
showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
loadGameIntegrations();
@@ -7,7 +7,8 @@ import {
colorStripSourcesCache, outputTargetsCache, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
import { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
@@ -149,9 +150,7 @@ function _getEntityItems() {
async function _fetchHAEntities(haSourceId: string): Promise<void> {
if (!haSourceId) { _cachedHAEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _cachedHAEntities = []; return; }
const data = await resp.json();
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
_cachedHAEntities = data.entities || [];
// Mirror into the shared cache so card chips/swatches across the
// app pick up friendly names on the next render.
@@ -381,9 +380,7 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat
if (isEdit) {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -542,22 +539,10 @@ export async function saveHALightEditor(): Promise<void> {
payload.target_type = 'ha_light';
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/output-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
await apiPut(`/output-targets/${targetId}`, payload);
} else {
response = await fetchWithAuth('/output-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
await apiPost('/output-targets', payload);
}
showToast(targetId ? t('ha_light.updated') : t('ha_light.created'), 'success');
@@ -579,12 +564,9 @@ export async function editHALightTarget(targetId: string): Promise<void> {
export async function cloneHALightTarget(targetId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showHALightEditor(null, data);
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
const { id: _omit, ...rest } = data;
await showHALightEditor(null, { ...rest, name: `${data.name} (copy)` });
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -834,8 +816,7 @@ const _haLightActions: Record<string, (id: string) => void> = {
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await apiPost(`/output-targets/${targetId}/${action}`);
outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
@@ -848,19 +829,13 @@ export async function turnOffHALightTarget(targetId: string): Promise<void> {
const confirmed = await showConfirm(t('confirm.turn_off_ha_light') || 'Turn off mapped lights?');
if (!confirmed) return;
try {
const resp = await fetchWithAuth(
`/output-targets/${targetId}/ha-light/turn-off`,
{ method: 'POST' },
);
if (resp.ok) {
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
} else {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
}
await apiPost(`/output-targets/${targetId}/ha-light/turn-off`, undefined, {
errorMessage: t('ha_light.turn_off.failed') || 'Failed to turn off lights',
});
showToast(t('ha_light.turn_off.success') || 'Lights turned off', 'success');
} catch (e: any) {
if (e.isAuth) return;
showToast(t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
showToast(e.message || t('ha_light.turn_off.failed') || 'Failed to turn off lights', 'error');
}
}
@@ -6,7 +6,8 @@ import {
_cachedHASources, haSourcesCache,
_haEntityNamesCache, setHAEntityNames,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -75,12 +76,10 @@ const haSourceModal = new HASourceModal();
export async function fetchHAEntities(haSourceId: string): Promise<void> {
if (!haSourceId) return;
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<{ entities?: any[] }>(`/home-assistant/sources/${haSourceId}/entities`);
setHAEntityNames(haSourceId, data.entities || []);
} catch {
// Leave any existing cache entry intact.
// Leave any existing cache entry intact (any non-2xx or network error).
}
}
@@ -174,16 +173,10 @@ export async function saveHASource(): Promise<void> {
if (token) payload.token = token;
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/home-assistant/sources/${id}` : '/home-assistant/sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/home-assistant/sources/${id}`, payload);
} else {
await apiPost('/home-assistant/sources', payload);
}
showToast(t(id ? 'ha_source.updated' : 'ha_source.created'), 'success');
haSourceModal.forceClose();
@@ -199,9 +192,7 @@ export async function saveHASource(): Promise<void> {
export async function editHASource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
if (!resp.ok) throw new Error(t('ha_source.error.load'));
const data = await resp.json();
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
await showHASourceModal(data);
} catch (e: any) {
if (e.isAuth) return;
@@ -211,12 +202,9 @@ export async function editHASource(sourceId: string): Promise<void> {
export async function cloneHASource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`);
if (!resp.ok) throw new Error(t('ha_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showHASourceModal(data);
const data = await apiGet<HomeAssistantSource>(`/home-assistant/sources/${sourceId}`, { errorMessage: t('ha_source.error.load') });
const { id: _omit, ...rest } = data;
await showHASourceModal({ ...rest, name: `${data.name} (copy)` } as HomeAssistantSource);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -227,11 +215,7 @@ export async function deleteHASource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('ha_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/home-assistant/sources/${sourceId}`);
showToast(t('ha_source.deleted'), 'success');
haSourcesCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -251,9 +235,7 @@ export async function testHASource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading');
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${id}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<HATestResult>(`/home-assistant/sources/${id}/test`);
if (data.success) {
showToast(`${t('ha_source.test.success')} | HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
} else {
@@ -267,6 +249,14 @@ export async function testHASource(): Promise<void> {
}
}
/** Shape returned by `POST /home-assistant/sources/{id}/test`. */
interface HATestResult {
success: boolean;
ha_version?: string;
entity_count?: number;
error?: string;
}
// ── Card rendering ──
export function createHASourceCard(source: HomeAssistantSource) {
@@ -328,9 +318,7 @@ const _haSourceActions: Record<string, (id: string) => void> = {
async function _testHASourceFromCard(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${sourceId}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<HATestResult>(`/home-assistant/sources/${sourceId}/test`);
if (data.success) {
showToast(`HA ${data.ha_version} | ${data.entity_count} entities`, 'success');
} else {
@@ -13,7 +13,8 @@
import {
_cachedHTTPEndpoints, httpEndpointsCache,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -315,16 +316,10 @@ export async function saveHTTPEndpoint(): Promise<void> {
}
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/http/endpoints/${id}` : '/http/endpoints';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/http/endpoints/${id}`, payload);
} else {
await apiPost('/http/endpoints', payload);
}
showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success');
httpEndpointModal.forceClose();
@@ -340,9 +335,7 @@ export async function saveHTTPEndpoint(): Promise<void> {
export async function editHTTPEndpoint(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
const data: HTTPEndpoint = await resp.json();
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
await showHTTPEndpointModal(data);
} catch (e: any) {
if (e.isAuth) return;
@@ -352,14 +345,14 @@ export async function editHTTPEndpoint(endpointId: string): Promise<void> {
export async function cloneHTTPEndpoint(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`);
if (!resp.ok) throw new Error(t('http_endpoint.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
// Cloning never reveals the token — user must re-enter if needed.
data.auth_token_set = false;
await showHTTPEndpointModal(data);
const data = await apiGet<HTTPEndpoint>(`/http/endpoints/${endpointId}`, { errorMessage: t('http_endpoint.error.load') });
const { id: _omit, ...rest } = data;
await showHTTPEndpointModal({
...rest,
name: `${data.name} (copy)`,
// Cloning never reveals the token — user must re-enter if needed.
auth_token_set: false,
} as HTTPEndpoint);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -370,11 +363,7 @@ export async function deleteHTTPEndpoint(endpointId: string): Promise<void> {
const confirmed = await showConfirm(t('http_endpoint.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/http/endpoints/${endpointId}`);
showToast(t('http_endpoint.deleted'), 'success');
httpEndpointsCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -427,13 +416,7 @@ export async function testHTTPEndpoint(): Promise<void> {
</div>`;
try {
const resp = await fetchWithAuth('/http/endpoints/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, method, auth_token: token, headers, timeout_s }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data: HTTPTestResponse = await resp.json();
const data = await apiPost<HTTPTestResponse>('/http/endpoints/test', { url, method, auth_token: token, headers, timeout_s });
_renderTestResult(out, data);
} catch (e: any) {
if (e.isAuth) return;
@@ -493,12 +476,7 @@ function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) {
async function _testHTTPEndpointFromCard(endpointId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const data: HTTPTestResponse = await resp.json();
const data = await apiPost<HTTPTestResponse>(`/http/endpoints/${endpointId}/test`);
if (data.success) {
const status = data.status_code != null ? ` (${data.status_code})` : '';
showToast(`${t('http_endpoint.test.success')}${status}`, 'success');
@@ -14,7 +14,8 @@
import { Modal } from '../core/modal.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiPut } from '../core/api-client.ts';
import { showToast } from '../core/ui.ts';
import { devicesCache, outputTargetsCache } from '../core/state.ts';
import {
@@ -494,22 +495,14 @@ async function _applyChange(nextIconId: string, nextColor: string): Promise<void
if (adapter.bodyExtras) {
Object.assign(body, adapter.bodyExtras(entityId));
}
const resp = await fetchWithAuth(adapter.endpoint(entityId), {
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;
}
await apiPut(adapter.endpoint(entityId), body, { errorMessage: t('device.icon.error.save_failed') });
if (nextIconId) _pushRecent(nextIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success');
await adapter.reload();
closeIconPicker();
} catch (error: any) {
if (error?.isAuth) return;
showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
showToast(error?.message || t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
}
}
@@ -15,7 +15,7 @@ import { CardSection } from '../core/card-sections.ts';
import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash } from './tabs.ts';
import { getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts';
import { fetchWithAuth } from '../core/api.ts';
import { apiDelete } from '../core/api-client.ts';
import { showToast, setTabRefreshing } from '../core/ui.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
@@ -29,10 +29,8 @@ import * as P from '../core/icon-paths.ts';
function _bulkDeleteFactory(endpoint: string, cache: any, toast: string) {
return async (ids: string[]) => {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/${endpoint}/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
const results = await Promise.allSettled(ids.map(id => apiDelete(`/${endpoint}/${id}`)));
const failed = results.filter(r => r.status === 'rejected').length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t(toast), 'success');
cache.invalidate();
@@ -3,7 +3,8 @@
*/
import { mqttSourcesCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -144,16 +145,10 @@ export async function saveMQTTSource(): Promise<void> {
if (password) payload.password = password;
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/mqtt/sources/${id}`, payload);
} else {
await apiPost('/mqtt/sources', payload);
}
showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success');
mqttSourceModal.forceClose();
@@ -168,9 +163,7 @@ export async function saveMQTTSource(): Promise<void> {
export async function editMQTTSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
const data = await resp.json();
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
await showMQTTSourceModal(data);
} catch (e: any) {
if (e.isAuth) return;
@@ -180,12 +173,9 @@ export async function editMQTTSource(sourceId: string): Promise<void> {
export async function cloneMQTTSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`);
if (!resp.ok) throw new Error(t('mqtt_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showMQTTSourceModal(data);
const data = await apiGet<MQTTSource>(`/mqtt/sources/${sourceId}`, { errorMessage: t('mqtt_source.error.load') });
const { id: _omit, ...rest } = data;
await showMQTTSourceModal({ ...rest, name: `${data.name} (copy)` } as MQTTSource);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -196,11 +186,7 @@ export async function deleteMQTTSource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('mqtt_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/mqtt/sources/${sourceId}`);
showToast(t('mqtt_source.deleted'), 'success');
mqttSourcesCache.invalidate();
} catch (e: any) {
@@ -219,9 +205,7 @@ export async function testMQTTSource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading');
try {
const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${id}/test`);
if (data.success) {
showToast(t('mqtt_source.test.success'), 'success');
} 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> {
try {
const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<MQTTTestResult>(`/mqtt/sources/${sourceId}/test`);
if (data.success) {
showToast(t('mqtt_source.test.success'), 'success');
} else {
@@ -21,7 +21,7 @@
* settings.
*/
import { fetchWithAuth } from '../core/api.ts';
import { apiGet, apiPut } from '../core/api-client.ts';
import { showToast } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { logError } from '../core/log.ts';
@@ -102,9 +102,7 @@ export async function startNotificationsWatcher(): Promise<void> {
/** Pull the latest prefs from the server and cache them. */
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
try {
const resp = await fetchWithAuth('/preferences/notifications');
if (!resp.ok) return _prefs;
const data = await resp.json();
const data = await apiGet<any>('/preferences/notifications');
_prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
} catch (err) {
logError('notifications.fetch', err);
@@ -116,15 +114,7 @@ export async function refreshNotificationPreferences(): Promise<NotificationPref
export async function saveNotificationPreferences(
next: NotificationPreferences,
): Promise<NotificationPreferences> {
const resp = await fetchWithAuth('/preferences/notifications', {
method: 'PUT',
body: JSON.stringify(next),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const saved = await resp.json();
const saved = await apiPut<any>('/preferences/notifications', next);
_prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
return _prefs;
}
@@ -15,7 +15,8 @@ import {
PATTERN_RECT_BORDERS,
streamsCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { patternTemplatesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
@@ -260,20 +261,10 @@ export async function savePatternTemplate(): Promise<void> {
};
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'PUT', body: JSON.stringify(payload),
});
await apiPut(`/pattern-templates/${templateId}`, payload, { errorMessage: t('pattern.error.save_failed') });
} else {
response = await fetchWithAuth('/pattern-templates', {
method: 'POST', body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
await apiPost('/pattern-templates', payload, { errorMessage: t('pattern.error.save_failed') });
}
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
@@ -305,20 +296,13 @@ export async function deletePatternTemplate(templateId: string): Promise<void> {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/pattern-templates/${templateId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('pattern.deleted'), 'success');
patternTemplatesCache.invalidate();
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else {
const error = await response.json();
showToast(error.detail || t('pattern.error.delete_failed'), 'error');
}
await apiDelete(`/pattern-templates/${templateId}`, { errorMessage: t('pattern.error.delete_failed') });
showToast(t('pattern.deleted'), 'success');
patternTemplatesCache.invalidate();
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
showToast(t('pattern.error.delete_failed'), 'error');
showToast(error.message || t('pattern.error.delete_failed'), 'error');
}
}
@@ -8,7 +8,8 @@
* cheap for 120-sample lines.
*/
import { fetchMetricsHistory, fetchWithAuth } from '../core/api.ts';
import { fetchMetricsHistory } from '../core/api.ts';
import { apiGet } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -1102,9 +1103,7 @@ function _renderValuePair(key: string, sysVal: string, appVal: string | null): v
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/performance');
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<any>('/system/performance');
_lastFetchData = data;
_applyPerfDataToDom(data, /*pushHistory=*/true);
} catch (err) {
@@ -3,7 +3,8 @@
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -135,10 +136,8 @@ export const csScenes = new CardSection('scenes', {
bulkActions: [{
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete',
handler: async (ids) => {
const results = await Promise.allSettled(ids.map(id =>
fetchWithAuth(`/scene-presets/${id}`, { method: 'DELETE' })
));
const failed = results.filter(r => r.status === 'rejected' || (r.value && !r.value.ok)).length;
const results = await Promise.allSettled(ids.map(id => apiDelete(`/scene-presets/${id}`)));
const failed = results.filter(r => r.status === 'rejected').length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
@@ -384,37 +383,22 @@ export async function saveScenePreset(): Promise<void> {
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
try {
let resp;
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => (el as HTMLElement).dataset.targetId);
const body = { name, description, target_ids, tags };
if (_editingId) {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => (el as HTMLElement).dataset.targetId);
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
method: 'PUT',
body: JSON.stringify({ name, description, target_ids, tags }),
});
await apiPut(`/scene-presets/${_editingId}`, body, { errorMessage: t('scenes.error.save_failed') });
} else {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => (el as HTMLElement).dataset.targetId);
resp = await fetchWithAuth('/scene-presets', {
method: 'POST',
body: JSON.stringify({ name, description, target_ids, tags }),
});
}
if (!resp.ok) {
const err = await resp.json();
errorEl.textContent = err.detail || t('scenes.error.save_failed');
errorEl.style.display = 'block';
return;
await apiPost('/scene-presets', body, { errorMessage: t('scenes.error.save_failed') });
}
scenePresetModal.forceClose();
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} catch (error) {
} catch (error: any) {
if (error.isAuth) return;
errorEl.textContent = t('scenes.error.save_failed');
errorEl.textContent = error.message || t('scenes.error.save_failed');
errorEl.style.display = 'block';
}
}
@@ -488,17 +472,9 @@ export async function addSceneTarget(): Promise<void> {
export async function activateScenePreset(presetId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
method: 'POST',
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.activate_failed'), 'error');
return;
}
const result = await resp.json();
const result = await apiPost<{ status: string; errors: any[] }>(
`/scene-presets/${presetId}/activate`, undefined, { errorMessage: t('scenes.error.activate_failed') },
);
if (result.status === 'activated') {
showToast(t('scenes.activated'), 'success');
} else {
@@ -507,7 +483,7 @@ export async function activateScenePreset(presetId: string): Promise<void> {
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
} catch (error: any) {
if (error.isAuth) return;
showToast(t('scenes.error.activate_failed'), 'error');
showToast(error.message || t('scenes.error.activate_failed'), 'error');
}
}
@@ -520,20 +496,11 @@ export async function recaptureScenePreset(presetId: string): Promise<void> {
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
method: 'POST',
});
if (resp.ok) {
showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.recapture_failed'), 'error');
}
} catch (error) {
await apiPost(`/scene-presets/${presetId}/recapture`, undefined, { errorMessage: t('scenes.error.recapture_failed') });
showToast(t('scenes.recaptured'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('scenes.error.recapture_failed'), 'error');
}
@@ -592,20 +559,11 @@ export async function deleteScenePreset(presetId: string): Promise<void> {
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
method: 'DELETE',
});
if (resp.ok) {
showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} else {
const errData = await resp.json().catch(() => ({}));
const detail = errData.detail || errData.message || '';
const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail);
showToast(detailStr || t('scenes.error.delete_failed'), 'error');
}
} catch (error) {
await apiDelete(`/scene-presets/${presetId}`, { errorMessage: t('scenes.error.delete_failed') });
showToast(t('scenes.deleted'), 'success');
scenePresetsCache.invalidate();
_reloadScenesTab();
} catch (error: any) {
if (error.isAuth) return;
showToast(error.message || t('scenes.error.delete_failed'), 'error');
}
@@ -11,7 +11,8 @@ import {
audioTemplatesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { API_BASE, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { logError } from '../core/log.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
@@ -61,9 +62,7 @@ const audioTemplateModal = new AudioTemplateModal();
async function loadAvailableAudioEngines() {
try {
const response = await fetchWithAuth('/audio-engines');
if (!response.ok) throw new Error(`Failed to load audio engines: ${response.status}`);
const data = await response.json();
const data = await apiGet<{ engines?: any[] }>('/audio-engines');
setAvailableAudioEngines(data.engines || []);
const select = document.getElementById('audio-template-engine') as HTMLSelectElement;
@@ -232,9 +231,7 @@ export async function showAddAudioTemplateModal(cloneData: any = null) {
export async function editAudioTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load audio template: ${response.status}`);
const template = await response.json();
const template = await apiGet<any>(`/audio-templates/${templateId}`);
setCurrentEditingAudioTemplateId(templateId);
document.getElementById('audio-template-modal-title')!.innerHTML = `${ICON_AUDIO_TEMPLATE} ${t('audio_template.edit')}`;
@@ -284,16 +281,10 @@ export async function saveAudioTemplate() {
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
await apiPut(`/audio-templates/${templateId}`, payload, { errorMessage: t('audio_template.error.save_failed') });
} else {
response = await fetchWithAuth('/audio-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save audio template');
await apiPost('/audio-templates', payload, { errorMessage: t('audio_template.error.save_failed') });
}
showToast(templateId ? t('audio_template.updated') : t('audio_template.created'), 'success');
@@ -312,11 +303,7 @@ export async function deleteAudioTemplate(templateId: any) {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/audio-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete audio template');
}
await apiDelete(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.delete') });
showToast(t('audio_template.deleted'), 'success');
audioTemplatesCache.invalidate();
await loadAudioTemplates();
@@ -328,11 +315,9 @@ export async function deleteAudioTemplate(templateId: any) {
export async function cloneAudioTemplate(templateId: any) {
try {
const resp = await fetchWithAuth(`/audio-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load audio template');
const tmpl = await resp.json();
const tmpl = await apiGet<any>(`/audio-templates/${templateId}`, { errorMessage: t('audio_template.error.load_failed') });
showAddAudioTemplateModal(tmpl);
} catch (error) {
} catch (error: any) {
if (error.isAuth) return;
console.error('Failed to clone audio template:', error);
showToast(t('audio_template.error.clone_failed'), 'error');
@@ -364,21 +349,18 @@ export async function showTestAudioTemplateModal(templateId: any) {
// Load audio devices for picker — filter by engine type
const deviceSelect = document.getElementById('test-audio-template-device') as HTMLSelectElement;
try {
const resp = await fetchWithAuth('/audio-devices');
if (resp.ok) {
const data = await resp.json();
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
const data = await apiGet<{ by_engine?: Record<string, any[]>; devices?: any[] }>('/audio-devices');
// Use engine-specific device list if available, fall back to flat list
const devices = (engineType && data.by_engine && data.by_engine[engineType])
? data.by_engine[engineType]
: (data.devices || []);
deviceSelect.innerHTML = devices.map(d => {
const label = d.name;
const val = `${d.index}:${d.is_loopback ? '1' : '0'}`;
return `<option value="${val}">${escapeHtml(label)}</option>`;
}).join('');
if (devices.length === 0) {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
}
} catch {
deviceSelect.innerHTML = '<option value="-1:1">Default</option>';
@@ -12,7 +12,8 @@ import {
captureTemplatesCache, displaysCache, enginesCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { API_BASE, getHeaders, escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
@@ -105,9 +106,7 @@ export async function showAddTemplateModal(cloneData: any = null) {
export async function editTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
const template = await apiGet<any>(`/capture-templates/${templateId}`);
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
@@ -414,9 +413,7 @@ async function loadDisplaysForTest() {
// Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
const displaysData = await apiGet<{ displays?: any[] }>(url);
displaysCache.update(displaysData.displays || []);
}
@@ -607,16 +604,10 @@ export async function saveTemplate() {
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
await apiPut(`/capture-templates/${templateId}`, payload, { errorMessage: t('templates.error.save_failed') });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
await apiPost('/capture-templates', payload, { errorMessage: t('templates.error.save_failed') });
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
@@ -635,11 +626,7 @@ export async function deleteTemplate(templateId: any) {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
await apiDelete(`/capture-templates/${templateId}`, { errorMessage: t('templates.error.delete') });
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
@@ -3,7 +3,8 @@
*/
import { _cachedSyncClocks, syncClocksCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -124,16 +125,10 @@ export async function saveSyncClock(): Promise<void> {
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/sync-clocks/${id}`, payload);
} else {
await apiPost('/sync-clocks', payload);
}
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
syncClockModal.forceClose();
@@ -149,9 +144,7 @@ export async function saveSyncClock(): Promise<void> {
export async function editSyncClock(clockId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
await showSyncClockModal(data);
} catch (e) {
if (e.isAuth) return;
@@ -161,12 +154,9 @@ export async function editSyncClock(clockId: string): Promise<void> {
export async function cloneSyncClock(clockId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showSyncClockModal(data);
const data = await apiGet<SyncClock>(`/sync-clocks/${clockId}`, { errorMessage: t('sync_clock.error.load') });
const { id: _omit, ...rest } = data;
await showSyncClockModal({ ...rest, name: `${data.name} (copy)` } as SyncClock);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -177,11 +167,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/sync-clocks/${clockId}`);
showToast(t('sync_clock.deleted'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
@@ -195,8 +181,7 @@ export async function deleteSyncClock(clockId: string): Promise<void> {
export async function pauseSyncClock(clockId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await apiPost(`/sync-clocks/${clockId}/pause`);
showToast(t('sync_clock.paused'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
@@ -208,8 +193,7 @@ export async function pauseSyncClock(clockId: string): Promise<void> {
export async function resumeSyncClock(clockId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await apiPost(`/sync-clocks/${clockId}/resume`);
showToast(t('sync_clock.resumed'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
@@ -221,8 +205,7 @@ export async function resumeSyncClock(clockId: string): Promise<void> {
export async function resetSyncClock(clockId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await apiPost(`/sync-clocks/${clockId}/reset`);
showToast(t('sync_clock.reset_done'), 'success');
syncClocksCache.invalidate();
await loadPictureSources();
@@ -2,7 +2,7 @@
* Auto-update check for new releases, show banner, manage settings.
*/
import { fetchWithAuth } from '../core/api.ts';
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { IconSelect } from '../core/icon-select.ts';
@@ -129,10 +129,7 @@ export function dismissUpdate(): void {
_hideBanner();
_setVersionBadgeUpdate(false);
fetchWithAuth('/system/update/dismiss', {
method: 'POST',
body: JSON.stringify({ version }),
}).catch(() => {});
apiPost('/system/update/dismiss', { version }).catch(() => {});
}
// ─── Apply update ───────────────────────────────────────────
@@ -151,14 +148,7 @@ export async function applyUpdate(): Promise<void> {
btns.forEach(b => (b as HTMLButtonElement).disabled = true);
try {
const resp = await fetchWithAuth('/system/update/apply', {
method: 'POST',
timeout: 600000, // 10 min for download + apply
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiPost('/system/update/apply', undefined, { timeout: 600000 /* 10 min for download + apply */ });
// Server will shut down — the frontend reconnect overlay handles the rest
showToast(t('update.applying'), 'info');
} catch (err) {
@@ -171,9 +161,7 @@ export async function applyUpdate(): Promise<void> {
export async function loadUpdateStatus(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/update/status');
if (!resp.ok) return;
const status: UpdateStatus = await resp.json();
const status = await apiGet<UpdateStatus>('/system/update/status');
_lastStatus = status;
_applyStatus(status);
} catch {
@@ -260,12 +248,7 @@ export async function checkForUpdates(): Promise<void> {
if (spinner) spinner.style.display = '';
try {
const resp = await fetchWithAuth('/system/update/check', { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
const status: UpdateStatus = await resp.json();
const status = await apiPost<UpdateStatus>('/system/update/check');
_lastStatus = status;
_applyStatus(status);
@@ -350,9 +333,7 @@ export function initUpdateSettingsPanel(): void {
export async function loadUpdateSettings(): Promise<void> {
try {
const resp = await fetchWithAuth('/system/update/settings');
if (!resp.ok) return;
const data = await resp.json();
const data = await apiGet<{ enabled: boolean; check_interval_hours: number; include_prerelease: boolean }>('/system/update/settings');
const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null;
const intervalEl = document.getElementById('update-interval') as HTMLSelectElement | null;
@@ -388,14 +369,7 @@ export async function saveUpdateSettings(): Promise<void> {
if (Number.isNaN(check_interval_hours)) return;
try {
const resp = await fetchWithAuth('/system/update/settings', {
method: 'PUT',
body: JSON.stringify({ enabled, check_interval_hours, include_prerelease }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiPut('/system/update/settings', { enabled, check_interval_hours, include_prerelease });
} catch (err) {
showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error');
}
@@ -3,7 +3,8 @@
*/
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut, apiDelete } from '../core/api-client.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
@@ -166,16 +167,10 @@ export async function saveWeatherSource(): Promise<void> {
};
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/weather-sources/${id}` : '/weather-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
if (id) {
await apiPut(`/weather-sources/${id}`, payload);
} else {
await apiPost('/weather-sources', payload);
}
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
weatherSourceModal.forceClose();
@@ -191,9 +186,7 @@ export async function saveWeatherSource(): Promise<void> {
export async function editWeatherSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('weather_source.error.load'));
const data = await resp.json();
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
await showWeatherSourceModal(data);
} catch (e: any) {
if (e.isAuth) return;
@@ -203,12 +196,9 @@ export async function editWeatherSource(sourceId: string): Promise<void> {
export async function cloneWeatherSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('weather_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showWeatherSourceModal(data);
const data = await apiGet<WeatherSource>(`/weather-sources/${sourceId}`, { errorMessage: t('weather_source.error.load') });
const { id: _omit, ...rest } = data;
await showWeatherSourceModal({ ...rest, name: `${data.name} (copy)` } as WeatherSource);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -219,11 +209,7 @@ export async function deleteWeatherSource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
await apiDelete(`/weather-sources/${sourceId}`);
showToast(t('weather_source.deleted'), 'success');
weatherSourcesCache.invalidate();
if (typeof window.loadIntegrations === 'function') await window.loadIntegrations();
@@ -243,9 +229,7 @@ export async function testWeatherSource(): Promise<void> {
if (testBtn) testBtn.classList.add('loading');
try {
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<WeatherTestResult>(`/weather-sources/${id}/test`);
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) {
if (e.isAuth) return;
@@ -255,6 +239,13 @@ export async function testWeatherSource(): Promise<void> {
}
}
/** Shape returned by `POST /weather-sources/{id}/test`. */
interface WeatherTestResult {
condition: string;
temperature: number;
wind_speed: number;
}
// ── Geolocation ──
export function weatherSourceGeolocate(): void {
@@ -336,9 +327,7 @@ const _weatherSourceActions: Record<string, (id: string) => void> = {
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const data = await apiPost<WeatherTestResult>(`/weather-sources/${sourceId}/test`);
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) {
if (e.isAuth) return;
@@ -16,7 +16,8 @@ import {
colorStripSourcesCache, mqttSourcesCache,
outputTargetsCache, valueSourcesCache,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { escapeHtml } from '../core/api.ts';
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
import { logError } from '../core/log.ts';
import { safeJsonParse } from '../core/storage.ts';
import { t } from '../core/i18n.ts';
@@ -303,9 +304,7 @@ export async function showZ2MLightEditor(targetId: string | null = null, cloneDa
let editData: any = null;
if (isEdit) {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
editData = await resp.json();
editData = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -463,13 +462,10 @@ export async function saveZ2MLightEditor(): Promise<void> {
};
try {
const response = targetId
? await fetchWithAuth(`/output-targets/${targetId}`, { method: 'PUT', body: JSON.stringify(payload) })
: await fetchWithAuth('/output-targets', { method: 'POST', body: JSON.stringify(payload) });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${response.status}`);
if (targetId) {
await apiPut(`/output-targets/${targetId}`, payload);
} else {
await apiPost('/output-targets', payload);
}
showToast(targetId ? t('z2m_light.updated') : t('z2m_light.created'), 'success');
outputTargetsCache.invalidate();
@@ -489,12 +485,9 @@ export async function editZ2MLightTarget(targetId: string): Promise<void> {
export async function cloneZ2MLightTarget(targetId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
if (!resp.ok) throw new Error('Failed to load target');
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showZ2MLightEditor(null, data);
const data = await apiGet<any>(`/output-targets/${targetId}`, { errorMessage: t('target.error.load_failed') });
const { id: _omit, ...rest } = data;
await showZ2MLightEditor(null, { ...rest, name: `${data.name} (copy)` });
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
@@ -659,8 +652,7 @@ const _z2mLightActions: Record<string, (id: string) => void> = {
async function _startStop(targetId: string, action: 'start' | 'stop'): Promise<void> {
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/${action}`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await apiPost(`/output-targets/${targetId}/${action}`);
outputTargetsCache.invalidate();
if (window.loadTargetsTab) await window.loadTargetsTab();
} catch (e: any) {
@@ -673,16 +665,13 @@ export async function turnOffZ2MLightTarget(targetId: string): Promise<void> {
const confirmed = await showConfirm(t('confirm.turn_off_z2m_light') || 'Turn off mapped bulbs?');
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/output-targets/${targetId}/z2m-light/turn-off`, { method: 'POST' });
if (resp.ok) {
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
} else {
const err = await resp.json().catch(() => ({}));
showToast(err.detail || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
}
await apiPost(`/output-targets/${targetId}/z2m-light/turn-off`, undefined, {
errorMessage: t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs',
});
showToast(t('z2m_light.turn_off.success') || 'Bulbs turned off', 'success');
} catch (e: any) {
if (e.isAuth) return;
showToast(t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
showToast(e.message || t('z2m_light.turn_off.failed') || 'Failed to turn off bulbs', 'error');
}
}
+13
View File
@@ -125,6 +125,8 @@
"templates.error.engines": "Failed to load engines",
"templates.error.required": "Please fill in all required fields",
"templates.error.delete": "Failed to delete template",
"templates.error.save_failed": "Failed to save template",
"templates.error.load_failed": "Failed to load template",
"templates.test.title": "Test Capture",
"templates.test.description": "Test this template before saving to see a capture preview and performance metrics.",
"templates.test.display": "Display:",
@@ -1283,6 +1285,10 @@
"automations.deleted": "Automation deleted",
"automations.error.name_required": "Name is required",
"automations.error.clone_failed": "Failed to clone automation",
"automations.error.load_failed": "Failed to load automation",
"automations.error.save_failed": "Failed to save automation",
"automations.error.delete_failed": "Failed to delete automation",
"automations.error.toggle_failed": "Failed to toggle automation",
"scenes.title": "Scenes",
"scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene",
@@ -1824,6 +1830,8 @@
"audio_template.error.engines": "Failed to load audio engines",
"audio_template.error.required": "Please fill in all required fields",
"audio_template.error.delete": "Failed to delete audio template",
"audio_template.error.save_failed": "Failed to save audio template",
"audio_template.error.load_failed": "Failed to load audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"streams.group.gradients": "Gradients",
@@ -1843,6 +1851,7 @@
"gradient.error.name_required": "Name is required",
"gradient.error.min_stops": "At least 2 color stops are required",
"gradient.error.delete_failed": "Failed to delete gradient",
"gradient.error.save_failed": "Failed to save gradient",
"gradient.create_name": "New gradient name:",
"gradient.edit_name": "Rename gradient:",
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
@@ -2208,6 +2217,7 @@
"device.error.update": "Failed to update device",
"device.error.save": "Failed to save settings",
"device.error.clone_failed": "Failed to clone device",
"device.error.load_failed": "Failed to load device",
"device_discovery.error.fill_all_fields": "Please fill in all fields",
"device_discovery.added": "Device added successfully",
"device_discovery.error.add_failed": "Failed to add device",
@@ -2249,6 +2259,7 @@
"target.error.stop_failed": "Failed to stop target",
"target.error.clone_failed": "Failed to clone target",
"target.error.delete_failed": "Failed to delete target",
"target.error.load_failed": "Failed to load target",
"targets.stop_all.button": "Stop All",
"targets.stop_all.none_running": "No targets are currently running",
"targets.stop_all.stopped": "Stopped {count} target(s)",
@@ -2263,6 +2274,7 @@
"pattern.error.clone_failed": "Failed to clone pattern template",
"pattern.error.delete_failed": "Failed to delete pattern template",
"pattern.error.capture_bg_failed": "Failed to capture background",
"pattern.error.save_failed": "Failed to save pattern template",
"stream.error.clone_picture_failed": "Failed to clone picture source",
"stream.error.clone_capture_failed": "Failed to clone capture template",
"stream.error.clone_pp_failed": "Failed to clone postprocessing template",
@@ -2953,6 +2965,7 @@
"audio_processing.error.load": "Error loading audio processing template",
"audio_processing.error.delete": "Error deleting audio processing template",
"audio_processing.error.clone_failed": "Failed to clone audio processing template",
"audio_processing.error.save_failed": "Failed to save audio processing template",
"audio_processing.filter_count": "Filter count",
"audio_processing.filters_label": "filters",
"streams.group.audio_processing": "Audio Processing",
+14
View File
@@ -180,6 +180,8 @@
"templates.error.engines": "Не удалось загрузить движки",
"templates.error.required": "Пожалуйста, заполните все обязательные поля",
"templates.error.delete": "Не удалось удалить шаблон",
"templates.error.save_failed": "Не удалось сохранить шаблон",
"templates.error.load_failed": "Не удалось загрузить шаблон",
"templates.test.title": "Тест Захвата",
"templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.",
"templates.test.display": "Дисплей:",
@@ -1317,6 +1319,10 @@
"automations.deleted": "Автоматизация удалена",
"automations.error.name_required": "Введите название",
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
"automations.error.load_failed": "Не удалось загрузить автоматизацию",
"automations.error.save_failed": "Не удалось сохранить автоматизацию",
"automations.error.delete_failed": "Не удалось удалить автоматизацию",
"automations.error.toggle_failed": "Не удалось переключить автоматизацию",
"scenes.title": "Сцены",
"scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену",
@@ -1789,6 +1795,10 @@
"audio_template.error.engines": "Не удалось загрузить аудиодвижки",
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"audio_template.error.save_failed": "Не удалось сохранить аудиошаблон",
"audio_template.error.load_failed": "Не удалось загрузить аудиошаблон",
"gradient.error.save_failed": "Не удалось сохранить градиент",
"gradient.error.delete_failed": "Не удалось удалить градиент",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"tree.group.picture": "Источники изображений",
@@ -2067,6 +2077,7 @@
"device.error.update": "Не удалось обновить устройство",
"device.error.save": "Не удалось сохранить настройки",
"device.error.clone_failed": "Не удалось клонировать устройство",
"device.error.load_failed": "Не удалось загрузить устройство",
"device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля",
"device_discovery.added": "Устройство успешно добавлено",
"device_discovery.error.add_failed": "Не удалось добавить устройство",
@@ -2108,6 +2119,7 @@
"target.error.stop_failed": "Не удалось остановить цель",
"target.error.clone_failed": "Не удалось клонировать цель",
"target.error.delete_failed": "Не удалось удалить цель",
"target.error.load_failed": "Не удалось загрузить цель",
"targets.stop_all.button": "Остановить все",
"targets.stop_all.none_running": "Нет запущенных целей",
"targets.stop_all.stopped": "Остановлено целей: {count}",
@@ -2122,6 +2134,7 @@
"pattern.error.clone_failed": "Не удалось клонировать шаблон узоров",
"pattern.error.delete_failed": "Не удалось удалить шаблон узоров",
"pattern.error.capture_bg_failed": "Не удалось захватить фон",
"pattern.error.save_failed": "Не удалось сохранить шаблон узоров",
"stream.error.clone_picture_failed": "Не удалось клонировать источник изображения",
"stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата",
"stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки",
@@ -2634,6 +2647,7 @@
"audio_processing.error.load": "Ошибка загрузки шаблона обработки звука",
"audio_processing.error.delete": "Ошибка удаления шаблона обработки звука",
"audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука",
"audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука",
"audio_processing.filter_count": "Количество фильтров",
"audio_processing.filters_label": "фильтров",
"streams.group.audio_processing": "Обработка звука",
+14
View File
@@ -178,6 +178,8 @@
"templates.error.engines": "加载引擎失败",
"templates.error.required": "请填写所有必填项",
"templates.error.delete": "删除模板失败",
"templates.error.save_failed": "保存模板失败",
"templates.error.load_failed": "加载模板失败",
"templates.test.title": "测试采集",
"templates.test.description": "保存前测试此模板,查看采集预览和性能指标。",
"templates.test.display": "显示器:",
@@ -1313,6 +1315,10 @@
"automations.deleted": "自动化已删除",
"automations.error.name_required": "名称为必填项",
"automations.error.clone_failed": "克隆自动化失败",
"automations.error.load_failed": "加载自动化失败",
"automations.error.save_failed": "保存自动化失败",
"automations.error.delete_failed": "删除自动化失败",
"automations.error.toggle_failed": "切换自动化失败",
"scenes.title": "场景",
"scenes.add": "捕获场景",
"scenes.edit": "编辑场景",
@@ -1785,6 +1791,10 @@
"audio_template.error.engines": "加载音频引擎失败",
"audio_template.error.required": "请填写所有必填项",
"audio_template.error.delete": "删除音频模板失败",
"audio_template.error.save_failed": "保存音频模板失败",
"audio_template.error.load_failed": "加载音频模板失败",
"gradient.error.save_failed": "保存渐变失败",
"gradient.error.delete_failed": "删除渐变失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"tree.group.picture": "图片源",
@@ -2063,6 +2073,7 @@
"device.error.update": "更新设备失败",
"device.error.save": "保存设置失败",
"device.error.clone_failed": "克隆设备失败",
"device.error.load_failed": "加载设备失败",
"device_discovery.error.fill_all_fields": "请填写所有字段",
"device_discovery.added": "设备添加成功",
"device_discovery.error.add_failed": "添加设备失败",
@@ -2104,6 +2115,7 @@
"target.error.stop_failed": "停止目标失败",
"target.error.clone_failed": "克隆目标失败",
"target.error.delete_failed": "删除目标失败",
"target.error.load_failed": "加载目标失败",
"targets.stop_all.button": "全部停止",
"targets.stop_all.none_running": "当前没有运行中的目标",
"targets.stop_all.stopped": "已停止 {count} 个目标",
@@ -2118,6 +2130,7 @@
"pattern.error.clone_failed": "克隆图案模板失败",
"pattern.error.delete_failed": "删除图案模板失败",
"pattern.error.capture_bg_failed": "捕获背景失败",
"pattern.error.save_failed": "保存图案模板失败",
"stream.error.clone_picture_failed": "克隆图片源失败",
"stream.error.clone_capture_failed": "克隆捕获模板失败",
"stream.error.clone_pp_failed": "克隆后处理模板失败",
@@ -2628,6 +2641,7 @@
"audio_processing.error.load": "加载音频处理模板时出错",
"audio_processing.error.delete": "删除音频处理模板时出错",
"audio_processing.error.clone_failed": "克隆音频处理模板失败",
"audio_processing.error.save_failed": "保存音频处理模板失败",
"audio_processing.filter_count": "过滤器数量",
"audio_processing.filters_label": "个过滤器",
"streams.group.audio_processing": "音频处理",