Files
mediamtx/internal/api/admin/app.js
T
QiuSW 9068712bb1
lint / go (push) Canceled after 0s
lint / go_mod (push) Canceled after 0s
lint / conf (push) Canceled after 0s
lint / docslinks (push) Canceled after 0s
lint / docsorder (push) Canceled after 0s
lint / apidocs (push) Canceled after 0s
lint / other (push) Canceled after 0s
test / test_64 (push) Canceled after 0s
test / test_32 (push) Canceled after 0s
test / test_e2e (push) Canceled after 0s
feat(admin): add complete device pagination (#6)
2026-08-28 16:48:17 +08:00

890 lines
31 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.
(() => {
"use strict";
const ListTools = window.MediaMTXAdminList;
const API_PAGE_SIZE = 100;
const API_PAGE_CONCURRENCY = 4;
const API = {
info: "/v3/info",
configs: "/v3/config/paths/list",
paths: "/v3/paths/list",
global: "/v3/config/global/get",
};
const AUTH_STORAGE_KEY = "mediamtx.admin.authorization";
const state = {
configs: [],
runtime: new Map(),
global: {},
editingName: null,
deletingName: null,
previewName: null,
previewProtocol: "webrtc",
loading: false,
toastTimer: null,
authorization: "",
currentPage: 1,
pageSize: 50,
};
const elements = {};
class APIError extends Error {
constructor(status, message) {
super(message);
this.name = "APIError";
this.status = status;
}
}
function byID(id) {
return document.getElementById(id);
}
function create(tag, options = {}) {
const node = document.createElement(tag);
if (options.className) node.className = options.className;
if (options.text !== undefined) node.textContent = String(options.text);
if (options.title) node.title = options.title;
return node;
}
function redactSensitiveText(value) {
return String(value || "").replace(
/(rtsps?:\/\/)([^\s/:@]+):([^\s/@]+)@/gi,
"$1$2:••••@",
);
}
function readStoredAuthorization() {
try {
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) || "";
} catch (_) {
return "";
}
}
function saveAuthorization(value) {
state.authorization = value;
try {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, value);
} catch (_) {
// The current tab can still stay authenticated when storage is unavailable.
}
}
function clearAuthorization() {
state.authorization = "";
try {
window.sessionStorage.removeItem(AUTH_STORAGE_KEY);
} catch (_) {
// Nothing else is required when storage is unavailable.
}
}
function basicAuthorization(username, password) {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${window.btoa(binary)}`;
}
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body !== undefined) headers.set("Content-Type", "application/json");
const authorization = Object.prototype.hasOwnProperty.call(options, "authorization")
? options.authorization
: state.authorization;
if (authorization) headers.set("Authorization", authorization);
const requestOptions = { ...options };
const handleUnauthorized = requestOptions.handleUnauthorized !== false;
delete requestOptions.authorization;
delete requestOptions.handleUnauthorized;
let response;
try {
response = await fetch(url, {
...requestOptions,
headers,
cache: "no-store",
credentials: "same-origin",
});
} catch (error) {
throw new APIError(0, "无法连接 MediaMTX Control API。请检查服务和网络连接。");
}
if (!response.ok) {
let detail = "";
try {
const payload = await response.json();
detail = typeof payload.error === "string" ? redactSensitiveText(payload.error) : "";
} catch (_) {
detail = "";
}
if (response.status === 401) {
if (handleUnauthorized && state.authorization) {
endSession("登录已失效,请重新登录。");
}
throw new APIError(401, "认证失败。请使用具有 action: api 权限的凭据重新访问此页面。");
}
if (response.status === 403) {
throw new APIError(403, "当前凭据没有修改路径的 action: api 权限。");
}
throw new APIError(response.status, detail || `请求失败(HTTP ${response.status})。`);
}
if (response.status === 204) return null;
const contentType = response.headers.get("Content-Type") || "";
return contentType.includes("json") ? response.json() : null;
}
function maskSource(raw) {
try {
const parsed = new URL(raw);
const username = parsed.username ? decodeURIComponent(parsed.username) : "";
const auth = username
? `${username}${parsed.password ? ":••••" : ""}@`
: "";
return `${parsed.protocol}//${auth}${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch (_) {
return "RTSP 地址不可解析";
}
}
function scrubConfig(item) {
return {
name: String(item.name || ""),
sourceMasked: maskSource(String(item.source || "")),
transport: ["automatic", "tcp", "udp"].includes(item.rtspTransport)
? item.rtspTransport
: "automatic",
sourceOnDemand: Boolean(item.sourceOnDemand),
};
}
function isRTSPSource(item) {
const source = String(item.source || "").toLowerCase();
return source.startsWith("rtsp://") || source.startsWith("rtsps://");
}
function getPathState(config) {
const runtime = state.runtime.get(config.name);
if (runtime?.available) {
return { key: "online", label: "在线", className: "status-online" };
}
if (config.sourceOnDemand) {
return { key: "idle", label: "待请求", className: "status-idle" };
}
return { key: "offline", label: "离线", className: "status-offline" };
}
function readerCount(name) {
const readers = state.runtime.get(name)?.readers;
return Array.isArray(readers) ? readers.length : 0;
}
function setAPIStatus(kind, text) {
elements.apiStatus.className = `status-pill status-${kind}`;
elements.apiStatus.textContent = text;
}
function showLogin(message = "") {
elements.appView.hidden = true;
elements.loginView.hidden = false;
elements.loginError.textContent = message;
elements.loginUsername.removeAttribute("aria-invalid");
elements.loginPassword.removeAttribute("aria-invalid");
elements.loginPassword.value = "";
elements.loginPassword.type = "password";
elements.loginPasswordToggle.textContent = "显示";
elements.loginPasswordToggle.setAttribute("aria-pressed", "false");
elements.skipLink.href = "#login-main";
window.setTimeout(() => elements.loginUsername.focus(), 0);
}
function showApp() {
elements.loginView.hidden = true;
elements.appView.hidden = false;
elements.loginError.textContent = "";
elements.skipLink.href = "#main-content";
window.setTimeout(() => elements.mainContent.focus(), 0);
}
function endSession(message = "") {
clearAuthorization();
document.querySelectorAll("dialog[open]").forEach((dialog) => dialog.close());
elements.previewFrame.src = "about:blank";
showLogin(message);
}
function togglePassword(input, button) {
const showing = input.type === "text";
input.type = showing ? "password" : "text";
button.textContent = showing ? "显示" : "隐藏";
button.setAttribute("aria-pressed", String(!showing));
input.focus();
}
async function submitLogin(event) {
event.preventDefault();
const username = elements.loginUsername.value.trim();
const password = elements.loginPassword.value;
elements.loginError.textContent = "";
elements.loginUsername.removeAttribute("aria-invalid");
elements.loginPassword.removeAttribute("aria-invalid");
if (!username) {
elements.loginUsername.setAttribute("aria-invalid", "true");
elements.loginError.textContent = "请输入用户名。";
elements.loginUsername.focus();
return;
}
if (!password) {
elements.loginPassword.setAttribute("aria-invalid", "true");
elements.loginError.textContent = "请输入密码。";
elements.loginPassword.focus();
return;
}
setButtonBusy(elements.loginSubmit, true, "正在登录…", "登录");
try {
const authorization = basicAuthorization(username, password);
await apiFetch(API.info, { authorization, handleUnauthorized: false });
saveAuthorization(authorization);
showApp();
await loadData();
} catch (error) {
const apiError = error instanceof APIError ? error : new APIError(0, String(error));
elements.loginPassword.setAttribute("aria-invalid", "true");
elements.loginError.textContent = apiError.status === 401
? "用户名、密码或 API 权限不正确。"
: apiError.message;
elements.loginPassword.select();
} finally {
setButtonBusy(elements.loginSubmit, false, "", "登录");
}
}
async function restoreSession() {
const authorization = readStoredAuthorization();
if (!authorization) {
showLogin();
return;
}
state.authorization = authorization;
setButtonBusy(elements.loginSubmit, true, "正在验证…", "登录");
try {
await apiFetch(API.info, { handleUnauthorized: false });
showApp();
await loadData();
} catch (error) {
clearAuthorization();
const apiError = error instanceof APIError ? error : new APIError(0, String(error));
showLogin(apiError.status === 401 ? "登录已失效,请重新登录。" : apiError.message);
} finally {
setButtonBusy(elements.loginSubmit, false, "", "登录");
}
}
function showPageError(error) {
elements.pageAlert.hidden = false;
elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法完整加载设备";
elements.pageAlertMessage.textContent = error.message;
setAPIStatus("error", error.status === 403 ? "API 权限不足" : "API 连接失败");
}
function hidePageError() {
elements.pageAlert.hidden = true;
elements.pageAlertTitle.textContent = "";
elements.pageAlertMessage.textContent = "";
}
function setLoading(loading) {
state.loading = loading;
elements.loadingState.hidden = !loading;
elements.refreshButton.disabled = loading;
if (loading) {
elements.pathsContent.hidden = true;
elements.emptyState.hidden = true;
elements.loadingText.textContent = "正在加载设备数据…";
setAPIStatus("loading", "正在连接 API");
}
}
function normalizeAPIError(error) {
if (error instanceof APIError) return error;
const status = Number.isInteger(error?.status) ? error.status : 0;
return new APIError(status, redactSensitiveText(error?.message || String(error)));
}
function loadingProgressReporter() {
const resources = new Map();
return (progress) => {
if (!state.loading) return;
resources.set(progress.label, progress);
let loaded = 0;
let total = 0;
for (const item of resources.values()) {
loaded += item.loaded;
total += item.total;
}
const message = total > 0
? `正在分批加载数据 ${Math.min(loaded, total)} / ${total}…`
: "正在加载设备数据…";
elements.loadingText.textContent = message;
setAPIStatus("loading", total > 0 ? `正在加载 ${Math.min(loaded, total)}/${total}` : "正在连接 API");
};
}
async function loadData() {
if (state.loading) return;
setLoading(true);
hidePageError();
try {
const reportProgress = loadingProgressReporter();
const pagedFetch = ListTools.limitConcurrency(apiFetch, API_PAGE_CONCURRENCY);
const [configData, pathData, globalData] = await Promise.all([
ListTools.fetchAllPages(pagedFetch, API.configs, {
label: "设备配置",
pageSize: API_PAGE_SIZE,
concurrency: API_PAGE_CONCURRENCY,
onProgress: reportProgress,
}),
ListTools.fetchAllPages(pagedFetch, API.paths, {
label: "运行状态",
pageSize: API_PAGE_SIZE,
concurrency: API_PAGE_CONCURRENCY,
onProgress: reportProgress,
}),
apiFetch(API.global).catch(() => ({})),
]);
const configs = ListTools.uniqueSortedByName(configData.items)
.filter(isRTSPSource)
.map(scrubConfig);
const runtimeItems = ListTools.uniqueSortedByName(pathData.items);
state.configs = configs;
state.runtime = new Map(runtimeItems.map((item) => [item.name, item]));
state.global = globalData || {};
setAPIStatus("ok", "API 已连接");
render();
} catch (error) {
const apiError = normalizeAPIError(error);
if (apiError.status === 401) return;
if (state.configs.length > 0) render();
else {
elements.pathsContent.hidden = true;
elements.emptyState.hidden = true;
updateStats([]);
}
showPageError(apiError);
} finally {
setLoading(false);
}
}
function filteredConfigs() {
return ListTools.filterConfigs(
state.configs,
elements.searchInput.value,
elements.statusFilter.value,
(config) => getPathState(config).key,
);
}
function updateStats(configs) {
const counts = ListTools.countStatuses(configs, (config) => getPathState(config).key);
elements.statTotal.textContent = String(counts.total);
elements.statOnline.textContent = String(counts.online);
elements.statOffline.textContent = String(counts.offline);
elements.statIdle.textContent = String(counts.idle);
}
function statusNode(config) {
const status = getPathState(config);
return create("span", { className: `path-status ${status.className}`, text: status.label });
}
function actionButton(label, className, handler) {
const button = create("button", { className: `button ${className}`, text: label });
button.type = "button";
button.addEventListener("click", handler);
return button;
}
function actionsNode(config) {
const actions = create("div", { className: "row-actions" });
actions.append(
actionButton("预览", "button-primary", () => openPreview(config.name)),
actionButton("编辑", "button-secondary", () => openPathDialog(config)),
actionButton("删除", "button-secondary", () => openDeleteDialog(config.name)),
);
return actions;
}
function renderTable(configs) {
const rows = document.createDocumentFragment();
for (const config of configs) {
const row = create("tr");
const nameCell = create("td");
nameCell.append(create("span", { className: "path-name", text: config.name }));
const statusCell = create("td");
statusCell.append(statusNode(config));
const sourceCell = create("td");
sourceCell.append(create("span", {
className: "source-address",
text: config.sourceMasked,
title: config.sourceMasked,
}));
const transportCell = create("td", { text: transportLabel(config.transport) });
const readersCell = create("td", { className: "readers", text: readerCount(config.name) });
const actionsCell = create("td");
actionsCell.append(actionsNode(config));
row.append(nameCell, statusCell, sourceCell, transportCell, readersCell, actionsCell);
rows.append(row);
}
elements.tableBody.replaceChildren(rows);
}
function renderCards(configs) {
const cards = document.createDocumentFragment();
for (const config of configs) {
const card = create("article", { className: "path-card" });
const heading = create("div", { className: "path-card-heading" });
heading.append(create("strong", { className: "path-name", text: config.name }), statusNode(config));
card.append(
heading,
create("span", { className: "source-address", text: config.sourceMasked }),
create("p", {
className: "path-card-meta",
text: `${transportLabel(config.transport)} · ${readerCount(config.name)} 个读取者`,
}),
actionsNode(config),
);
cards.append(card);
}
elements.cardList.replaceChildren(cards);
}
function updatePagination(pageData) {
state.currentPage = pageData.page;
state.pageSize = pageData.pageSize;
elements.pageSize.value = String(pageData.pageSize);
elements.paginationSummary.textContent = `${pageData.start}–${pageData.end} / 共 ${pageData.totalItems} 条`;
elements.paginationPage.textContent = `${pageData.page} / ${pageData.totalPages} 页`;
elements.previousPage.disabled = pageData.page <= 1;
elements.nextPage.disabled = pageData.page >= pageData.totalPages;
}
function render() {
updateStats(state.configs);
const configs = filteredConfigs();
const hasFilters = Boolean(elements.searchInput.value.trim()) || elements.statusFilter.value !== "all";
if (configs.length === 0) {
elements.pathsContent.hidden = true;
elements.emptyState.hidden = false;
elements.emptyTitle.textContent = hasFilters ? "没有匹配的路径" : "还没有 RTSP 路径";
elements.emptyMessage.textContent = hasFilters
? "请调整搜索词或状态筛选。"
: "添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。";
elements.emptyAddButton.hidden = hasFilters;
return;
}
const pageData = ListTools.paginateItems(configs, state.currentPage, state.pageSize);
elements.emptyState.hidden = true;
elements.pathsContent.hidden = false;
renderTable(pageData.items);
renderCards(pageData.items);
updatePagination(pageData);
}
function transportLabel(value) {
return { automatic: "自动", tcp: "TCP", udp: "UDP" }[value] || "自动";
}
function clearFieldError(input, errorElement) {
input.removeAttribute("aria-invalid");
errorElement.textContent = "";
}
function setFieldError(input, errorElement, message) {
input.setAttribute("aria-invalid", "true");
errorElement.textContent = message;
}
function validatePathName(name) {
if (!name) return "请输入路径名称。";
if (name === "all" || name === "all_others") return "该名称为 MediaMTX 保留名称。";
if (name.startsWith("/") || name.endsWith("/") || name.includes("//")) {
return "路径名称不能以斜杠开头或结尾,也不能包含空路径段。";
}
if (name.split("/").some((part) => part === "." || part === "..")) {
return "路径名称不能包含 . 或 .. 路径段。";
}
return "";
}
function validateSource(value, required) {
if (!value) return required ? "请输入 RTSP 源地址。" : "";
try {
const parsed = new URL(value);
if (!["rtsp:", "rtsps:"].includes(parsed.protocol) || !parsed.hostname) {
return "请输入以 rtsp:// 或 rtsps:// 开头的有效地址。";
}
return "";
} catch (_) {
return "请输入有效的 RTSP 地址。";
}
}
function openPathDialog(config = null) {
state.editingName = config?.name || null;
const editing = Boolean(config);
elements.pathDialogTitle.textContent = editing ? "编辑 RTSP 源" : "添加 RTSP 源";
elements.pathSubmit.textContent = editing ? "保存修改" : "添加路径";
elements.pathName.value = config?.name || "";
elements.pathName.readOnly = editing;
elements.sourceURL.value = "";
elements.sourceURL.type = "password";
elements.sourceToggle.textContent = "显示";
elements.sourceToggle.setAttribute("aria-pressed", "false");
elements.transport.value = config?.transport || "automatic";
elements.sourceOnDemand.checked = Boolean(config?.sourceOnDemand);
elements.sourceRequired.hidden = editing;
elements.sourceHelp.textContent = editing
? `当前地址:${config.sourceMasked}。留空将保留原地址和凭据。`
: "例如 rtsp://user:password@camera.local/live。凭据不会持久化到浏览器。";
elements.formAlert.hidden = true;
clearFieldError(elements.pathName, elements.pathNameError);
clearFieldError(elements.sourceURL, elements.sourceError);
elements.pathDialog.showModal();
window.setTimeout(() => (editing ? elements.sourceURL : elements.pathName).focus(), 0);
}
function encodePathName(name) {
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
}
function setButtonBusy(button, busy, busyText, normalText) {
button.disabled = busy;
button.textContent = busy ? busyText : normalText;
}
async function submitPath(event) {
event.preventDefault();
const editing = Boolean(state.editingName);
const name = elements.pathName.value.trim();
const source = elements.sourceURL.value.trim();
const nameError = validatePathName(name);
const sourceError = validateSource(source, !editing);
nameError
? setFieldError(elements.pathName, elements.pathNameError, nameError)
: clearFieldError(elements.pathName, elements.pathNameError);
sourceError
? setFieldError(elements.sourceURL, elements.sourceError, sourceError)
: clearFieldError(elements.sourceURL, elements.sourceError);
if (nameError || sourceError) {
(nameError ? elements.pathName : elements.sourceURL).focus();
return;
}
const payload = {
rtspTransport: elements.transport.value,
sourceOnDemand: elements.sourceOnDemand.checked,
};
if (source) payload.source = source;
const normalText = editing ? "保存修改" : "添加路径";
setButtonBusy(elements.pathSubmit, true, "正在保存…", normalText);
elements.formAlert.hidden = true;
try {
const endpoint = editing ? "patch" : "add";
const method = editing ? "PATCH" : "POST";
await apiFetch(`/v3/config/paths/${endpoint}/${encodePathName(name)}`, {
method,
body: JSON.stringify(payload),
});
elements.pathDialog.close();
showToast(editing ? `已更新路径 ${name}` : `已添加路径 ${name}`);
await loadData();
} catch (error) {
elements.formAlert.textContent = error.message;
elements.formAlert.hidden = false;
} finally {
setButtonBusy(elements.pathSubmit, false, "", normalText);
}
}
function openDeleteDialog(name) {
state.deletingName = name;
elements.deletePathName.textContent = name;
elements.deleteAlert.hidden = true;
elements.deleteDialog.showModal();
window.setTimeout(() => elements.deleteSubmit.focus(), 0);
}
async function submitDelete(event) {
event.preventDefault();
const name = state.deletingName;
if (!name) return;
setButtonBusy(elements.deleteSubmit, true, "正在删除…", "删除路径");
elements.deleteAlert.hidden = true;
try {
await apiFetch(`/v3/config/paths/delete/${encodePathName(name)}`, { method: "DELETE" });
elements.deleteDialog.close();
showToast(`已删除路径 ${name}`);
await loadData();
} catch (error) {
elements.deleteAlert.textContent = error.message;
elements.deleteAlert.hidden = false;
} finally {
setButtonBusy(elements.deleteSubmit, false, "", "删除路径");
}
}
function extractPort(address, fallback) {
const match = String(address || "").match(/:(\d+)$/);
return match ? match[1] : String(fallback);
}
function browserHost() {
const hostname = window.location.hostname || "localhost";
return hostname.includes(":") ? `[${hostname}]` : hostname;
}
function encodedMediaPath(name) {
return name.split("/").map((part) => encodeURIComponent(part)).join("/");
}
function outputAddresses(name) {
const host = browserHost();
const path = encodedMediaPath(name);
const rtspPort = extractPort(state.global.rtspAddress, 8554);
const hlsPort = extractPort(state.global.hlsAddress, 8888);
const webrtcPort = extractPort(state.global.webrtcAddress, 8889);
const hlsScheme = state.global.hlsEncryption ? "https" : "http";
const webrtcScheme = state.global.webrtcEncryption ? "https" : "http";
return {
rtsp: `rtsp://${host}:${rtspPort}/${path}`,
webrtc: `${webrtcScheme}://${host}:${webrtcPort}/${path}`,
hls: `${hlsScheme}://${host}:${hlsPort}/${path}/`,
};
}
function renderOutputAddresses(addresses) {
const fragment = document.createDocumentFragment();
const entries = [
["RTSP", addresses.rtsp],
["WebRTC", addresses.webrtc],
["HLS", addresses.hls],
];
for (const [label, value] of entries) {
const item = create("div", { className: "output-item" });
const labelNode = create("label", { text: label });
const valueNode = create("span", { className: "output-value", text: value });
const copyButton = actionButton(`复制 ${label} 地址`, "button-secondary", () => copyText(value));
item.append(labelNode, valueNode, copyButton);
fragment.append(item);
}
elements.outputAddresses.replaceChildren(fragment);
}
function setPreviewProtocol(protocol) {
state.previewProtocol = protocol;
const addresses = outputAddresses(state.previewName);
const isWebRTC = protocol === "webrtc";
elements.webrtcTab.setAttribute("aria-selected", String(isWebRTC));
elements.hlsTab.setAttribute("aria-selected", String(!isWebRTC));
elements.previewFrame.src = isWebRTC ? addresses.webrtc : addresses.hls;
elements.previewHelp.textContent = isWebRTC
? "WebRTC 低延迟优先;连接失败时可切换到 HLS。"
: "HLS 兼容性优先,通常延迟高于 WebRTC。";
}
function openPreview(name) {
state.previewName = name;
elements.previewTitle.textContent = `预览 · ${name}`;
const addresses = outputAddresses(name);
renderOutputAddresses(addresses);
elements.previewDialog.showModal();
setPreviewProtocol("webrtc");
window.setTimeout(() => elements.webrtcTab.focus(), 0);
}
async function copyText(value) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const input = create("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.className = "visually-hidden";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
showToast("地址已复制");
} catch (_) {
showToast("复制失败,请手动选择地址", true);
}
}
function showToast(message, danger = false) {
window.clearTimeout(state.toastTimer);
elements.toast.textContent = message;
elements.toast.style.background = danger ? "var(--danger)" : "var(--foreground)";
elements.toast.hidden = false;
state.toastTimer = window.setTimeout(() => {
elements.toast.hidden = true;
}, 4000);
}
function closeDialog(id) {
const dialog = byID(id);
if (dialog?.open) dialog.close();
}
function cacheElements() {
Object.assign(elements, {
skipLink: byID("skip-link"),
loginView: byID("login-view"),
loginForm: byID("login-form"),
loginUsername: byID("login-username"),
loginPassword: byID("login-password"),
loginPasswordToggle: byID("login-password-toggle"),
loginError: byID("login-error"),
loginSubmit: byID("login-submit"),
appView: byID("app-view"),
mainContent: byID("main-content"),
apiStatus: byID("api-status"),
refreshButton: byID("refresh-button"),
addButton: byID("add-button"),
logoutButton: byID("logout-button"),
statTotal: byID("stat-total"),
statOnline: byID("stat-online"),
statOffline: byID("stat-offline"),
statIdle: byID("stat-idle"),
searchInput: byID("search-input"),
statusFilter: byID("status-filter"),
pageAlert: byID("page-alert"),
pageAlertTitle: byID("page-alert-title"),
pageAlertMessage: byID("page-alert-message"),
alertRetryButton: byID("alert-retry-button"),
loadingState: byID("loading-state"),
emptyState: byID("empty-state"),
emptyTitle: byID("empty-title"),
emptyMessage: byID("empty-message"),
emptyAddButton: byID("empty-add-button"),
pathsContent: byID("paths-content"),
tableBody: byID("paths-table-body"),
cardList: byID("paths-card-list"),
paginationSummary: byID("pagination-summary"),
paginationPage: byID("pagination-page"),
pageSize: byID("page-size"),
previousPage: byID("previous-page"),
nextPage: byID("next-page"),
loadingText: byID("loading-text"),
pathDialog: byID("path-dialog"),
pathForm: byID("path-form"),
pathDialogTitle: byID("path-dialog-title"),
formAlert: byID("form-alert"),
pathName: byID("path-name"),
pathNameError: byID("path-name-error"),
sourceURL: byID("source-url"),
sourceToggle: byID("source-toggle"),
sourceRequired: byID("source-required"),
sourceHelp: byID("source-help"),
sourceError: byID("source-error"),
transport: byID("transport"),
sourceOnDemand: byID("source-on-demand"),
pathSubmit: byID("path-submit"),
deleteDialog: byID("delete-dialog"),
deleteForm: byID("delete-form"),
deletePathName: byID("delete-path-name"),
deleteAlert: byID("delete-alert"),
deleteSubmit: byID("delete-submit"),
previewDialog: byID("preview-dialog"),
previewTitle: byID("preview-title"),
previewFrame: byID("preview-frame"),
previewHelp: byID("preview-help"),
webrtcTab: byID("webrtc-tab"),
hlsTab: byID("hls-tab"),
outputAddresses: byID("output-addresses"),
toast: byID("toast"),
});
}
function bindEvents() {
elements.loginForm.addEventListener("submit", submitLogin);
elements.loginPasswordToggle.addEventListener("click", () => {
togglePassword(elements.loginPassword, elements.loginPasswordToggle);
});
elements.logoutButton.addEventListener("click", () => endSession());
elements.refreshButton.addEventListener("click", loadData);
elements.alertRetryButton.addEventListener("click", loadData);
elements.addButton.addEventListener("click", () => openPathDialog());
elements.emptyAddButton.addEventListener("click", () => openPathDialog());
elements.searchInput.addEventListener("input", () => {
state.currentPage = 1;
render();
});
elements.statusFilter.addEventListener("change", () => {
state.currentPage = 1;
render();
});
elements.pageSize.addEventListener("change", () => {
state.pageSize = Number(elements.pageSize.value);
state.currentPage = 1;
render();
});
elements.previousPage.addEventListener("click", () => {
state.currentPage -= 1;
render();
});
elements.nextPage.addEventListener("click", () => {
state.currentPage += 1;
render();
});
elements.pathForm.addEventListener("submit", submitPath);
elements.deleteForm.addEventListener("submit", submitDelete);
elements.sourceToggle.addEventListener("click", () => {
togglePassword(elements.sourceURL, elements.sourceToggle);
});
elements.webrtcTab.addEventListener("click", () => setPreviewProtocol("webrtc"));
elements.hlsTab.addEventListener("click", () => setPreviewProtocol("hls"));
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
button.addEventListener("click", () => closeDialog(button.dataset.closeDialog));
});
elements.previewDialog.addEventListener("close", () => {
elements.previewFrame.src = "about:blank";
state.previewName = null;
});
}
function initialize() {
cacheElements();
bindEvents();
restoreSession();
}
document.addEventListener("DOMContentLoaded", initialize);
})();