diff --git a/docs/maintainer/3-architecture-and-code-map.md b/docs/maintainer/3-architecture-and-code-map.md index 315e9ef1..055136ef 100644 --- a/docs/maintainer/3-architecture-and-code-map.md +++ b/docs/maintainer/3-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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 个并发槽;完整快照成功后才替换页面数据,再执行统计、筛选和客户端分页。 diff --git a/docs/maintainer/4-product-requirements-overview.md b/docs/maintainer/4-product-requirements-overview.md index e17612cb..df7e3da3 100644 --- a/docs/maintainer/4-product-requirements-overview.md +++ b/docs/maintainer/4-product-requirements-overview.md @@ -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 # 产品需求总览 @@ -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) 为准;本页只维护长期需求索引,不替代单元工单。 diff --git a/docs/maintainer/6-common-changes.md b/docs/maintainer/6-common-changes.md index 1a53ffde..42b7bd94 100644 --- a/docs/maintainer/6-common-changes.md +++ b/docs/maintainer/6-common-changes.md @@ -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 # 常见修改 @@ -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、处理器测试和相关长期文档。 diff --git a/internal/api/admin/app.css b/internal/api/admin/app.css index 03124dcc..e37212e7 100644 --- a/internal/api/admin/app.css +++ b/internal/api/admin/app.css @@ -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; } diff --git a/internal/api/admin/app.js b/internal/api/admin/app.js index 3dbb0568..a9c2fc10 100644 --- a/internal/api/admin/app.js +++ b/internal/api/admin/app.js @@ -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(); - elements.pathsContent.hidden = true; - elements.emptyState.hidden = true; - showPageError(error instanceof APIError ? error : new APIError(0, String(error))); - updateStats([]); + const apiError = normalizeAPIError(error); + if (apiError.status === 401) return; + if (state.configs.length > 0) render(); + else { + elements.pathsContent.hidden = true; + elements.emptyState.hidden = true; + updateStats([]); + } + showPageError(apiError); } finally { setLoading(false); } } function filteredConfigs() { - 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", () => { diff --git a/internal/api/admin/index.html b/internal/api/admin/index.html index 6c3f08d4..7f9e20a9 100644 --- a/internal/api/admin/index.html +++ b/internal/api/admin/index.html @@ -6,6 +6,7 @@ MediaMTX Stream Console + @@ -105,7 +106,7 @@
- 正在加载路径… + 正在加载设备数据…
+ diff --git a/internal/api/admin/list-tools.js b/internal/api/admin/list-tools.js new file mode 100644 index 00000000..16423a52 --- /dev/null +++ b/internal/api/admin/list-tools.js @@ -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, + }; +})); diff --git a/internal/api/api_admin_test.go b/internal/api/api_admin_test.go index e31d973f..87b4e7dd 100644 --- a/internal/api/api_admin_test.go +++ b/internal/api/api_admin_test.go @@ -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) } diff --git a/tests/admin_list_tools_test.js b/tests/admin_list_tools_test.js new file mode 100644 index 00000000..6deb0526 --- /dev/null +++ b/tests/admin_list_tools_test.js @@ -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}`); + } +});