Files
vacuum-wall/webui/static/hoover/api.js
T

241 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hoover — api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* Toast notification system with auto-dismiss.
*/
import { modelFetch } from './model.js?v=8';
import { requestUpdate } from './reactivity.js?v=8';
/**
* JSON-friendly fetch wrapper.
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, …)
* @returns {Promise<{ok, data, error, status}>}
*/
export async function apiFetch(url, options = {}) {
const { method = 'GET', body, ...opts } = options;
const headers = { 'Accept': 'application/json', ...opts.headers };
if (body && typeof body === 'object' && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
if (opts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401) {
window.location.reload();
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await res.json();
if (!res.ok) {
return { ok: false, data: null, error: json.error || `HTTP ${res.status}`, status: res.status };
}
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: res.status };
} catch (e) {
return { ok: false, data: null, error: e.message || 'Network error', status: 0 };
}
}
/** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */
export const _toasts = [];
const _toastIds = { next: 1 };
/**
* Show a toast notification. Auto-dismisses after `duration` ms.
*
* @param {string} message Toast text
* @param {string} [type] 'info' | 'success' | 'error' | 'warning'
* @param {number} [duration] Auto-dismiss timeout in ms (0 = indefinite)
* @returns {number} id
*/
export function toast(message, type = 'info', duration = 4000) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
requestUpdate();
if (duration > 0) setTimeout(() => dismissToast(id), duration);
return id;
}
/**
* Dismiss a toast by id.
*/
export function dismissToast(id) {
const idx = _toasts.findIndex(t => t.id === id);
if (idx !== -1) _toasts.splice(idx, 1);
requestUpdate();
}
/**
* Create an abort-checking function from an AbortController.
*
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
* fetching with abort handling and loading state management.
* @param {AbortController} ac
* @returns {function} () => boolean
*/
export function checkAbort(ac) {
return () => ac?.signal?.aborted || false;
}
/**
* Standard data loading wrapper with state management and abort handling.
*
* Sets loading=true before, loading=false after, tracks errors.
*
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
* fetching with abort handling and loading state management.
* @param {object} state - Reactive state object
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
* @param {function} fetchFn - (state, signal, isAborted) => Promise
* @param {object} [opts] - Additional options
* @param {object} [opts.entry] - Component entry for requestId tracking
* @param {AbortController} [opts.abortController] - Fresh abort controller
*/
export async function refactorLoad(state, dataKey, fetchFn, opts = {}) {
const entry = opts.entry;
const myId = entry ? entry.requestId : 0;
const ab = opts.abortController;
const isAborted = ab ? checkAbort(ab) : () => false;
const signal = ab ? ab.signal : null;
if (entry) {
if (dataKey(state) !== undefined) state.refreshing = true;
else state.loading = true;
}
state.error = null;
try {
await fetchFn(state, signal, isAborted);
} catch (e) {
if (!isAborted()) state.error = e.message || 'Request failed';
} finally {
if (!isAborted()) {
if (entry) {
state.loading = false;
state.refreshing = false;
}
}
}
}
/**
* Poll a URL until success or error condition is met.
*
* @param {object} opts
* @param {string} opts.url - URL to poll
* @param {function} opts.successKey - (data) => boolean, when true poll succeeds
* @param {function} opts.onErrorKey - (data) => boolean, when true poll fails
* @param {function} [opts.onComplete] - (data) => void, called on success
* @param {function} [opts.onError] - (data) => void, called on failure
* @param {number} [opts.interval] - Poll interval in ms (default: 3000)
* @param {number} [opts.timeout] - Overall timeout in ms (default: 60000)
*/
export async function poll(opts) {
const {
url,
successKey,
onErrorKey,
onComplete,
onError,
interval = 3000,
timeout = 60000,
} = opts;
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > timeout) {
clearInterval(timer);
if (onError) onError(null);
return;
}
const res = await apiFetch(url);
if (!res.ok) {
clearInterval(timer);
if (onError) onError(res);
return;
}
if (successKey(res.data)) {
clearInterval(timer);
if (onComplete) onComplete(res.data);
} else if (onErrorKey(res.data)) {
clearInterval(timer);
if (onError) onError(res.data);
}
}, interval);
}
/**
* Generate action button descriptors for modal form submission.
*
* Returns an array of action descriptors that can be spread into the
* actions array passed to formModal. First item is the submit button.
*
* @param {object} opts
* @param {string} opts.url - API URL to POST/PUT to
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {string} [opts.successMsg] - Success toast message
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
export function apiSubmit(opts) {
const {
url,
method = 'POST',
body,
validate,
successMsg = 'Saved',
refresh,
submitText = 'Submit',
closeModal,
} = opts;
return [
{
label: submitText,
cls: 'btn-primary',
action: 's',
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;
let msg = successMsg;
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
}
} else {
toast(res.error || 'Failed', 'error');
}
},
},
];
}