003517247f
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.
619 lines
27 KiB
TypeScript
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;
|