feat: add embedded RTSP admin console (#3)
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

This commit is contained in:
QiuSW
2026-08-28 11:24:47 +08:00
parent a3ca54cae4
commit 6465a11074
10 changed files with 2001 additions and 43 deletions
+24 -11
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Architecture-and-Code-Map.-
wiki_revision: 998205ee17d1cdea34d6e68681f7cff8ac34b941
synchronized_at: 2026-08-28T02:10:15Z
wiki_revision: a978bca7f5f9a686b33387bb8477c0adddf7a40c
synchronized_at: 2026-08-28T03:22:44Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -20,21 +20,34 @@ synchronized_at: 2026-08-28T02:10:15Z
- `api/openapi.yaml`:Control API 契约。
- `internal/**/*_test.go`:单元和集成测试。
## 定制管理页面目标结构
## 内嵌管理页面
```text
MediaMTX API HTTP 服务 :9997
├─ /admin/ 嵌入式原生 HTML/CSS/JS
└─ /v3/... 现有 Control API
├─ /admin 301 规范化到 /admin/
├─ /admin/ 内嵌 index.html
├─ /admin/app.css 内嵌原生样式
├─ /admin/app.js 内嵌原生交互
└─ /v3/... 现有 Control API
MediaMTX WebRTC :8889/{path} 低延迟预览
MediaMTX HLS :8888/{path} 兼容预览
MediaMTX WebRTC :8889/{path} 内置页面负责低延迟预览
MediaMTX HLS :8888/{path}/ 内置页面负责兼容预览
```
管理页面只负责调用既有 Control API、展示状态和嵌入播放器,不代理 RTSP 媒体数据,不引入 Node、数据库或前端框架。
管理页面只调用既有 Control API、展示状态并在 iframe 中打开 MediaMTX 内置播放器,不代理 RTSP 或 HTTP 媒体数据,不引入 Node、数据库或前端框架。
## 代码边界
- `internal/api/api.go`:注册 `/admin` 与 `/admin/*path`,路由位于现有 API 认证中间件之后。
- `internal/api/api_admin.go`:Go `embed`、资源响应、缓存策略和安全响应头。
- `internal/api/admin/index.html`:语义化页面、表单、对话框与无障碍 live regions。
- `internal/api/admin/app.css`:蓝灰设计 token、桌面表格、375px 卡片布局、焦点与 reduced-motion。
- `internal/api/admin/app.js`:配置/状态合并、CRUD、脱敏、筛选、预览地址和错误恢复。
- `internal/api/api_admin_test.go`:入口、重定向、资源、CSP、鉴权和静态安全约束测试。
## 主要 API
- `GET /v3/config/global/get`
- `GET /v3/config/paths/list`
- `POST /v3/config/paths/add/{name}`
- `PATCH /v3/config/paths/patch/{name}`
@@ -45,7 +58,7 @@ API 契约以当前仓库 `api/openapi.yaml` 为唯一代码级事实来源。
## 修改边界
- UI 静态资源与路由集中在 API 包的独立目录。
- 不改变 RTSP 拉流、转封装、协议协商等媒体核心行为。
- 管理路由必须复用现有 API 认证边界。
- 上游内置播放器优先复用,避免维护第二套媒体播放实现。
- 管理路由复用现有 `action: api` 认证边界。
- 预览复用上游内置 WebRTC/HLS 页面,避免维护第二套媒体播放实现。
- 前端只保留管理静态 RTSP 源需要的最小配置字段;新增字段必须按工单和 API 契约扩展。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Product-Requirements-Overview.-
wiki_revision: 69f6a337a250440c7c5043c8d75122d8e5bfd0a6
synchronized_at: 2026-08-28T02:48:30Z
wiki_revision: 66aa5cb6e1b5d1558fd5fe6dafd6f38fe3a6f5ae
synchronized_at: 2026-08-28T03:22:52Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -13,16 +13,16 @@ synchronized_at: 2026-08-28T02:48:30Z
| 状态 | 需求 | 入口 |
|---|---|---|
| 已完成 | 增量接入 DevHarness | [工单 #1](https://git.ilapage.cn/OPC/mediamtx/issues/1) |
| 待实施 | 原生轻量 RTSP 管理页面 | [实现工单 #3](https://git.ilapage.cn/OPC/mediamtx/issues/3) · [设计工单 #2](https://git.ilapage.cn/OPC/mediamtx/issues/2) · [Quant-UX V1](https://qux.ilapage.cn/#/apps/6a90f1d7191a826306a7f403.html) |
| 实现完成、待验收 | 原生轻量 RTSP 管理页面 | [实现工单 #3](https://git.ilapage.cn/OPC/mediamtx/issues/3) · [设计工单 #2](https://git.ilapage.cn/OPC/mediamtx/issues/2) · [Quant-UX V1](https://qux.ilapage.cn/#/apps/6a90f1d7191a826306a7f403.html) |
## 管理页面目标
## 管理页面能力
- 使用原生 HTML、CSS 和 JavaScript。
- 随 MediaMTX 二进制嵌入,不要求 Node、Nginx、数据库或单独 Web 服务。
- 增加、编辑、删除静态 RTSP 源。
- 展示路径在线/离线状态。
- 提供 WebRTC 与 HLS 预览入口。
- 可以复制 RTSP、HLS、WebRTC 输出地址。
- 使用原生 HTML、CSS 和 JavaScript,并由 Go `embed` 随 MediaMTX 二进制嵌入。
- API 启用后,由同一 API HTTP 服务在 `/admin/` 提供页面,不要求 Node、Nginx、数据库或单独 Web 服务。
- 增加、编辑、删除静态 RTSP / RTSPS 源。
- 展示路径在线、离线、按需待请求状态及读取者数量。
- 默认提供 WebRTC 预览,并可切换 HLS。
- 可以复制 RTSP、HLS、WebRTC 输出地址;端口来自当前全局配置。
- 保持界面小而直接,不扩展为完整 VMS。
## 已确认设计基线
@@ -32,7 +32,15 @@ synchronized_at: 2026-08-28T02:48:30Z
- Quant-UX App ID:`6a90f1d7191a826306a7f403`。
- 确认范围:桌面路径列表、添加、编辑、WebRTC/HLS 预览、删除确认、空/加载状态、API 不可达、HTTP 403 权限错误和手机端卡片布局。
- 原型校验:9 个画布、176 个组件、29 条交互连线,Quant-UX consistency verification 为 `PASS`。
- 生产实现工单为 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3),以该原型和设计工单的文字边界为准;关键交互变化需重新确认。
- 生产实现工单为 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3),实现完成后仍需用户明确验收;关键交互变化需重新确认。
## 安全和权限
- `/admin/` 和 Control API 共用 `action: api` 鉴权边界。
- 页面列表和帮助文字只保存脱敏后的 RTSP 地址;编辑时源地址留空可保留既有凭据。
- 页面不使用 localStorage 保存敏感值,不把动态数据直接注入 `innerHTML`。
- 页面静态响应包含 CSP、nosniff、referrer policy 和同源 frame 限制。
- 真实摄像头地址、用户名、密码和 Token 不进入源码、测试、Wiki 或工单。
## 非目标
@@ -41,7 +49,8 @@ synchronized_at: 2026-08-28T02:48:30Z
- 不复制 MediaMTX 所有全局配置为大型后台。
- 不允许匿名用户修改路径配置。
- 不承诺浏览器支持摄像头输出的所有编码。
- 不由管理页代理媒体数据。
## 验收入口
具体页面、状态、权限和异常处理以对应单元工单及已确认原型为准;本页只维护长期需求索引,不替代单元工单。
具体实现和验证证据以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准;本页只维护长期需求索引,不替代单元工单。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Local-Development-and-Verification.-
wiki_revision: 9291a287f01fdf1258a74e4461ca747679518cce
synchronized_at: 2026-08-28T02:10:40Z
wiki_revision: 5515588b36721b79890d6a12b89f3d3d6e7cc426
synchronized_at: 2026-08-28T03:23:29Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -13,40 +13,77 @@ synchronized_at: 2026-08-28T02:10:40Z
- 工作目录:`D:\github\mediamtx`
- 主要终端:PowerShell
- 语言:Go(版本以 `go.mod` 和上游 CI 为准)
- 管理页:原生 HTML/CSS/JavaScript,无运行时前端依赖
- DevHarness:Python 3 标准库
- 上游远端:`upstream=https://github.com/bluenviron/mediamtx.git`
- 定制远端:`origin=https://git.ilapage.cn/OPC/mediamtx.git`
## 首次准备
上游通过 `go:generate` 生成被 Git 忽略的构建资源。新工作树执行:
```powershell
go generate ./internal/servers/hls
go generate ./internal/core
```
HLS 下载器使用固定版本和 SHA-256 校验;生成的 `hls.min.js` 与 `VERSION` 不进入提交。
## 常用命令
```powershell
git status --short --branch
go test ./internal/api ./internal/core
go test ./...
go build ./...
node --check internal/api/admin/app.js
go test ./internal/api -run '^TestAdmin' -count=1
go test ./... -run '^$'
go test -tags enable_linters ./internal/linters/docsorder
python dev_scripts/harness.py check --strict
python dev_scripts/harness.py sync --check
python -m unittest discover -s tests -v
git diff --check
```
预期:
- Git 状态只包含当前工单和已知用户改动。
- 受影响 Go 测试通过;全量测试按风险执行。
- Go 构建成功。
- 管理页路由、资源、安全头和鉴权测试通过。
- 全部 Go 包可编译;全量测试按端口占用和风险执行。
- Harness 结构、镜像和单元测试通过。
## 运行
## 运行管理页面
```powershell
go run . mediamtx.yml
在测试配置中启用 API:
```yaml
api: yes
apiAddress: :9997
```
启用管理 API 时使用不含真实凭据的测试配置。Control API 默认端口为 `:9997`,WebRTC 为 `:8889`,HLS 为 `:8888`。
运行 MediaMTX 后访问 `http://127.0.0.1:9997/admin/`。页面和 API 共用 `action: api` 权限;未授权浏览器会收到现有 Basic authentication challenge。
默认媒体入口:
- RTSP:`:8554/{path}`
- HLS:`:8888/{path}/`
- WebRTC:`:8889/{path}`
页面从 `/v3/config/global/get` 读取自定义端口,不写死部署主机名。启用 TLS 时由对应 HLS/WebRTC 配置决定预览协议。
## 浏览器检查
至少检查 1440×900 和 375×812:
- 桌面表格与手机卡片切换,无横向溢出。
- 所有表单有可见标签、就地错误和键盘焦点。
- 主要操作高度至少 44px。
- reduced-motion 生效,页面不请求第三方资源。
- 新增、编辑留空保留源凭据、删除确认、WebRTC/HLS 切换和 API 断线重试。
- DOM、帮助文字和通知中不存在完整 RTSP 密码。
- 控制台无运行时错误。
## 凭据
`ip_camera.env` 只允许保存在本地并被 Git 忽略。测试、日志、截图、工单和 Wiki 使用虚构地址与账号。
`ip_camera.env` 只允许保存在本地并被 Git 忽略。测试、日志、截图、工单和 Wiki 使用 `192.0.2.x` 等保留地址及虚构账号。
## 未验证内容
+18 -8
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/mediamtx/wiki/Common-Changes.-
wiki_revision: 17db8cd186ac676dd3c58c114751fc4f19483cfe
synchronized_at: 2026-08-28T02:10:42Z
wiki_revision: 713c7047d63271c8b345c163c3582d2caa20a2e2
synchronized_at: 2026-08-28T03:23:37Z
<!-- gitea-wiki-mirror:end -->
# 常见修改
@@ -11,10 +11,20 @@ synchronized_at: 2026-08-28T02:10:42Z
## 增加管理页字段
1. 先核对 `api/openapi.yaml` 和 `internal/conf/` 中的真实字段。
2. 更新已确认的页面状态和验证范围。
3. 修改原生表单及请求体。
4. 增加前端输入校验和 Go 路由/响应测试。
5. 验证新增、读取、编辑、删除及错误提示。
2. 判断是否改变已确认的页面结构、主要流程、权限或异常处理;关键变化先更新原型并重新确认。
3. 修改 `internal/api/admin/` 中的原生表单、脱敏逻辑和请求体。
4. 保持可见标签、就地校验、键盘焦点、44px 触控目标和窄屏卡片布局。
5. 增加前端静态约束、Go 路由/响应测试和浏览器场景检查。
6. 验证新增、读取、编辑留空保留既有值、删除及错误恢复。
## 调整管理页资源或路由
- 静态资源使用 Go `embed`,集中在 `internal/api/admin/`。
- `/admin/` 必须继续位于 API 鉴权中间件之后并使用 `action: api`。
- 保持 CSP、nosniff、referrer policy、缓存策略和未知资源 404 测试。
- 不引入 CDN、外部字体、Node 运行时、前端框架或独立 Web 服务。
- 动态内容使用 `textContent` / DOM API,不把路径名、URL 或错误直接注入 `innerHTML`。
- 新增媒体预览能力时优先复用 MediaMTX 内置播放页,不让管理页代理媒体流。
## 调整 Control API
@@ -24,9 +34,9 @@ API 属于中风险变更。必须先建工单并确认兼容性,不得只改
1. 记录目标上游完整提交。
2. 获取 `upstream`。
3. 检查定制文件和 API 契约变化。
3. 检查 `internal/api/admin/`、API 路由和配置 JSON 字段变化。
4. 合并时保留用户未提交改动。
5. 运行受影响测试和构建。
5. 运行管理页专项测试、全部包编译、受影响测试和 Harness 检查。
6. 更新项目档案中的上游基线并回写工单证据。
## 修改文档
+844
View File
@@ -0,0 +1,844 @@
:root {
color-scheme: light;
--background: #fafafa;
--surface: #ffffff;
--surface-muted: #f4f4f5;
--foreground: #09090b;
--secondary: #52525b;
--border: #e4e4e7;
--border-strong: #a1a1aa;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--success: #166534;
--success-bg: #dcfce7;
--warning: #854d0e;
--warning-bg: #fef3c7;
--danger: #b91c1c;
--danger-hover: #991b1b;
--danger-bg: #fee2e2;
--focus: #1d4ed8;
--shadow: 0 12px 32px rgb(9 9 11 / 10%);
--radius: 10px;
--z-toast: 30;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--background);
font-family: "Segoe UI", Arial, sans-serif;
color: var(--foreground);
}
body {
min-height: 100dvh;
margin: 0;
font-size: 16px;
line-height: 1.5;
}
button,
input,
select {
font: inherit;
}
button,
select,
input[type="checkbox"] {
cursor: pointer;
touch-action: manipulation;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 3px solid rgb(37 99 235 / 35%);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
margin-bottom: 2px;
font-size: clamp(1.5rem, 2vw, 1.9rem);
line-height: 1.2;
letter-spacing: -0.02em;
}
h2 {
margin-bottom: 4px;
font-size: 1.25rem;
}
h3 {
margin-bottom: 8px;
font-size: 1rem;
}
code {
font-family: Consolas, "Courier New", monospace;
font-size: 0.9em;
}
.skip-link {
position: fixed;
z-index: var(--z-toast);
top: 8px;
left: 8px;
padding: 10px 14px;
border-radius: 6px;
color: #ffffff;
background: var(--foreground);
transform: translateY(-150%);
}
.skip-link:focus {
transform: translateY(0);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: min(100% - 48px, 1360px);
margin: 0 auto;
padding: 28px 0 20px;
}
.eyebrow {
margin-bottom: 2px;
color: var(--secondary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.subtitle,
.panel-heading p,
.helper {
margin-bottom: 0;
color: var(--secondary);
}
.subtitle {
font-size: 0.875rem;
}
.topbar-actions,
.modal-actions,
.row-actions,
.preview-tabs {
display: flex;
align-items: center;
gap: 8px;
}
.button,
.icon-button,
.tab {
min-height: 44px;
border: 1px solid transparent;
border-radius: 7px;
padding: 9px 14px;
font-weight: 650;
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
}
.button-primary {
border-color: var(--accent);
color: #ffffff;
background: var(--accent);
}
.button-primary:hover {
background: var(--accent-hover);
}
.button-secondary,
.icon-button,
.tab {
border-color: var(--border);
color: var(--foreground);
background: var(--surface);
}
.button-secondary:hover,
.icon-button:hover,
.tab:hover {
background: var(--surface-muted);
}
.button-danger {
border-color: var(--danger);
color: #ffffff;
background: var(--danger);
}
.button-danger:hover {
background: var(--danger-hover);
}
.icon-button {
min-width: 64px;
}
.status-pill,
.path-status {
display: inline-flex;
align-items: center;
min-height: 30px;
border-radius: 999px;
padding: 4px 11px;
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
}
.status-pill::before,
.path-status::before {
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 50%;
background: currentColor;
content: "";
}
.status-loading,
.status-idle {
color: var(--warning);
background: var(--warning-bg);
}
.status-ok,
.status-online {
color: var(--success);
background: var(--success-bg);
}
.status-error,
.status-offline {
color: var(--danger);
background: var(--danger-bg);
}
.page {
width: min(100% - 48px, 1360px);
margin: 0 auto;
padding-bottom: 48px;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin: 12px 0 24px;
}
.stat-card,
.paths-panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.stat-card {
min-height: 98px;
padding: 18px 20px;
}
.stat-card span {
display: block;
margin-bottom: 6px;
color: var(--secondary);
font-size: 0.82rem;
font-weight: 650;
}
.stat-card strong {
font-size: 1.75rem;
font-variant-numeric: tabular-nums;
}
.paths-panel {
overflow: hidden;
}
.panel-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
padding: 20px 22px;
border-bottom: 1px solid var(--border);
}
.panel-heading p {
font-size: 0.82rem;
}
.filters {
display: flex;
align-items: end;
gap: 10px;
}
.filters label,
.field {
display: grid;
gap: 6px;
}
.filters label > span,
.field > label {
font-size: 0.82rem;
font-weight: 650;
}
input,
select {
min-height: 44px;
border: 1px solid var(--border-strong);
border-radius: 7px;
padding: 9px 11px;
color: var(--foreground);
background: var(--surface);
}
input[readonly] {
cursor: default;
color: var(--secondary);
background: var(--surface-muted);
}
.search-field input {
width: min(28vw, 280px);
}
.page-alert,
.form-alert {
border: 1px solid #fecaca;
border-radius: 8px;
color: var(--danger);
background: #fff7f7;
}
.page-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 18px 22px 0;
padding: 14px 16px;
}
.page-alert p,
.form-alert {
margin-bottom: 0;
font-size: 0.875rem;
}
.loading-state,
.empty-state {
display: grid;
place-items: center;
min-height: 310px;
padding: 40px 20px;
text-align: center;
}
.loading-state {
align-content: center;
grid-auto-flow: column;
gap: 12px;
color: var(--secondary);
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 700ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state p {
max-width: 520px;
color: var(--secondary);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: middle;
}
th {
color: var(--secondary);
background: var(--surface-muted);
font-size: 0.78rem;
font-weight: 700;
}
th:nth-child(1) { width: 15%; }
th:nth-child(2) { width: 10%; }
th:nth-child(3) { width: 33%; }
th:nth-child(4) { width: 9%; }
th:nth-child(5) { width: 8%; }
th:nth-child(6) { width: 25%; }
td {
font-size: 0.875rem;
}
tbody tr:last-child td {
border-bottom: 0;
}
.path-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.source-address {
display: block;
overflow: hidden;
color: var(--secondary);
font-family: Consolas, "Courier New", monospace;
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.readers {
font-variant-numeric: tabular-nums;
}
.row-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.row-actions .button {
min-height: 38px;
padding: 6px 10px;
font-size: 0.8rem;
}
.card-list {
display: none;
}
.modal {
width: min(720px, calc(100% - 32px));
max-height: calc(100dvh - 32px);
margin: auto;
border: 1px solid var(--border);
border-radius: 12px;
padding: 0;
color: var(--foreground);
background: var(--surface);
box-shadow: var(--shadow);
}
.modal::backdrop {
background: rgb(9 9 11 / 55%);
}
.modal form,
.modal > div:not(.modal-header),
.modal > p,
.modal > aside {
margin-right: 0;
}
.modal form {
padding: 22px;
}
.modal > .modal-header,
.modal > .preview-tabs,
.modal > .preview-layout {
margin-right: 22px;
margin-left: 22px;
}
.modal > .modal-header {
margin-top: 22px;
}
.modal-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
}
.modal-header h2 {
margin-bottom: 0;
}
.form-alert {
margin-bottom: 18px;
padding: 11px 13px;
}
.field + .field,
.field-grid {
margin-top: 18px;
}
.helper,
.field-error {
font-size: 0.78rem;
}
.field-error {
min-height: 1.2em;
margin: 0;
color: var(--danger);
font-weight: 600;
}
.password-field {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.switch-field {
display: flex;
align-items: center;
min-height: 72px;
gap: 12px;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
cursor: pointer;
}
.switch-field input {
width: 22px;
min-height: 22px;
margin: 0;
accent-color: var(--accent);
}
.switch-field strong,
.switch-field small {
display: block;
}
.switch-field small {
color: var(--secondary);
}
.modal-actions {
justify-content: flex-end;
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.modal-small {
width: min(520px, calc(100% - 32px));
}
.modal-small p {
line-height: 1.65;
}
.danger-text {
color: var(--danger);
}
.modal-wide {
width: min(1180px, calc(100% - 32px));
padding-bottom: 22px;
}
.preview-tabs {
margin-bottom: 14px;
}
.tab[aria-selected="true"] {
border-color: var(--accent);
color: #ffffff;
background: var(--accent);
}
.preview-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
gap: 18px;
}
.preview-frame-wrap {
min-width: 0;
}
#preview-frame {
display: block;
width: 100%;
min-height: 460px;
border: 0;
border-radius: 9px;
background: #111827;
}
.preview-frame-wrap .helper {
margin-top: 8px;
}
.output-panel {
border: 1px solid var(--border);
border-radius: 9px;
padding: 16px;
background: var(--surface-muted);
}
.output-item + .output-item {
margin-top: 16px;
}
.output-item label {
display: block;
margin-bottom: 5px;
font-size: 0.78rem;
font-weight: 700;
}
.output-value {
display: block;
margin-bottom: 7px;
overflow-wrap: anywhere;
color: var(--secondary);
font-family: Consolas, "Courier New", monospace;
font-size: 0.76rem;
}
.output-item .button {
width: 100%;
}
.toast {
position: fixed;
z-index: var(--z-toast);
right: 20px;
bottom: 20px;
max-width: min(420px, calc(100% - 40px));
border-radius: 8px;
padding: 13px 16px;
color: #ffffff;
background: var(--foreground);
box-shadow: var(--shadow);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
[hidden] {
display: none !important;
}
@media (max-width: 900px) {
.topbar,
.panel-heading {
align-items: stretch;
flex-direction: column;
}
.topbar-actions {
flex-wrap: wrap;
}
.stats {
grid-template-columns: repeat(2, 1fr);
}
.filters,
.filters label,
.search-field input {
width: 100%;
}
.preview-layout {
grid-template-columns: 1fr;
}
#preview-frame {
min-height: 380px;
}
}
@media (max-width: 700px) {
.topbar,
.page {
width: min(100% - 32px, 1360px);
}
.topbar {
padding-top: 20px;
}
.topbar-actions .status-pill {
width: 100%;
justify-content: center;
}
.topbar-actions .button {
flex: 1;
}
.stats {
gap: 10px;
}
.stat-card {
min-height: 88px;
padding: 14px;
}
.panel-heading {
padding: 18px 16px;
}
.filters {
align-items: stretch;
flex-direction: column;
}
.table-wrap {
display: none;
}
.card-list {
display: grid;
gap: 12px;
padding: 14px;
}
.path-card {
border: 1px solid var(--border);
border-radius: 9px;
padding: 16px;
}
.path-card-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.path-card .source-address {
margin-bottom: 12px;
overflow: visible;
text-overflow: initial;
white-space: normal;
}
.path-card-meta {
margin-bottom: 14px;
color: var(--secondary);
font-size: 0.82rem;
}
.row-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.row-actions .button {
min-height: 44px;
}
.field-grid,
.password-field {
grid-template-columns: 1fr;
}
.modal {
width: calc(100% - 20px);
max-height: calc(100dvh - 20px);
}
.modal form {
padding: 18px;
}
.modal > .modal-header,
.modal > .preview-tabs,
.modal > .preview-layout {
margin-right: 16px;
margin-left: 16px;
}
.modal > .modal-header {
margin-top: 16px;
}
#preview-frame {
min-height: 280px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+645
View File
@@ -0,0 +1,645 @@
(() => {
"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);
})();
+210
View File
@@ -0,0 +1,210 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>MediaMTX Stream Console</title>
<link rel="stylesheet" href="./app.css">
<script src="./app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<header class="topbar">
<div>
<p class="eyebrow">MediaMTX</p>
<h1>Stream Console</h1>
<p class="subtitle">局域网 RTSP 路径管理与快速预览</p>
</div>
<div class="topbar-actions" aria-label="服务操作">
<span id="api-status" class="status-pill status-loading" role="status">正在连接 API</span>
<button id="refresh-button" class="button button-secondary" type="button">刷新</button>
<button id="add-button" class="button button-primary" type="button">添加 RTSP 源</button>
</div>
</header>
<main id="main-content" class="page" tabindex="-1">
<section class="stats" aria-label="路径概览">
<article class="stat-card">
<span>路径总数</span>
<strong id="stat-total">—</strong>
</article>
<article class="stat-card">
<span>在线</span>
<strong id="stat-online">—</strong>
</article>
<article class="stat-card">
<span>离线</span>
<strong id="stat-offline">—</strong>
</article>
<article class="stat-card">
<span>待请求</span>
<strong id="stat-idle">—</strong>
</article>
</section>
<section class="paths-panel" aria-labelledby="paths-heading">
<div class="panel-heading">
<div>
<h2 id="paths-heading">RTSP 路径</h2>
<p>仅显示静态 RTSP / RTSPS 源。地址中的密码始终遮盖。</p>
</div>
<div class="filters">
<label class="search-field" for="search-input">
<span>搜索</span>
<input id="search-input" type="search" placeholder="路径名称或地址" autocomplete="off">
</label>
<label for="status-filter">
<span>状态</span>
<select id="status-filter">
<option value="all">全部</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="idle">待请求</option>
</select>
</label>
</div>
</div>
<div id="page-alert" class="page-alert" role="alert" hidden>
<div>
<strong id="page-alert-title"></strong>
<p id="page-alert-message"></p>
</div>
<button id="alert-retry-button" class="button button-secondary" type="button">重试</button>
</div>
<div id="loading-state" class="loading-state" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span>正在加载路径…</span>
</div>
<div id="empty-state" class="empty-state" hidden>
<h3 id="empty-title">还没有 RTSP 路径</h3>
<p id="empty-message">添加第一个摄像头或 RTSP 服务地址,之后可在列表中预览与管理。</p>
<button id="empty-add-button" class="button button-primary" type="button">添加 RTSP 源</button>
</div>
<div id="paths-content" hidden>
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">路径</th>
<th scope="col">状态</th>
<th scope="col">源地址</th>
<th scope="col">传输</th>
<th scope="col">读取者</th>
<th scope="col"><span class="visually-hidden">操作</span></th>
</tr>
</thead>
<tbody id="paths-table-body"></tbody>
</table>
</div>
<div id="paths-card-list" class="card-list"></div>
</div>
</section>
</main>
<dialog id="path-dialog" class="modal">
<form id="path-form" method="dialog" novalidate>
<div class="modal-header">
<div>
<p class="eyebrow">路径配置</p>
<h2 id="path-dialog-title">添加 RTSP 源</h2>
</div>
<button class="icon-button" type="button" data-close-dialog="path-dialog" aria-label="关闭路径配置">关闭</button>
</div>
<div id="form-alert" class="form-alert" role="alert" hidden></div>
<div class="field">
<label for="path-name">路径名称 <span aria-hidden="true">*</span></label>
<input id="path-name" name="pathName" type="text" required autocomplete="off" aria-describedby="path-name-help path-name-error">
<p id="path-name-help" class="helper">允许使用多级名称,例如 <code>building/east</code>。</p>
<p id="path-name-error" class="field-error" role="alert"></p>
</div>
<div class="field">
<label for="source-url">RTSP 源地址 <span id="source-required" aria-hidden="true">*</span></label>
<div class="password-field">
<input id="source-url" name="source" type="password" inputmode="url" autocomplete="new-password" spellcheck="false" aria-describedby="source-help source-error">
<button id="source-toggle" class="button button-secondary" type="button" aria-pressed="false">显示</button>
</div>
<p id="source-help" class="helper">例如 rtsp://user:password@camera.local/live。凭据不会持久化到浏览器。</p>
<p id="source-error" class="field-error" role="alert"></p>
</div>
<div class="field-grid">
<div class="field">
<label for="transport">RTSP 传输协议</label>
<select id="transport" name="transport">
<option value="automatic">自动</option>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<label class="switch-field" for="source-on-demand">
<input id="source-on-demand" name="sourceOnDemand" type="checkbox">
<span>
<strong>按需拉流</strong>
<small>仅在有读取者时连接上游。</small>
</span>
</label>
</div>
<div class="modal-actions">
<button class="button button-secondary" type="button" data-close-dialog="path-dialog">取消</button>
<button id="path-submit" class="button button-primary" type="submit">添加路径</button>
</div>
</form>
</dialog>
<dialog id="delete-dialog" class="modal modal-small">
<form id="delete-form" method="dialog">
<div class="modal-header">
<div>
<p class="eyebrow danger-text">危险操作</p>
<h2>删除路径</h2>
</div>
<button class="icon-button" type="button" data-close-dialog="delete-dialog" aria-label="关闭删除确认">关闭</button>
</div>
<p>确定删除 <strong id="delete-path-name"></strong>?该路径的当前读取连接会被中断。</p>
<div id="delete-alert" class="form-alert" role="alert" hidden></div>
<div class="modal-actions">
<button class="button button-secondary" type="button" data-close-dialog="delete-dialog">取消</button>
<button id="delete-submit" class="button button-danger" type="submit">删除路径</button>
</div>
</form>
</dialog>
<dialog id="preview-dialog" class="modal modal-wide">
<div class="modal-header">
<div>
<p class="eyebrow">媒体预览</p>
<h2 id="preview-title">预览</h2>
</div>
<button class="icon-button" type="button" data-close-dialog="preview-dialog" aria-label="关闭视频预览">关闭</button>
</div>
<div class="preview-tabs" role="tablist" aria-label="预览协议">
<button id="webrtc-tab" class="tab" type="button" role="tab" aria-selected="true" aria-controls="preview-frame">WebRTC</button>
<button id="hls-tab" class="tab" type="button" role="tab" aria-selected="false" aria-controls="preview-frame">HLS</button>
</div>
<div class="preview-layout">
<div class="preview-frame-wrap">
<iframe id="preview-frame" title="MediaMTX 视频预览" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" referrerpolicy="no-referrer"></iframe>
<p id="preview-help" class="helper">WebRTC 低延迟优先;连接失败时可切换到 HLS。</p>
</div>
<aside class="output-panel" aria-labelledby="output-heading">
<h3 id="output-heading">输出地址</h3>
<div id="output-addresses"></div>
</aside>
</div>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+3
View File
@@ -127,6 +127,9 @@ func (a *API) Initialize() error {
router.Use(a.middlewarePreflightRequests)
router.Use(a.middlewareAuth)
router.GET("/admin", a.onAdminRedirect)
router.GET("/admin/*path", a.onAdminAsset)
group := router.Group("/v3")
group.GET("/info", a.onInfo)
+65
View File
@@ -0,0 +1,65 @@
package api //nolint:revive
import (
"embed"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed admin/*
var adminAssets embed.FS
var adminContentTypes = map[string]string{
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
}
func setAdminSecurityHeaders(ctx *gin.Context) {
ctx.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; "+
"connect-src 'self'; frame-src http: https:; img-src 'self' data:; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'")
ctx.Header("Referrer-Policy", "no-referrer")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header("X-Frame-Options", "SAMEORIGIN")
}
func (a *API) onAdminRedirect(ctx *gin.Context) {
ctx.Redirect(http.StatusMovedPermanently, "/admin/")
}
func (a *API) onAdminAsset(ctx *gin.Context) {
assetPath := strings.TrimPrefix(ctx.Param("path"), "/")
if assetPath == "" {
assetPath = "index.html"
}
assetPath = path.Clean(assetPath)
if assetPath == "." || strings.HasPrefix(assetPath, "../") {
ctx.Status(http.StatusNotFound)
return
}
byts, err := adminAssets.ReadFile("admin/" + assetPath)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
contentType, ok := adminContentTypes[path.Ext(assetPath)]
if !ok {
ctx.Status(http.StatusNotFound)
return
}
setAdminSecurityHeaders(ctx)
if assetPath == "index.html" {
ctx.Header("Cache-Control", "no-store")
} else {
ctx.Header("Cache-Control", "no-cache")
}
ctx.Data(http.StatusOK, contentType, byts)
}
+122
View File
@@ -0,0 +1,122 @@
package api //nolint:revive
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/bluenviron/mediamtx/internal/auth"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/test"
)
const adminTestAddress = "localhost:9996"
func newAdminTestAPI(t *testing.T, authManager apiAuthManager) *API {
t.Helper()
api := &API{
Address: adminTestAddress,
ReadTimeout: conf.Duration(10 * time.Second),
WriteTimeout: conf.Duration(10 * time.Second),
AuthManager: authManager,
Parent: &testParent{},
}
require.NoError(t, api.Initialize())
t.Cleanup(api.Close)
return api
}
func readAdminResponse(t *testing.T, client *http.Client, url string) (*http.Response, string) {
t.Helper()
res, err := client.Get(url)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
return res, string(body)
}
func TestAdminAssets(t *testing.T) {
newAdminTestAPI(t, test.NilAuthManager)
transport := &http.Transport{}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
res, body := readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/")
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "text/html; charset=utf-8", res.Header.Get("Content-Type"))
require.Equal(t, "no-store", res.Header.Get("Cache-Control"))
require.Contains(t, res.Header.Get("Content-Security-Policy"), "object-src 'none'")
require.Equal(t, "nosniff", res.Header.Get("X-Content-Type-Options"))
require.Contains(t, body, "MediaMTX Stream Console")
require.Contains(t, body, "./app.css")
require.Contains(t, body, "./app.js")
require.NotContains(t, body, "https://")
res, body = readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/app.css")
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "text/css; charset=utf-8", res.Header.Get("Content-Type"))
require.Contains(t, body, "prefers-reduced-motion")
require.Contains(t, body, "@media (max-width: 700px)")
res, body = readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/app.js")
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "text/javascript; charset=utf-8", res.Header.Get("Content-Type"))
require.Contains(t, body, "sourceMasked")
require.Contains(t, body, "encodePathName")
require.Contains(t, body, "if (source) payload.source = source")
require.Contains(t, body, "redactSensitiveText")
require.Contains(t, body, "state.global.webrtcAddress")
require.NotContains(t, body, ".innerHTML")
require.NotContains(t, body, "localStorage")
res, _ = readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/missing.js")
require.Equal(t, http.StatusNotFound, res.StatusCode)
}
func TestAdminRedirect(t *testing.T) {
newAdminTestAPI(t, test.NilAuthManager)
transport := &http.Transport{}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
res, _ := readAdminResponse(t, client, "http://"+adminTestAddress+"/admin")
require.Equal(t, http.StatusMovedPermanently, res.StatusCode)
require.Equal(t, "/admin/", res.Header.Get("Location"))
}
func TestAdminUsesAPIAuthentication(t *testing.T) {
var got *auth.Request
newAdminTestAPI(t, &test.AuthManager{
AuthenticateImpl: func(req *auth.Request) (string, *auth.Error) {
got = req
return "", &auth.Error{AskCredentials: true, Wrapped: fmt.Errorf("denied")}
},
})
transport := &http.Transport{}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport}
res, body := readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/")
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
require.Equal(t, `Basic realm="mediamtx"`, res.Header.Get("WWW-Authenticate"))
require.NotNil(t, got)
require.Equal(t, conf.AuthActionAPI, got.Action)
require.True(t, got.EnableAskCredentials)
require.True(t, strings.Contains(body, "authentication error"))
}