Files
ledgrab/server/src/ledgrab/static/js/features/http-endpoints.ts
T
alexei.dolgolyov 003517247f refactor(types): migrate (window as any) statics to typed window globals
59 sites across 19 feature modules switched from
`(window as any).foo` to the typed `window.foo` form against
global-types.d.ts. The 7 remaining sites use dynamic string indexing
(`window[fnName]`) where a typed access is impossible — those keep
the narrow cast and are documented as the legitimate exception in
the typedef file's header.

global-types.d.ts grows entries for `loadIntegrations`,
`loadUpdateSettings`, `loadUpdateStatus`, `initUpdateSettingsPanel`,
`onTestDisplaySelected`, `openSettingsModal`, `renderAboutPanel`,
`switchSettingsTab`. The `applyAccentColor` signature is widened to
accept the (accent, persist) call shape observed at the appearance
preset call site so tsc validates the real contract.
2026-05-23 01:22:29 +03:00

619 lines
27 KiB
TypeScript

/**
* 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<HTTPEndpoint>({
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 = `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`;
// ── 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: `<svg class="icon" viewBox="0 0 24 24">${P.download}</svg>`,
label: 'GET',
desc: t('http_endpoint.method.get.desc'),
},
{
value: 'HEAD',
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
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 = `<div class="http-headers-empty">${escapeHtml(t('http_endpoint.headers.empty'))}</div>`;
return;
}
list.innerHTML = _httpHeaders.map((h, idx) => `
<div class="http-header-row" data-idx="${idx}">
<span class="http-header-index" aria-hidden="true">${idx + 1}</span>
<div class="http-header-fields">
<input type="text" class="http-header-name" placeholder="${escapeHtml(t('http_endpoint.headers.name_placeholder'))}" value="${escapeHtml(h.name)}" spellcheck="false" autocomplete="off">
<input type="text" class="http-header-value" placeholder="${escapeHtml(t('http_endpoint.headers.value_placeholder'))}" value="${escapeHtml(h.value)}" spellcheck="false" autocomplete="off">
</div>
<button type="button" class="btn btn-icon btn-secondary http-header-remove" title="${escapeHtml(t('common.delete'))}" aria-label="${escapeHtml(t('common.delete'))}">${ICON_TRASH}</button>
</div>
`).join('');
list.querySelectorAll<HTMLInputElement>('.http-header-name').forEach((inp) => {
inp.addEventListener('input', () => {
const row = inp.closest<HTMLElement>('.http-header-row')!;
const idx = parseInt(row.dataset.idx || '0', 10);
if (_httpHeaders[idx]) _httpHeaders[idx].name = inp.value;
});
});
list.querySelectorAll<HTMLInputElement>('.http-header-value').forEach((inp) => {
inp.addEventListener('input', () => {
const row = inp.closest<HTMLElement>('.http-header-row')!;
const idx = parseInt(row.dataset.idx || '0', 10);
if (_httpHeaders[idx]) _httpHeaders[idx].value = inp.value;
});
});
list.querySelectorAll<HTMLButtonElement>('.http-header-remove').forEach((btn) => {
btn.addEventListener('click', () => {
const row = btn.closest<HTMLElement>('.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<HTMLInputElement>('.http-header-name');
names[names.length - 1]?.focus();
}
}
// ── Show / Close ──────────────────────────────────────────────
export async function showHTTPEndpointModal(editData: HTTPEndpoint | null = null): Promise<void> {
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<void> {
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 <input> values to buffer) ─
function _collectHeaders(): Record<string, string> {
// Pull current input values to be safe (in case of paste / IME).
const list = document.getElementById('http-endpoint-headers-list');
if (list) {
list.querySelectorAll<HTMLElement>('.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<string, string> = {};
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<void> {
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<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();
await showHTTPEndpointModal(data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
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);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
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}`);
}
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<void> {
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 = `<div class="http-test-result http-test-fail">
<span class="http-test-badge http-test-badge-fail">${escapeHtml(t('http_endpoint.test.failed'))}</span>
<code class="http-test-error">${escapeHtml(msg)}</code>
</div>`;
};
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 = `<div class="http-test-pending">
<span class="http-test-pending-spinner" aria-hidden="true"></span>
<span>${escapeHtml(t('http_endpoint.test.pending'))}</span>
</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();
_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 `<div class="http-test-result http-test-fail">
<div class="http-test-line">
<span class="http-test-badge http-test-badge-fail">${ICON_WARNING}<span>${escapeHtml(t('http_endpoint.test.failed'))}</span></span>
</div>
<code class="http-test-error">${escapeHtml(message)}</code>
</div>`;
}
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
? `<span class="http-test-status" title="HTTP ${data.status_code}">${data.status_code}</span>`
: '';
const errLine = data.error && !data.success
? `<code class="http-test-error">${escapeHtml(data.error)}</code>`
: '';
// 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 = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.json'))}</div>`;
bodyHtml = `<pre class="http-test-body">${escapeHtml(pretty)}</pre>`;
} else if (data.body_preview) {
bodyLabel = `<div class="http-test-body-label">${escapeHtml(t('http_endpoint.test.body.text'))}</div>`;
bodyHtml = `<pre class="http-test-body">${escapeHtml(data.body_preview)}</pre>`;
}
out.innerHTML = `<div class="http-test-result ${statusClass}">
<div class="http-test-line">
<span class="http-test-badge ${badgeClass}">${badgeIcon}<span>${escapeHtml(badgeText)}</span></span>
${statusLine}
</div>
${errLine}
${bodyLabel}
${bodyHtml}
</div>`;
}
// ── Card-level test (uses stored config, no form needed) ──────
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();
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: `<svg class="icon" viewBox="0 0 24 24">${P.globe}</svg>`,
text: endpoint.url,
title: endpoint.url,
},
{
icon: `<svg class="icon" viewBox="0 0 24 24">${P.refreshCw}</svg>`,
text: endpoint.method,
},
];
if (hasAuth) {
chips.push({
icon: `<svg class="icon" viewBox="0 0 24 24">${P.lock}</svg>`,
text: t('http_endpoint.auth.set'),
});
}
if (headerCount > 0) {
chips.push({
icon: `<svg class="icon" viewBox="0 0 24 24">${P.listChecks}</svg>`,
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}</div>`) : cardHtml;
}
// ── Event delegation ──────────────────────────────────────────
const _httpEndpointActions: Record<string, (id: string) => 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<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="http-endpoints"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[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;