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
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:
@@ -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` 等保留地址及虚构账号。
|
||||
|
||||
## 未验证内容
|
||||
|
||||
|
||||
@@ -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. 更新项目档案中的上游基线并回写工单证据。
|
||||
|
||||
## 修改文档
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user