feat(admin): add complete device pagination (#6)
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: 6991cffa6aea712d358645cf0942550bd277175a
|
||||
synchronized_at: 2026-08-28T04:12:48Z
|
||||
wiki_revision: 2b5014fc8d752f58255ebc4318acd8d798bc29be
|
||||
synchronized_at: 2026-08-28T08:45:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -28,6 +28,7 @@ MediaMTX API HTTP 服务 :9997
|
||||
├─ /admin/ 匿名加载内嵌登录外壳与 index.html
|
||||
├─ /admin/app.css 内嵌原生样式
|
||||
├─ /admin/app.js 内嵌原生交互
|
||||
├─ /admin/list-tools.js 分批读取、完整集合与客户端分页工具
|
||||
└─ /v3/... action: api 强制认证的 Control API
|
||||
|
||||
MediaMTX WebRTC :8889/{path} 内置页面负责低延迟预览
|
||||
@@ -42,7 +43,8 @@ MediaMTX HLS :8888/{path}/ 内置页面负责兼容预览
|
||||
- `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`:Basic 登录、当前标签页会话、401 清理、退出,以及配置/状态合并、CRUD、脱敏、筛选和预览。
|
||||
- `internal/api/admin/app.js`:Basic 登录、当前标签页会话、401 清理、退出,以及全量分批加载、配置/状态合并、CRUD、脱敏、筛选、客户端分页和预览。
|
||||
- `internal/api/admin/list-tools.js`:无运行时依赖的列表工具,负责共享并发上限、API 多页完整读取、稳定去重排序、完整统计和分页钳制;Node 测试可直接加载。
|
||||
- `internal/api/api_admin_test.go`:入口、重定向、资源、CSP、鉴权和静态安全约束测试。
|
||||
|
||||
## 主要 API
|
||||
@@ -62,3 +64,4 @@ API 契约以当前仓库 `api/openapi.yaml` 为唯一代码级事实来源。
|
||||
- 管理静态资源只负责展示登录外壳;所有管理数据和写操作仍复用 `/v3` 的 `action: api` 认证边界。
|
||||
- 预览复用上游内置 WebRTC/HLS 页面,避免维护第二套媒体播放实现。
|
||||
- 前端只保留管理静态 RTSP 源需要的最小配置字段;新增字段必须按工单和 API 契约扩展。
|
||||
- 列表 API 固定以每页 100 条分批读取,配置与运行状态请求共用最多 4 个并发槽;完整快照成功后才替换页面数据,再执行统计、筛选和客户端分页。
|
||||
|
||||
@@ -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: 736e3cdfaa941073735475ac5a2b8962ed9ed566
|
||||
synchronized_at: 2026-08-28T04:12:53Z
|
||||
wiki_revision: 2c63353ad6789d3155b136d7f96b8064cc66c36e
|
||||
synchronized_at: 2026-08-28T08:45:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -15,6 +15,7 @@ synchronized_at: 2026-08-28T04:12:53Z
|
||||
| 已完成 | 增量接入 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) |
|
||||
| 实施中 | 管理页轻量登录与 API 认证 | [工单 #4](https://git.ilapage.cn/OPC/mediamtx/issues/4) |
|
||||
| 实现完成、待验收 | 管理页面设备分页与完整统计 | [工单 #6](https://git.ilapage.cn/OPC/mediamtx/issues/6) |
|
||||
|
||||
## 管理页面能力
|
||||
|
||||
@@ -26,6 +27,8 @@ synchronized_at: 2026-08-28T04:12:53Z
|
||||
- 可以复制 RTSP、HLS、WebRTC 输出地址;端口来自当前全局配置。
|
||||
- 保持界面小而直接,不扩展为完整 VMS。
|
||||
- 提供原生轻量登录、当前标签页会话和显式退出;账号权限来自 MediaMTX `authInternalUsers`。
|
||||
- 配置路径与运行路径按每批 100 条完整读取,并以共享并发上限 4 控制请求;统计、搜索和状态筛选覆盖全部设备。
|
||||
- 浏览器默认每页渲染 50 条,可切换 25 / 50 / 100,并显示范围、总数、当前页及上一页/下一页。
|
||||
|
||||
## 已确认设计基线
|
||||
|
||||
@@ -57,4 +60,4 @@ synchronized_at: 2026-08-28T04:12:53Z
|
||||
|
||||
## 验收入口
|
||||
|
||||
管理页面主体以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准,登录与认证以 [#4](https://git.ilapage.cn/OPC/mediamtx/issues/4) 为准;本页只维护长期需求索引,不替代单元工单。
|
||||
管理页面主体以 [#3](https://git.ilapage.cn/OPC/mediamtx/issues/3) 为准,登录与认证以 [#4](https://git.ilapage.cn/OPC/mediamtx/issues/4) 为准,分页与完整统计以 [#6](https://git.ilapage.cn/OPC/mediamtx/issues/6) 为准;本页只维护长期需求索引,不替代单元工单。
|
||||
|
||||
@@ -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: 713c7047d63271c8b345c163c3582d2caa20a2e2
|
||||
synchronized_at: 2026-08-28T03:23:37Z
|
||||
wiki_revision: dad147556fe40e39f0d73131830f8b38ce8e9d55
|
||||
synchronized_at: 2026-08-28T08:45:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 常见修改
|
||||
@@ -26,6 +26,15 @@ synchronized_at: 2026-08-28T03:23:37Z
|
||||
- 动态内容使用 `textContent` / DOM API,不把路径名、URL 或错误直接注入 `innerHTML`。
|
||||
- 新增媒体预览能力时优先复用 MediaMTX 内置播放页,不让管理页代理媒体流。
|
||||
|
||||
## 调整管理页设备列表与分页
|
||||
|
||||
- 配置路径和运行路径都必须按 Control API 分页完整读取;当前基线为每批 100 条、共享并发上限 4。
|
||||
- 完整快照成功后才能替换页面数据;任一批次失败不得提交部分统计或部分列表。
|
||||
- 先在全部静态 RTSP 配置上计算统计和筛选,再做客户端分页;分页只限制 DOM 渲染数量。
|
||||
- 默认每页 50 条,可选 25 / 50 / 100;搜索、状态或每页数量变化回到第一页,CRUD 与刷新后钳制到有效页。
|
||||
- 修改此流程时覆盖 0、边界数量、至少 235 条、多页失败、共享并发上限和窄屏无溢出场景。
|
||||
- 保持原生 HTML/CSS/JavaScript 和安全 DOM API,不引入前端运行时依赖。
|
||||
|
||||
## 调整 Control API
|
||||
|
||||
API 属于中风险变更。必须先建工单并确认兼容性,不得只改前端假定接口。实现后更新 OpenAPI、处理器测试和相关长期文档。
|
||||
|
||||
@@ -530,6 +530,48 @@ tbody tr:last-child td {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 52px;
|
||||
padding: 4px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--secondary);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pagination-summary {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pagination-controls,
|
||||
.pagination-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-controls label > span,
|
||||
.pagination-page {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination-controls select {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.pagination-page {
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: none;
|
||||
}
|
||||
@@ -845,6 +887,40 @@ tbody tr:last-child td {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.pagination-summary {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-controls label {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pagination-controls select {
|
||||
min-width: 64px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.pagination-controls .button {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.pagination-page {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+107
-24
@@ -1,6 +1,10 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const ListTools = window.MediaMTXAdminList;
|
||||
const API_PAGE_SIZE = 100;
|
||||
const API_PAGE_CONCURRENCY = 4;
|
||||
|
||||
const API = {
|
||||
info: "/v3/info",
|
||||
configs: "/v3/config/paths/list",
|
||||
@@ -21,6 +25,8 @@
|
||||
loading: false,
|
||||
toastTimer: null,
|
||||
authorization: "",
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
};
|
||||
|
||||
const elements = {};
|
||||
@@ -288,7 +294,7 @@
|
||||
|
||||
function showPageError(error) {
|
||||
elements.pageAlert.hidden = false;
|
||||
elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法加载路径";
|
||||
elements.pageAlertTitle.textContent = error.status === 403 ? "没有操作权限" : "无法完整加载设备";
|
||||
elements.pageAlertMessage.textContent = error.message;
|
||||
setAPIStatus("error", error.status === 403 ? "API 权限不足" : "API 连接失败");
|
||||
}
|
||||
@@ -306,58 +312,98 @@
|
||||
if (loading) {
|
||||
elements.pathsContent.hidden = true;
|
||||
elements.emptyState.hidden = true;
|
||||
elements.loadingText.textContent = "正在加载设备数据…";
|
||||
setAPIStatus("loading", "正在连接 API");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAPIError(error) {
|
||||
if (error instanceof APIError) return error;
|
||||
const status = Number.isInteger(error?.status) ? error.status : 0;
|
||||
return new APIError(status, redactSensitiveText(error?.message || String(error)));
|
||||
}
|
||||
|
||||
function loadingProgressReporter() {
|
||||
const resources = new Map();
|
||||
return (progress) => {
|
||||
if (!state.loading) return;
|
||||
resources.set(progress.label, progress);
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const item of resources.values()) {
|
||||
loaded += item.loaded;
|
||||
total += item.total;
|
||||
}
|
||||
const message = total > 0
|
||||
? `正在分批加载数据 ${Math.min(loaded, total)} / ${total}…`
|
||||
: "正在加载设备数据…";
|
||||
elements.loadingText.textContent = message;
|
||||
setAPIStatus("loading", total > 0 ? `正在加载 ${Math.min(loaded, total)}/${total}` : "正在连接 API");
|
||||
};
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (state.loading) return;
|
||||
setLoading(true);
|
||||
hidePageError();
|
||||
|
||||
try {
|
||||
const reportProgress = loadingProgressReporter();
|
||||
const pagedFetch = ListTools.limitConcurrency(apiFetch, API_PAGE_CONCURRENCY);
|
||||
const [configData, pathData, globalData] = await Promise.all([
|
||||
apiFetch(API.configs),
|
||||
apiFetch(API.paths),
|
||||
ListTools.fetchAllPages(pagedFetch, API.configs, {
|
||||
label: "设备配置",
|
||||
pageSize: API_PAGE_SIZE,
|
||||
concurrency: API_PAGE_CONCURRENCY,
|
||||
onProgress: reportProgress,
|
||||
}),
|
||||
ListTools.fetchAllPages(pagedFetch, API.paths, {
|
||||
label: "运行状态",
|
||||
pageSize: API_PAGE_SIZE,
|
||||
concurrency: API_PAGE_CONCURRENCY,
|
||||
onProgress: reportProgress,
|
||||
}),
|
||||
apiFetch(API.global).catch(() => ({})),
|
||||
]);
|
||||
|
||||
state.configs = (configData.items || [])
|
||||
const configs = ListTools.uniqueSortedByName(configData.items)
|
||||
.filter(isRTSPSource)
|
||||
.map(scrubConfig);
|
||||
state.runtime = new Map((pathData.items || []).map((item) => [item.name, item]));
|
||||
const runtimeItems = ListTools.uniqueSortedByName(pathData.items);
|
||||
|
||||
state.configs = configs;
|
||||
state.runtime = new Map(runtimeItems.map((item) => [item.name, item]));
|
||||
state.global = globalData || {};
|
||||
|
||||
setAPIStatus("ok", "API 已连接");
|
||||
render();
|
||||
} catch (error) {
|
||||
if (error instanceof APIError && error.status === 401) return;
|
||||
state.configs = [];
|
||||
state.runtime = new Map();
|
||||
const apiError = normalizeAPIError(error);
|
||||
if (apiError.status === 401) return;
|
||||
if (state.configs.length > 0) render();
|
||||
else {
|
||||
elements.pathsContent.hidden = true;
|
||||
elements.emptyState.hidden = true;
|
||||
showPageError(error instanceof APIError ? error : new APIError(0, String(error)));
|
||||
updateStats([]);
|
||||
}
|
||||
showPageError(apiError);
|
||||
} 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;
|
||||
});
|
||||
return ListTools.filterConfigs(
|
||||
state.configs,
|
||||
elements.searchInput.value,
|
||||
elements.statusFilter.value,
|
||||
(config) => getPathState(config).key,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const counts = ListTools.countStatuses(configs, (config) => getPathState(config).key);
|
||||
elements.statTotal.textContent = String(counts.total);
|
||||
elements.statOnline.textContent = String(counts.online);
|
||||
elements.statOffline.textContent = String(counts.offline);
|
||||
elements.statIdle.textContent = String(counts.idle);
|
||||
@@ -429,6 +475,16 @@
|
||||
elements.cardList.replaceChildren(cards);
|
||||
}
|
||||
|
||||
function updatePagination(pageData) {
|
||||
state.currentPage = pageData.page;
|
||||
state.pageSize = pageData.pageSize;
|
||||
elements.pageSize.value = String(pageData.pageSize);
|
||||
elements.paginationSummary.textContent = `${pageData.start}–${pageData.end} / 共 ${pageData.totalItems} 条`;
|
||||
elements.paginationPage.textContent = `${pageData.page} / ${pageData.totalPages} 页`;
|
||||
elements.previousPage.disabled = pageData.page <= 1;
|
||||
elements.nextPage.disabled = pageData.page >= pageData.totalPages;
|
||||
}
|
||||
|
||||
function render() {
|
||||
updateStats(state.configs);
|
||||
const configs = filteredConfigs();
|
||||
@@ -445,10 +501,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const pageData = ListTools.paginateItems(configs, state.currentPage, state.pageSize);
|
||||
elements.emptyState.hidden = true;
|
||||
elements.pathsContent.hidden = false;
|
||||
renderTable(configs);
|
||||
renderCards(configs);
|
||||
renderTable(pageData.items);
|
||||
renderCards(pageData.items);
|
||||
updatePagination(pageData);
|
||||
}
|
||||
|
||||
function transportLabel(value) {
|
||||
@@ -736,6 +794,12 @@
|
||||
pathsContent: byID("paths-content"),
|
||||
tableBody: byID("paths-table-body"),
|
||||
cardList: byID("paths-card-list"),
|
||||
paginationSummary: byID("pagination-summary"),
|
||||
paginationPage: byID("pagination-page"),
|
||||
pageSize: byID("page-size"),
|
||||
previousPage: byID("previous-page"),
|
||||
nextPage: byID("next-page"),
|
||||
loadingText: byID("loading-text"),
|
||||
pathDialog: byID("path-dialog"),
|
||||
pathForm: byID("path-form"),
|
||||
pathDialogTitle: byID("path-dialog-title"),
|
||||
@@ -776,8 +840,27 @@
|
||||
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.searchInput.addEventListener("input", () => {
|
||||
state.currentPage = 1;
|
||||
render();
|
||||
});
|
||||
elements.statusFilter.addEventListener("change", () => {
|
||||
state.currentPage = 1;
|
||||
render();
|
||||
});
|
||||
elements.pageSize.addEventListener("change", () => {
|
||||
state.pageSize = Number(elements.pageSize.value);
|
||||
state.currentPage = 1;
|
||||
render();
|
||||
});
|
||||
elements.previousPage.addEventListener("click", () => {
|
||||
state.currentPage -= 1;
|
||||
render();
|
||||
});
|
||||
elements.nextPage.addEventListener("click", () => {
|
||||
state.currentPage += 1;
|
||||
render();
|
||||
});
|
||||
elements.pathForm.addEventListener("submit", submitPath);
|
||||
elements.deleteForm.addEventListener("submit", submitDelete);
|
||||
elements.sourceToggle.addEventListener("click", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>MediaMTX Stream Console</title>
|
||||
<link rel="stylesheet" href="./app.css">
|
||||
<script src="./list-tools.js" defer></script>
|
||||
<script src="./app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -105,7 +106,7 @@
|
||||
|
||||
<div id="loading-state" class="loading-state" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>正在加载路径…</span>
|
||||
<span id="loading-text">正在加载设备数据…</span>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state" hidden>
|
||||
@@ -131,6 +132,22 @@
|
||||
</table>
|
||||
</div>
|
||||
<div id="paths-card-list" class="card-list"></div>
|
||||
<nav id="path-pagination" class="pagination" aria-label="设备列表分页">
|
||||
<p id="pagination-summary" class="pagination-summary" aria-live="polite"></p>
|
||||
<div class="pagination-controls">
|
||||
<label for="page-size">
|
||||
<span>每页</span>
|
||||
<select id="page-size">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="previous-page" class="button button-secondary" type="button">上一页</button>
|
||||
<span id="pagination-page" class="pagination-page"></span>
|
||||
<button id="next-page" class="button button-secondary" type="button">下一页</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
(function initMediaMTXAdminList(root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.MediaMTXAdminList = api;
|
||||
}(typeof globalThis !== "undefined" ? globalThis : this, () => {
|
||||
"use strict";
|
||||
|
||||
class PageLoadError extends Error {
|
||||
constructor(label, page, cause) {
|
||||
const detail = cause?.message ? `:${cause.message}` : "。";
|
||||
super(`${label}第 ${page + 1} 页加载失败${detail}`);
|
||||
this.name = "PageLoadError";
|
||||
this.status = Number.isInteger(cause?.status) ? cause.status : 0;
|
||||
this.page = page;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
function limitConcurrency(fetcher, requestedLimit) {
|
||||
const limit = Math.max(1, Number(requestedLimit) || 1);
|
||||
let active = 0;
|
||||
const queue = [];
|
||||
|
||||
function runNext() {
|
||||
while (active < limit && queue.length > 0) {
|
||||
const task = queue.shift();
|
||||
active += 1;
|
||||
Promise.resolve()
|
||||
.then(() => fetcher(...task.args))
|
||||
.then(task.resolve, task.reject)
|
||||
.finally(() => {
|
||||
active -= 1;
|
||||
runNext();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (...args) => new Promise((resolve, reject) => {
|
||||
queue.push({ args, resolve, reject });
|
||||
runNext();
|
||||
});
|
||||
}
|
||||
|
||||
function pageURL(endpoint, page, pageSize) {
|
||||
const separator = endpoint.includes("?") ? "&" : "?";
|
||||
const query = new URLSearchParams({
|
||||
itemsPerPage: String(pageSize),
|
||||
page: String(page),
|
||||
});
|
||||
return `${endpoint}${separator}${query}`;
|
||||
}
|
||||
|
||||
function validatePage(payload, label, page) {
|
||||
if (!payload || !Array.isArray(payload.items)
|
||||
|| !Number.isInteger(payload.itemCount) || payload.itemCount < 0
|
||||
|| !Number.isInteger(payload.pageCount) || payload.pageCount < 0) {
|
||||
throw new PageLoadError(label, page, new Error("API 返回了无效的分页数据。"));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchPage(fetcher, endpoint, label, page, pageSize) {
|
||||
try {
|
||||
const payload = await fetcher(pageURL(endpoint, page, pageSize));
|
||||
return validatePage(payload, label, page);
|
||||
} catch (error) {
|
||||
if (error instanceof PageLoadError) throw error;
|
||||
throw new PageLoadError(label, page, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllPages(fetcher, endpoint, options = {}) {
|
||||
const label = options.label || "列表";
|
||||
const pageSize = options.pageSize || 100;
|
||||
const concurrency = Math.max(1, options.concurrency || 4);
|
||||
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
|
||||
|
||||
const first = await fetchPage(fetcher, endpoint, label, 0, pageSize);
|
||||
const pageCount = first.pageCount;
|
||||
const pages = new Array(Math.max(1, pageCount));
|
||||
pages[0] = first.items;
|
||||
let loaded = first.items.length;
|
||||
let pagesLoaded = 1;
|
||||
let stopped = false;
|
||||
onProgress({ label, loaded, total: first.itemCount, pagesLoaded, pageCount });
|
||||
|
||||
let nextPage = 1;
|
||||
const workerCount = Math.min(concurrency, Math.max(0, pageCount - 1));
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (!stopped && nextPage < pageCount) {
|
||||
const page = nextPage;
|
||||
nextPage += 1;
|
||||
let payload;
|
||||
try {
|
||||
payload = await fetchPage(fetcher, endpoint, label, page, pageSize);
|
||||
} catch (error) {
|
||||
stopped = true;
|
||||
throw error;
|
||||
}
|
||||
if (stopped) return;
|
||||
pages[page] = payload.items;
|
||||
loaded += payload.items.length;
|
||||
pagesLoaded += 1;
|
||||
onProgress({ label, loaded, total: first.itemCount, pagesLoaded, pageCount });
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
return {
|
||||
items: pages.flat(),
|
||||
itemCount: first.itemCount,
|
||||
pageCount,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueSortedByName(items) {
|
||||
const byName = new Map();
|
||||
for (const item of items) {
|
||||
const name = String(item?.name || "");
|
||||
if (name) byName.set(name, item);
|
||||
}
|
||||
return Array.from(byName.values()).sort((left, right) => {
|
||||
const leftName = String(left.name);
|
||||
const rightName = String(right.name);
|
||||
if (leftName < rightName) return -1;
|
||||
if (leftName > rightName) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function filterConfigs(items, query, statusFilter, getStatus) {
|
||||
const normalizedQuery = String(query || "").trim().toLocaleLowerCase();
|
||||
return items.filter((item) => {
|
||||
const matchesQuery = !normalizedQuery
|
||||
|| String(item.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
|| String(item.sourceMasked).toLocaleLowerCase().includes(normalizedQuery);
|
||||
const matchesStatus = statusFilter === "all" || getStatus(item) === statusFilter;
|
||||
return matchesQuery && matchesStatus;
|
||||
});
|
||||
}
|
||||
|
||||
function countStatuses(items, getStatus) {
|
||||
const result = { total: items.length, online: 0, offline: 0, idle: 0 };
|
||||
for (const item of items) {
|
||||
const status = getStatus(item);
|
||||
if (Object.prototype.hasOwnProperty.call(result, status)) result[status] += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function paginateItems(items, requestedPage, requestedPageSize) {
|
||||
const pageSize = [25, 50, 100].includes(Number(requestedPageSize))
|
||||
? Number(requestedPageSize)
|
||||
: 50;
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
||||
const page = Math.min(Math.max(1, Number(requestedPage) || 1), totalPages);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const pageItems = items.slice(startIndex, startIndex + pageSize);
|
||||
return {
|
||||
items: pageItems,
|
||||
page,
|
||||
pageSize,
|
||||
totalItems: items.length,
|
||||
totalPages,
|
||||
start: pageItems.length === 0 ? 0 : startIndex + 1,
|
||||
end: pageItems.length === 0 ? 0 : startIndex + pageItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
PageLoadError,
|
||||
countStatuses,
|
||||
fetchAllPages,
|
||||
filterConfigs,
|
||||
limitConcurrency,
|
||||
pageURL,
|
||||
paginateItems,
|
||||
uniqueSortedByName,
|
||||
};
|
||||
}));
|
||||
@@ -66,6 +66,7 @@ func TestAdminAssets(t *testing.T) {
|
||||
require.Contains(t, body, `id="login-form"`)
|
||||
require.Contains(t, body, `id="logout-button"`)
|
||||
require.Contains(t, body, "./app.css")
|
||||
require.Contains(t, body, "./list-tools.js")
|
||||
require.Contains(t, body, "./app.js")
|
||||
require.NotContains(t, body, "https://")
|
||||
require.Zero(t, authCalls)
|
||||
@@ -89,6 +90,14 @@ func TestAdminAssets(t *testing.T) {
|
||||
require.NotContains(t, body, ".innerHTML")
|
||||
require.NotContains(t, body, "localStorage")
|
||||
|
||||
res, body = readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/list-tools.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, "fetchAllPages")
|
||||
require.Contains(t, body, "API 返回了无效的分页数据")
|
||||
require.Contains(t, body, "concurrency")
|
||||
require.NotContains(t, body, ".innerHTML")
|
||||
|
||||
res, _ = readAdminResponse(t, client, "http://"+adminTestAddress+"/admin/missing.js")
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const ListTools = require("../internal/api/admin/list-tools.js");
|
||||
|
||||
function devices(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
name: `device-${String(index + 1).padStart(3, "0")}`,
|
||||
sourceMasked: `rtsp://camera.invalid/${index + 1}`,
|
||||
status: ["online", "offline", "idle"][index % 3],
|
||||
}));
|
||||
}
|
||||
|
||||
function paginatedFetcher(items, options = {}) {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const calls = [];
|
||||
const fetcher = async (url) => {
|
||||
const parsed = new URL(url, "http://localhost");
|
||||
const page = Number(parsed.searchParams.get("page"));
|
||||
const pageSize = Number(parsed.searchParams.get("itemsPerPage"));
|
||||
calls.push({ page, pageSize });
|
||||
if (page === options.failPage) {
|
||||
const error = new Error("测试页不可用。");
|
||||
error.status = 503;
|
||||
throw error;
|
||||
}
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, options.delay || 0));
|
||||
active -= 1;
|
||||
return {
|
||||
itemCount: items.length,
|
||||
pageCount: Math.ceil(items.length / pageSize),
|
||||
items: items.slice(page * pageSize, (page + 1) * pageSize),
|
||||
};
|
||||
};
|
||||
return { fetcher, calls, maxActive: () => maxActive };
|
||||
}
|
||||
|
||||
test("fetchAllPages loads all 235 items in API pages of 100", async () => {
|
||||
const source = devices(235);
|
||||
const mock = paginatedFetcher(source);
|
||||
const progress = [];
|
||||
const result = await ListTools.fetchAllPages(mock.fetcher, "/v3/config/paths/list", {
|
||||
label: "设备配置",
|
||||
pageSize: 100,
|
||||
concurrency: 4,
|
||||
onProgress: (value) => progress.push(value),
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 235);
|
||||
assert.equal(result.itemCount, 235);
|
||||
assert.equal(result.pageCount, 3);
|
||||
assert.deepEqual(mock.calls.map((call) => call.page).sort((a, b) => a - b), [0, 1, 2]);
|
||||
assert.ok(mock.calls.every((call) => call.pageSize === 100));
|
||||
assert.equal(progress.at(-1).loaded, 235);
|
||||
assert.equal(progress.at(-1).total, 235);
|
||||
});
|
||||
|
||||
test("fetchAllPages honors the concurrency limit", async () => {
|
||||
const mock = paginatedFetcher(devices(701), { delay: 5 });
|
||||
await ListTools.fetchAllPages(mock.fetcher, "/v3/paths/list", {
|
||||
pageSize: 100,
|
||||
concurrency: 3,
|
||||
});
|
||||
assert.ok(mock.maxActive() <= 3);
|
||||
assert.ok(mock.maxActive() >= 2);
|
||||
});
|
||||
|
||||
test("limitConcurrency bounds requests shared by multiple resources", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const limited = ListTools.limitConcurrency(async (value) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
return value;
|
||||
}, 4);
|
||||
|
||||
const values = await Promise.all(Array.from({ length: 12 }, (_, index) => limited(index)));
|
||||
assert.deepEqual(values, Array.from({ length: 12 }, (_, index) => index));
|
||||
assert.equal(maxActive, 4);
|
||||
});
|
||||
|
||||
test("fetchAllPages reports the failed resource page without returning partial data", async () => {
|
||||
const mock = paginatedFetcher(devices(235), { failPage: 2 });
|
||||
await assert.rejects(
|
||||
ListTools.fetchAllPages(mock.fetcher, "/v3/paths/list", {
|
||||
label: "运行状态",
|
||||
pageSize: 100,
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.name, "PageLoadError");
|
||||
assert.equal(error.status, 503);
|
||||
assert.equal(error.page, 2);
|
||||
assert.match(error.message, /运行状态第 3 页加载失败/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("uniqueSortedByName removes duplicates and sorts deterministically", () => {
|
||||
const result = ListTools.uniqueSortedByName([
|
||||
{ name: "camera-2", value: "old" },
|
||||
{ name: "camera-1" },
|
||||
{ name: "camera-2", value: "new" },
|
||||
{ name: "" },
|
||||
]);
|
||||
assert.deepEqual(result, [
|
||||
{ name: "camera-1" },
|
||||
{ name: "camera-2", value: "new" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("complete statistics and filters cover all devices before pagination", () => {
|
||||
const source = devices(235);
|
||||
const getStatus = (item) => item.status;
|
||||
assert.deepEqual(ListTools.countStatuses(source, getStatus), {
|
||||
total: 235,
|
||||
online: 79,
|
||||
offline: 78,
|
||||
idle: 78,
|
||||
});
|
||||
|
||||
const idle = ListTools.filterConfigs(source, "camera.invalid", "idle", getStatus);
|
||||
assert.equal(idle.length, 78);
|
||||
assert.deepEqual(
|
||||
ListTools.filterConfigs(source, "device-235", "all", getStatus).map((item) => item.name),
|
||||
["device-235"],
|
||||
);
|
||||
});
|
||||
|
||||
test("paginateItems handles empty, boundary and clamped pages", () => {
|
||||
assert.deepEqual(ListTools.paginateItems([], 9, 50), {
|
||||
items: [], page: 1, pageSize: 50, totalItems: 0, totalPages: 1, start: 0, end: 0,
|
||||
});
|
||||
|
||||
const source = devices(235);
|
||||
const last = ListTools.paginateItems(source, 5, 50);
|
||||
assert.equal(last.items.length, 35);
|
||||
assert.equal(last.start, 201);
|
||||
assert.equal(last.end, 235);
|
||||
assert.equal(last.totalPages, 5);
|
||||
|
||||
const clamped = ListTools.paginateItems(source.slice(0, 100), 5, 50);
|
||||
assert.equal(clamped.page, 2);
|
||||
assert.equal(clamped.start, 51);
|
||||
assert.equal(clamped.end, 100);
|
||||
|
||||
const invalidSize = ListTools.paginateItems(source, 1, 17);
|
||||
assert.equal(invalidSize.pageSize, 50);
|
||||
});
|
||||
|
||||
test("paginateItems covers the documented device-count boundaries", () => {
|
||||
const expectations = [
|
||||
{ count: 0, pages: 1, lastPageItems: 0 },
|
||||
{ count: 1, pages: 1, lastPageItems: 1 },
|
||||
{ count: 50, pages: 1, lastPageItems: 50 },
|
||||
{ count: 51, pages: 2, lastPageItems: 1 },
|
||||
{ count: 100, pages: 2, lastPageItems: 50 },
|
||||
{ count: 101, pages: 3, lastPageItems: 1 },
|
||||
{ count: 235, pages: 5, lastPageItems: 35 },
|
||||
];
|
||||
|
||||
for (const expectation of expectations) {
|
||||
const result = ListTools.paginateItems(devices(expectation.count), 999, 50);
|
||||
assert.equal(result.totalPages, expectation.pages, `count ${expectation.count}`);
|
||||
assert.equal(result.page, expectation.pages, `count ${expectation.count}`);
|
||||
assert.equal(result.items.length, expectation.lastPageItems, `count ${expectation.count}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user