/** * HTTP Endpoints — CRUD, test, cards. * * An HTTP endpoint is a connection definition only (URL + auth + * headers + timeout). Polling cadence is owned by HTTPValueSource — * one endpoint can back many value sources at different intervals. * * Structurally mirrors `home-assistant-sources.ts` (HA-source CRUD UI): * register icon adapter → modal subclass with dirty-check → save/edit/ * clone/delete handlers → card builder → event delegation. */ import { _cachedHTTPEndpoints, httpEndpointsCache, } from '../core/state.ts'; import { fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { ICON_EDIT, ICON_TRASH, ICON_EYE, ICON_EYE_OFF, ICON_TEST, ICON_OK, ICON_WARNING, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { makeCardIconFields } from '../core/card-icon.ts'; import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; import { IconSelect } from '../core/icon-select.ts'; import type { HTTPEndpoint, HTTPEndpointWritePayload, HTTPMethod, HTTPTestResponse } from '../types.ts'; registerIconEntityType('http_endpoint', makeSimpleIconAdapter({ cache: httpEndpointsCache, endpointPrefix: '/http/endpoints', reload: async () => { if (typeof window.loadIntegrations === 'function') { await window.loadIntegrations(); } }, typeLabelKey: 'device.icon.entity.http_endpoint', typeLabelFallback: 'HTTP endpoint', cardSelectors: (id) => [ `[data-card-section="http-endpoints"] [data-id="${CSS.escape(id)}"]`, ], })); const ICON_HTTP = `${P.globe}`; // ── Modal ───────────────────────────────────────────────────── let _httpTagsInput: TagInput | null = null; let _httpMethodIconSelect: IconSelect | null = null; /** In-memory headers; mirrored into hidden snapshot field. */ let _httpHeaders: Array<{ name: string; value: string }> = []; class HTTPEndpointModal extends Modal { constructor() { super('http-endpoint-modal'); } onForceClose() { if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; } if (_httpMethodIconSelect) { _httpMethodIconSelect.destroy(); _httpMethodIconSelect = null; } _httpHeaders = []; const out = document.getElementById('http-endpoint-test-output'); if (out) { out.innerHTML = ''; out.style.display = 'none'; } } snapshotValues() { // Headers are compared as a sorted snapshot so the dirty-check is // immune to a backend serialization that emits keys in a different // order than the user originally entered them (false-positive // "discard changes?" on reopen of multi-header endpoints). const headersSnapshot = [..._httpHeaders] .sort((a, b) => a.name.localeCompare(b.name)); return { name: (document.getElementById('http-endpoint-name') as HTMLInputElement).value, url: (document.getElementById('http-endpoint-url') as HTMLInputElement).value, method: (document.getElementById('http-endpoint-method') as HTMLSelectElement).value, auth_token: (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value, timeout_s: (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value, description: (document.getElementById('http-endpoint-description') as HTMLInputElement).value, headers: JSON.stringify(headersSnapshot), tags: JSON.stringify(_httpTagsInput ? _httpTagsInput.getValue() : []), }; } } const httpEndpointModal = new HTTPEndpointModal(); // ── Method IconSelect (GET / HEAD) ──────────────────────────── function _ensureMethodIconSelect() { const sel = document.getElementById('http-endpoint-method') as HTMLSelectElement | null; if (!sel) return; const items = [ { value: 'GET', icon: `${P.download}`, label: 'GET', desc: t('http_endpoint.method.get.desc'), }, { value: 'HEAD', icon: `${P.listChecks}`, label: 'HEAD', desc: t('http_endpoint.method.head.desc'), }, ]; if (_httpMethodIconSelect) { _httpMethodIconSelect.updateItems(items); return; } _httpMethodIconSelect = new IconSelect({ target: sel, items, columns: 2 }); } // ── Headers list (key/value rows) ───────────────────────────── // Visual model mirrors `.group-child-row` (cards.css): bordered rows on // `--bg-color` with a subtle hover ring, inputs share the same height // and rounded corners, trash button on the right. Empty state matches // the `.pp-filter-empty` dashed-border pattern. function _renderHeaderRows() { const list = document.getElementById('http-endpoint-headers-list'); if (!list) return; if (_httpHeaders.length === 0) { list.innerHTML = `
${escapeHtml(t('http_endpoint.headers.empty'))}
`; return; } list.innerHTML = _httpHeaders.map((h, idx) => `
`).join(''); list.querySelectorAll('.http-header-name').forEach((inp) => { inp.addEventListener('input', () => { const row = inp.closest('.http-header-row')!; const idx = parseInt(row.dataset.idx || '0', 10); if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value; }); }); list.querySelectorAll('.http-header-value').forEach((inp) => { inp.addEventListener('input', () => { const row = inp.closest('.http-header-row')!; const idx = parseInt(row.dataset.idx || '0', 10); if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value; }); }); list.querySelectorAll('.http-header-remove').forEach((btn) => { btn.addEventListener('click', () => { const row = btn.closest('.http-header-row')!; const idx = parseInt(row.dataset.idx || '0', 10); _httpHeaders.splice(idx, 1); _renderHeaderRows(); }); }); } export function addHTTPEndpointHeader() { _httpHeaders.push({ name: '', value: '' }); _renderHeaderRows(); // Focus the newest row so the user can immediately start typing. const list = document.getElementById('http-endpoint-headers-list'); if (list) { const names = list.querySelectorAll('.http-header-name'); names[names.length - 1]?.focus(); } } // ── Show / Close ────────────────────────────────────────────── export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise { const isEdit = !!editData; const titleKey = isEdit ? 'http_endpoint.edit' : 'http_endpoint.add'; document.getElementById('http-endpoint-modal-title')!.innerHTML = `${ICON_HTTP} ${t(titleKey)}`; (document.getElementById('http-endpoint-id') as HTMLInputElement).value = editData?.id || ''; (document.getElementById('http-endpoint-error') as HTMLElement).style.display = 'none'; const nameInput = document.getElementById('http-endpoint-name') as HTMLInputElement; const urlInput = document.getElementById('http-endpoint-url') as HTMLInputElement; const methodSel = document.getElementById('http-endpoint-method') as HTMLSelectElement; const tokenInput = document.getElementById('http-endpoint-auth-token') as HTMLInputElement; const timeoutInput = document.getElementById('http-endpoint-timeout') as HTMLInputElement; const descInput = document.getElementById('http-endpoint-description') as HTMLInputElement; nameInput.value = editData?.name || ''; urlInput.value = editData?.url || ''; methodSel.value = editData?.method || 'GET'; tokenInput.value = ''; // never expose stored token tokenInput.type = 'password'; timeoutInput.value = String(editData?.timeout_s ?? 10); descInput.value = editData?.description || ''; // Headers: snapshot from data into our editable buffer _httpHeaders = editData?.headers ? Object.entries(editData.headers).map(([name, value]) => ({ name, value })) : []; _renderHeaderRows(); _ensureMethodIconSelect(); if (_httpMethodIconSelect) _httpMethodIconSelect.setValue(editData?.method || 'GET'); // Show "leave blank to keep" hint only when editing an endpoint // that already has a token configured. const tokenHint = document.getElementById('http-endpoint-token-hint'); if (tokenHint) tokenHint.style.display = (isEdit && editData?.auth_token_set) ? '' : 'none'; // Reveal password toggle const revealBtn = document.getElementById('http-endpoint-token-reveal'); if (revealBtn) revealBtn.innerHTML = ICON_EYE; // Inject icon into the inline Test button const testBtnIcon = document.querySelector('#http-endpoint-test-btn .http-endpoint-test-btn-icon'); if (testBtnIcon) testBtnIcon.innerHTML = ICON_TEST; // Reset test output const out = document.getElementById('http-endpoint-test-output'); if (out) { out.innerHTML = ''; out.style.display = 'none'; } // Tags if (_httpTagsInput) { _httpTagsInput.destroy(); _httpTagsInput = null; } _httpTagsInput = new TagInput( document.getElementById('http-endpoint-tags-container'), { placeholder: t('tags.placeholder') } ); _httpTagsInput.setValue(isEdit ? (editData?.tags || []) : []); httpEndpointModal.open(); httpEndpointModal.snapshot(); } export async function closeHTTPEndpointModal(): Promise { await httpEndpointModal.close(); } export function toggleHTTPEndpointTokenVisibility() { const inp = document.getElementById('http-endpoint-auth-token') as HTMLInputElement; const btn = document.getElementById('http-endpoint-token-reveal'); if (!inp || !btn) return; if (inp.type === 'password') { inp.type = 'text'; btn.innerHTML = ICON_EYE_OFF; } else { inp.type = 'password'; btn.innerHTML = ICON_EYE; } } // ── Header collection (commits live values to buffer) ─ function _collectHeaders(): Record { // Pull current input values to be safe (in case of paste / IME). const list = document.getElementById('http-endpoint-headers-list'); if (list) { list.querySelectorAll('.http-header-row').forEach((row) => { const idx = parseInt(row.dataset.idx || '0', 10); const name = (row.querySelector('.http-header-name') as HTMLInputElement)?.value || ''; const value = (row.querySelector('.http-header-value') as HTMLInputElement)?.value || ''; if (_httpHeaders[idx]) { _httpHeaders[idx].name = name; _httpHeaders[idx].value = value; } }); } const out: Record = {}; for (const h of _httpHeaders) { const name = h.name.trim(); if (!name) continue; out[name] = h.value; } return out; } // ── Save ────────────────────────────────────────────────────── export async function saveHTTPEndpoint(): Promise { const id = (document.getElementById('http-endpoint-id') as HTMLInputElement).value; if (httpEndpointModal.closeIfPristine(id)) return; const name = (document.getElementById('http-endpoint-name') as HTMLInputElement).value.trim(); const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim(); const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod; const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value; const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value; const description = (document.getElementById('http-endpoint-description') as HTMLInputElement).value.trim() || undefined; const headers = _collectHeaders(); const timeout_s = parseFloat(timeoutRaw); if (!name) { httpEndpointModal.showError(t('http_endpoint.error.name_required')); return; } if (!url) { httpEndpointModal.showError(t('http_endpoint.error.url_required')); return; } if (isNaN(timeout_s) || timeout_s <= 0) { httpEndpointModal.showError(t('http_endpoint.error.timeout_invalid')); return; } const payload: HTTPEndpointWritePayload = { name, url, method, headers, timeout_s, description, tags: _httpTagsInput ? _httpTagsInput.getValue() : [], }; // Auth token semantics (per backend schema): // POST — empty string means "no token" // PUT new — non-empty replaces; empty string CLEARS; omit to KEEP existing. // For PUT we omit the field unless the user typed something so we don't // accidentally clear a previously-configured token. if (!id) { payload.auth_token = token; } else if (token) { payload.auth_token = token; } 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}`); } showToast(t(id ? 'http_endpoint.updated' : 'http_endpoint.created'), 'success'); httpEndpointModal.forceClose(); httpEndpointsCache.invalidate(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); } catch (e: any) { if (e.isAuth) return; httpEndpointModal.showError(e.message); } } // ── Edit / Clone / Delete ───────────────────────────────────── export async function editHTTPEndpoint(endpointId: string): Promise { try { const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); if (!resp.ok) throw new Error(t('http_endpoint.error.load')); const data: HTTPEndpoint = await resp.json(); await showHTTPEndpointModal(data); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function cloneHTTPEndpoint(endpointId: string): Promise { try { const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`); if (!resp.ok) throw new Error(t('http_endpoint.error.load')); const data = await resp.json(); delete data.id; data.name = data.name + ' (copy)'; // Cloning never reveals the token — user must re-enter if needed. data.auth_token_set = false; await showHTTPEndpointModal(data); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function deleteHTTPEndpoint(endpointId: string): Promise { const confirmed = await showConfirm(t('http_endpoint.delete.confirm')); if (!confirmed) return; try { const resp = await fetchWithAuth(`/http/endpoints/${endpointId}`, { method: 'DELETE' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } showToast(t('http_endpoint.deleted'), 'success'); httpEndpointsCache.invalidate(); if (typeof window.loadIntegrations === 'function') await window.loadIntegrations(); } catch (e: any) { if (e.isAuth) return; showToast(e.message, 'error'); } } // ── Test (in-form, pre-save) ────────────────────────────────── /** Builds a `HTTPTestRequest` from the live form values and renders * the response inline. Works for both new and edit modes. */ export async function testHTTPEndpoint(): Promise { const url = (document.getElementById('http-endpoint-url') as HTMLInputElement).value.trim(); const method = (document.getElementById('http-endpoint-method') as HTMLSelectElement).value as HTTPMethod; const token = (document.getElementById('http-endpoint-auth-token') as HTMLInputElement).value; const timeoutRaw = (document.getElementById('http-endpoint-timeout') as HTMLInputElement).value; const headers = _collectHeaders(); const timeout_s = parseFloat(timeoutRaw); const out = document.getElementById('http-endpoint-test-output'); if (!out) return; // Render validation errors inline next to the Test button — the // modal-level banner at the top of the form is invisible from here. const renderValidationFail = (msg: string): void => { out.style.display = ''; out.innerHTML = `
${escapeHtml(t('http_endpoint.test.failed'))} ${escapeHtml(msg)}
`; }; if (!url) { renderValidationFail(t('http_endpoint.error.url_required')); return; } if (isNaN(timeout_s) || timeout_s <= 0) { renderValidationFail(t('http_endpoint.error.timeout_invalid')); return; } const testBtn = document.getElementById('http-endpoint-test-btn'); if (testBtn) testBtn.classList.add('loading'); out.style.display = ''; out.innerHTML = `
${escapeHtml(t('http_endpoint.test.pending'))}
`; 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(); _renderTestResult(out, data); } catch (e: any) { if (e.isAuth) return; out.innerHTML = _renderTestErrorHtml(e.message); } finally { if (testBtn) testBtn.classList.remove('loading'); } } function _renderTestErrorHtml(message: string): string { return `
${ICON_WARNING}${escapeHtml(t('http_endpoint.test.failed'))}
${escapeHtml(message)}
`; } function _renderTestResult(out: HTMLElement, data: HTTPTestResponse) { const statusClass = data.success ? 'http-test-ok' : 'http-test-fail'; const badgeClass = data.success ? 'http-test-badge-ok' : 'http-test-badge-fail'; const badgeIcon = data.success ? ICON_OK : ICON_WARNING; const badgeText = data.success ? t('http_endpoint.test.success') : t('http_endpoint.test.failed'); const statusLine = data.status_code != null ? `${data.status_code}` : ''; const errLine = data.error && !data.success ? `${escapeHtml(data.error)}` : ''; // Show JSON body when available — easier to copy a json_path from than raw text. let bodyHtml = ''; let bodyLabel = ''; if (data.body_json != null) { let pretty: string; try { pretty = JSON.stringify(data.body_json, null, 2); } catch { pretty = String(data.body_json); } bodyLabel = `
${escapeHtml(t('http_endpoint.test.body.json'))}
`; bodyHtml = `
${escapeHtml(pretty)}
`; } else if (data.body_preview) { bodyLabel = `
${escapeHtml(t('http_endpoint.test.body.text'))}
`; bodyHtml = `
${escapeHtml(data.body_preview)}
`; } out.innerHTML = `
${badgeIcon}${escapeHtml(badgeText)} ${statusLine}
${errLine} ${bodyLabel} ${bodyHtml}
`; } // ── Card-level test (uses stored config, no form needed) ────── async function _testHTTPEndpointFromCard(endpointId: string): Promise { try { const resp = await fetchWithAuth(`/http/endpoints/${endpointId}/test`, { method: 'POST' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } const data: HTTPTestResponse = await resp.json(); if (data.success) { const status = data.status_code != null ? ` (${data.status_code})` : ''; showToast(`${t('http_endpoint.test.success')}${status}`, 'success'); } else { const detail = data.error || `HTTP ${data.status_code ?? '?'}`; showToast(`${t('http_endpoint.test.failed')}: ${detail}`, 'error'); } } catch (e: any) { if (e.isAuth) return; showToast(`${t('http_endpoint.test.failed')}: ${e.message}`, 'error'); } } // ── Card rendering ──────────────────────────────────────────── export function createHTTPEndpointCard(endpoint: HTTPEndpoint) { const hasAuth = !!endpoint.auth_token_set; const headerCount = Object.keys(endpoint.headers || {}).length; const leds: LedState[] = ['on']; const chips: ModChipOpts[] = [ { icon: `${P.globe}`, text: endpoint.url, title: endpoint.url, }, { icon: `${P.refreshCw}`, text: endpoint.method, }, ]; if (hasAuth) { chips.push({ icon: `${P.lock}`, text: t('http_endpoint.auth.set'), }); } if (headerCount > 0) { chips.push({ icon: `${P.listChecks}`, text: t('http_endpoint.headers.count').replace('{n}', String(headerCount)), }); } const mod: ModCardOpts = { head: { badge: { text: 'HTTP · ENDPOINT' }, name: endpoint.name, metaHtml: escapeHtml(endpoint.url), leds, ...makeCardIconFields('http_endpoint', endpoint.id, endpoint), menu: { duplicateOnclick: `cloneHTTPEndpoint('${endpoint.id}')`, hideOnclick: `toggleCardHidden('http-endpoints','${endpoint.id}')`, deleteOnclick: `deleteHTTPEndpoint('${endpoint.id}')`, }, }, body: { desc: endpoint.description || undefined, chips, }, foot: { patchState: 'idle', patchLabel: `${endpoint.method} · ${Math.round(endpoint.timeout_s)}s`, iconActions: [ { icon: ICON_TEST, onclick: '', title: t('http_endpoint.test'), dataAttrs: { 'data-action': 'test' } }, { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, ], }, running: false, }; const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: endpoint.id, mod }); const tagsHtml = renderTagChips(endpoint.tags); return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } // ── Event delegation ────────────────────────────────────────── const _httpEndpointActions: Record void> = { clone: cloneHTTPEndpoint, edit: editHTTPEndpoint, test: _testHTTPEndpointFromCard, }; export function initHTTPEndpointDelegation(container: HTMLElement): void { container.addEventListener('click', (e: MouseEvent) => { const btn = (e.target as HTMLElement).closest('[data-action]'); if (!btn) return; const section = btn.closest('[data-card-section="http-endpoints"]'); if (!section) return; const card = btn.closest('[data-id]'); if (!card) return; const action = btn.dataset.action; const id = card.getAttribute('data-id'); if (!action || !id) return; const handler = _httpEndpointActions[action]; if (handler) { e.stopPropagation(); handler(id); } }); } // ── Expose to global scope for HTML onclick handlers ────────── window.showHTTPEndpointModal = showHTTPEndpointModal; window.closeHTTPEndpointModal = closeHTTPEndpointModal; window.saveHTTPEndpoint = saveHTTPEndpoint; window.editHTTPEndpoint = editHTTPEndpoint; window.cloneHTTPEndpoint = cloneHTTPEndpoint; window.deleteHTTPEndpoint = deleteHTTPEndpoint; window.testHTTPEndpoint = testHTTPEndpoint; window.addHTTPEndpointHeader = addHTTPEndpointHeader; window.toggleHTTPEndpointTokenVisibility = toggleHTTPEndpointTokenVisibility;