Files
mediamtx/internal/api/admin/app.js
T
QiuSW 6465a11074
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: add embedded RTSP admin console (#3)
2026-08-28 11:24:47 +08:00

646 lines
23 KiB
JavaScript

(() => {
"use strict";
const API = {
configs: "/v3/config/paths/list",
paths: "/v3/paths/list",
global: "/v3/config/global/get",
};
const state = {
configs: [],
runtime: new Map(),
global: {},
editingName: null,
deletingName: null,
previewName: null,
previewProtocol: "webrtc",
loading: false,
toastTimer: null,
};
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:••••@",
);
}
async function apiFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
if (options.body !== undefined) headers.set("Content-Type", "application/json");
let response;
try {
response = await fetch(url, {
...options,
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) {
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 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;
setAPIStatus("loading", "正在连接 API");
}
}
async function loadData() {
if (state.loading) return;
setLoading(true);
hidePageError();
try {
const [configData, pathData, globalData] = await Promise.all([
apiFetch(API.configs),
apiFetch(API.paths),
apiFetch(API.global).catch(() => ({})),
]);
state.configs = (configData.items || [])
.filter(isRTSPSource)
.map(scrubConfig);
state.runtime = new Map((pathData.items || []).map((item) => [item.name, item]));
state.global = globalData || {};
setAPIStatus("ok", "API 已连接");
render();
} catch (error) {
state.configs = [];
state.runtime = new Map();
elements.pathsContent.hidden = true;
elements.emptyState.hidden = true;
showPageError(error instanceof APIError ? error : new APIError(0, String(error)));
updateStats([]);
} finally {
setLoading(false);
}
}
function filteredConfigs() {
const query = elements.searchInput.value.trim().toLocaleLowerCase();
const filter = elements.statusFilter.value;
return state.configs.filter((config) => {
const matchesQuery = !query || config.name.toLocaleLowerCase().includes(query)
|| config.sourceMasked.toLocaleLowerCase().includes(query);
const matchesStatus = filter === "all" || getPathState(config).key === filter;
return matchesQuery && matchesStatus;
});
}
function updateStats(configs) {
const counts = { online: 0, offline: 0, idle: 0 };
for (const config of configs) counts[getPathState(config).key] += 1;
elements.statTotal.textContent = String(configs.length);
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 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;
}
elements.emptyState.hidden = true;
elements.pathsContent.hidden = false;
renderTable(configs);
renderCards(configs);
}
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, {
apiStatus: byID("api-status"),
refreshButton: byID("refresh-button"),
addButton: byID("add-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"),
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.refreshButton.addEventListener("click", loadData);
elements.alertRetryButton.addEventListener("click", loadData);
elements.addButton.addEventListener("click", () => openPathDialog());
elements.emptyAddButton.addEventListener("click", () => openPathDialog());
elements.searchInput.addEventListener("input", render);
elements.statusFilter.addEventListener("change", render);
elements.pathForm.addEventListener("submit", submitPath);
elements.deleteForm.addEventListener("submit", submitDelete);
elements.sourceToggle.addEventListener("click", () => {
const showing = elements.sourceURL.type === "text";
elements.sourceURL.type = showing ? "password" : "text";
elements.sourceToggle.textContent = showing ? "显示" : "隐藏";
elements.sourceToggle.setAttribute("aria-pressed", String(!showing));
elements.sourceURL.focus();
});
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();
loadData();
}
document.addEventListener("DOMContentLoaded", initialize);
})();