diff --git a/TEST.md b/TEST.md index 61099dca..e80906c7 100644 --- a/TEST.md +++ b/TEST.md @@ -54,7 +54,7 @@ ## T13 资源节点任务管理器曲线 -阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:system-status`、`provider:process-resource-status`、`frontend:system-monitor-visible` 和 `frontend:process-resource-sorting` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `资源监控` 子标签,确认可以像 Windows 任务管理器一样看到 CPU、Memory、Disk 当前用量和历史曲线,Memory 明确显示为不含 Linux page cache / buffer 的实际内存占用;确认 `进程资源占用` 表默认按内存 RSS 降序,能点击 CPU、PID、用户、磁盘 I/O 等表头切换排序,且只通过 `查看原始JSON` 查看完整进程快照;最后确认能执行 `Provider Gateway 升级` 的 `预检升级`。 +阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts e2e run`,确认 `provider:system-status`、`provider:process-resource-status`、`frontend:system-monitor-visible` 和 `frontend:process-resource-sorting` passed;再用浏览器登录 frontend,进入左侧 `资源节点` 和顶部 `资源监控` 子标签,确认可以像 Windows 任务管理器一样看到 CPU、Memory、Disk 当前用量和历史曲线,Memory 明确显示为不含 Linux page cache / buffer 的实际内存占用;确认 `进程资源占用` 表默认按内存 PSS 降序且保留 RSS 展示,避免 PostgreSQL shared buffer 等共享页被多个进程重复计入,能点击 CPU、PID、用户、磁盘 I/O 等表头切换排序,且只通过 `查看原始JSON` 查看完整进程快照;最后确认能执行 `Provider Gateway 升级` 的 `预检升级`。 ## T13A 通用性能面板 diff --git a/docs/reference/e2e.md b/docs/reference/e2e.md index 5051e45b..1013ccde 100644 --- a/docs/reference/e2e.md +++ b/docs/reference/e2e.md @@ -33,7 +33,7 @@ Typical targeted commands: - Public exposure: Docker port summary must not show core REST, Code Queue NodePort, or Code Queue public host mappings; frontend and provider ingress are the only browser/provider public entries. PostgreSQL `15432` and OA Event Flow `4255` may be host-mapped only for controlled Code Queue nodes and must be protected by the `DOCKER-USER` source restrictions generated from `network.restrictedHostAccess`; E2E treats either an unreachable generic probe or a verified restricted rule as passing. Known private user-service ports such as FindJob `3254`, MET Nonlinear `3288`, Todo Note `4211`, legacy Code Queue host ports and File Browser provider port `4251` probes must fail. - Core API: `docker exec unidesk-backend-core` calls internal `GET /api/overview`, which must report `dbReady: true`, `pgdata.volumeName=unidesk_pgdata_10gb`, a positive PostgreSQL database byte count, and at least one online node; internal `GET /api/performance` must report component request statistics, internal operation statistics, PGDATA usage and Code Queue PostgreSQL storage metadata. -- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json`, `labels.providerGatewayUpgradePolicy: "always-enabled"`, `labels.providerGatewayRestartPolicyOk: true`, `labels.providerGatewayPidModeOk: true`, and `labels.providerGatewayRuntimeGuardOk: true`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples plus a non-empty process resource list sorted by memory by default; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; every running `provider-gateway` container visible in Docker snapshots must report `restartPolicy: "always"` and `pidMode: "host"`; public provider ingress `/health` must return ok. +- Provider self-connection: internal `GET /api/nodes` must contain `main-server` with `status: online`, `labels.providerGatewayVersion` equal to `src/components/provider-gateway/package.json`, `labels.providerGatewayUpgradePolicy: "always-enabled"`, `labels.providerGatewayRestartPolicyOk: true`, `labels.providerGatewayPidModeOk: true`, and `labels.providerGatewayRuntimeGuardOk: true`; internal `GET /api/nodes/system-status` must contain CPU/memory/disk samples plus a non-empty process resource list sorted by `memoryBytes` by default, where `memoryBytes` should use PSS when `/proc/[pid]/smaps_rollup` is available and must retain `rssBytes` for diagnostics; internal `GET /api/nodes/docker-status` must contain a Docker snapshot for `main-server`; every running `provider-gateway` container visible in Docker snapshots must report `restartPolicy: "always"` and `pidMode: "host"`; public provider ingress `/health` must return ok. - Provider remote control: internal `/api/dispatch` must successfully complete a real `provider.upgrade` task in `mode: "plan"` so the upgrade path is validated without recreating the running gateway during E2E. - User services: internal `/api/microservices` must include `todo-note` and `oa-event-flow` on `main-server`, canonical `filebrowser` on `D518`, plus `k3sctl-adapter`, `code-queue`, `findjob`, `pipeline`, `met-nonlinear`, `claudeqq` and `filebrowser-d601` on `D601` with `public=false`; `/api/microservices/todo-note/health` must report `storage=postgres`, `/api/microservices/todo-note/proxy/api/instances` must expose the migrated Todo Note lists, and a temporary Todo Note list create/add/toggle/undo/delete cycle must succeed through the real provider-gateway proxy; `/api/microservices/oa-event-flow/health`, `/api/microservices/oa-event-flow/proxy/api/diagnostics`, `/api/microservices/oa-event-flow/proxy/api/events`, `/api/microservices/oa-event-flow/proxy/api/events?tags=service:pipeline` and `/api/microservices/oa-event-flow/proxy/api/stats/trace` must prove the independent OA event table、Pipeline bridge 和 stats center are reachable through UniDesk proxy; `/api/microservices/k3sctl-adapter/health` and `/api/microservices/k3sctl-adapter/proxy/api/control-plane` must expose the D601 `unidesk-k3s` control plane, `kubeApiProxy.mode=kubernetes-api-service-proxy`, D601 active Code Queue instance `servingHealthy=true`, `presentNodeIds` containing `D601`, `missingNodeIds=[]`, `status=healthy`, and `noFallback=true`; `/api/microservices/code-queue/health` must return the active Code Queue backend summary with default model `gpt-5.5`, `egressProxy.connected=true`, and `/api/microservices/code-queue/proxy/api/tasks/overview` must return queue state through backend-core -> k3sctl-adapter -> Kubernetes API service proxy -> k3s/k8s Service, not through a `serviceId=code-queue` provider-gateway direct task or `/api/code-queue-direct`; `/api/microservices/filebrowser/health`, `/api/microservices/filebrowser-d601/health` and `/api/microservices/filebrowser/proxy/` must prove File Browser health and WebUI access through UniDesk proxy; `/api/microservices/findjob/health` and `/api/microservices/findjob/proxy/api/summary` must succeed through the real provider-gateway proxy; `/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5` must return a bounded preview with `_unidesk.arrayLimits` metadata; `/api/microservices/pipeline/health`, `/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3` and `/api/microservices/pipeline/proxy/api/oa-event-flow/diagnostics` must return Pipeline health, registry/run previews and OA event-flow evidence; `/api/microservices/met-nonlinear/health`, `/api/microservices/met-nonlinear/proxy/api/queue`, `/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects?root=ex_projects&limit=500`, `/api/microservices/met-nonlinear/proxy/api/projects/config?path=` and `/api/microservices/met-nonlinear/proxy/api/images` must return the D601 TS backend health, queue/GPU policy, full project tree inputs, structured project detail and ready `met-nonlinear-ml:tf26` image status. - ClaudeQQ availability: `/api/microservices/claudeqq/health` must only pass when `ready=true`, NapCat HTTP and WebSocket are connected, and `napcat.loginState=logged_in`; `/api/microservices/claudeqq/proxy/api/napcat/login` must show the same logged-in account state and `/api/microservices/claudeqq/proxy/api/events/recent` must prove the backend can read the persistent event cache. A QR-code-only or not-logged-in NapCat state must be treated as unhealthy. diff --git a/docs/reference/frontend.md b/docs/reference/frontend.md index 0306d4e9..139423e3 100644 --- a/docs/reference/frontend.md +++ b/docs/reference/frontend.md @@ -68,7 +68,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL ## Resource Node Monitor View -资源节点模块必须提供 `资源监控` 子标签,用类似 Windows 任务管理器的性能页展示每个 provider 节点的 CPU、内存和硬盘用量历史曲线。该页面应包含节点切换、当前用量摘要、CPU/Memory/Disk 三条曲线、进程资源占用表、采样说明和 `Provider Gateway 升级` 控制区;曲线和进程表数据来自 backend-core 的 `/api/nodes/system-status`,不得在页面默认展示原始 JSON。内存曲线必须使用实际内存口径,不把 Linux page cache / buffer 计入占用。进程资源占用表必须用 React 表格控件展示进程名、命令摘要、PID、用户、状态、CPU、内存、RSS、磁盘 I/O、线程和运行时长,并支持按列排序;默认排序必须是内存 RSS 降序,表头要能明确显示当前排序方向。完整进程快照只能通过 `查看原始JSON` 显式查看。 +资源节点模块必须提供 `资源监控` 子标签,用类似 Windows 任务管理器的性能页展示每个 provider 节点的 CPU、内存和硬盘用量历史曲线。该页面应包含节点切换、当前用量摘要、CPU/Memory/Disk 三条曲线、进程资源占用表、采样说明和 `Provider Gateway 升级` 控制区;曲线和进程表数据来自 backend-core 的 `/api/nodes/system-status`,不得在页面默认展示原始 JSON。内存曲线必须使用实际内存口径,不把 Linux page cache / buffer 计入占用。进程资源占用表必须用 React 表格控件展示进程名、命令摘要、PID、用户、状态、CPU、内存、PSS/RSS、磁盘 I/O、线程和运行时长,并支持按列排序;默认排序必须按进程 `memoryBytes` 降序,其中 `memoryBytes` 优先为 PSS 以避免共享内存重复计算,表头要能明确显示当前排序方向。完整进程快照只能通过 `查看原始JSON` 显式查看。 ## Resource Node Docker View diff --git a/docs/reference/provider-gateway.md b/docs/reference/provider-gateway.md index a919ee20..c4b17e5b 100644 --- a/docs/reference/provider-gateway.md +++ b/docs/reference/provider-gateway.md @@ -122,7 +122,7 @@ provider-gateway 连接成功后必须周期性上报 Docker daemon 状态,数 ## System Status Telemetry -provider-gateway 连接成功后必须周期性上报节点 CPU、内存、硬盘和进程资源占用。整体采集来源是节点本地 `/proc/stat`、`/proc/loadavg`、`/proc/meminfo` 与 `df -PB1`,进程表来源是 `/proc/[pid]/stat`、`/proc/[pid]/status`、`/proc/[pid]/cmdline` 和 `/proc/[pid]/io`;backend-core 将最新快照保存到 `unidesk_node_system_status`,并将历史采样保存到 `unidesk_node_metric_samples` 供 frontend 绘制任务管理器风格曲线。内存使用量采用实际占用口径:`MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem`,也就是不把 Linux page cache / buffer 计入占用;上报中同时保留 `cacheBytes` 便于排查。进程表的 `rssBytes` 是实际常驻内存,默认按 `rssBytes` 降序截取前 120 个进程;`cpuPercent` 使用相邻采样 CPU tick 差值,首个采样用进程生命周期平均值兜底;磁盘 I/O 速率使用相邻 `/proc/[pid]/io` 的 `read_bytes/write_bytes` 差值。该链路仍然由 provider 主动上报,主 server 不反向探测计算节点。 +provider-gateway 连接成功后必须周期性上报节点 CPU、内存、硬盘和进程资源占用。整体采集来源是节点本地 `/proc/stat`、`/proc/loadavg`、`/proc/meminfo` 与 `df -PB1`,进程表来源是 `/proc/[pid]/stat`、`/proc/[pid]/status`、`/proc/[pid]/cmdline`、`/proc/[pid]/io` 和可用时的 `/proc/[pid]/smaps_rollup`;backend-core 将最新快照保存到 `unidesk_node_system_status`,并将历史采样保存到 `unidesk_node_metric_samples` 供 frontend 绘制任务管理器风格曲线。内存使用量采用实际占用口径:`MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem`,也就是不把 Linux page cache / buffer 计入占用;上报中同时保留 `cacheBytes` 便于排查。进程表的 `memoryBytes` 优先使用 `smaps_rollup` 中的 PSS,避免 PostgreSQL shared buffer 等共享页在多个进程之间重复计入,同时保留 `rssBytes`、`privateBytes` 和 `sharedBytes` 便于排查;如果目标内核或权限不支持 `smaps_rollup`,则按进程回退到 `rssBytes`。默认按 `memoryBytes` 降序截取前 120 个进程;`cpuPercent` 使用相邻采样 CPU tick 差值,首个采样用进程生命周期平均值兜底;磁盘 I/O 速率使用相邻 `/proc/[pid]/io` 的 `read_bytes/write_bytes` 差值。该链路仍然由 provider 主动上报,主 server 不反向探测计算节点。 ## Remote Provider Upgrade diff --git a/scripts/src/e2e.ts b/scripts/src/e2e.ts index 3ce06857..672cb8ff 100644 --- a/scripts/src/e2e.ts +++ b/scripts/src/e2e.ts @@ -1115,10 +1115,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record }> } }).body?.nodes ?? []; const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id); const expectedGatewayVersion = providerGatewayPackageVersion(); - const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number; mode?: string; cacheBytes?: number }; disk?: { percent?: number }; processes?: Array<{ pid?: number; rssBytes?: number; cpuPercent?: number; command?: string }>; processSummary?: { defaultSort?: string; visible?: number; total?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? []; + const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number; mode?: string; cacheBytes?: number }; disk?: { percent?: number }; processes?: Array<{ pid?: number; memoryBytes?: number; memoryMode?: string; rssBytes?: number; cpuPercent?: number; command?: string }>; processSummary?: { defaultSort?: string; visible?: number; total?: number; memoryMode?: string } }; history?: unknown[] }> } }).body?.systemStatuses ?? []; const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id); const mainProcesses = mainSystem?.current?.processes ?? []; - const processMemoryDescending = mainProcesses.length < 2 || mainProcesses.every((row, index, rows) => index === 0 || Number(rows[index - 1]?.rssBytes ?? 0) >= Number(row.rssBytes ?? 0)); + const processMemoryBytes = (row: { memoryBytes?: number }): number => Number(row.memoryBytes ?? -1); + const processMemoryDescending = mainProcesses.length < 2 || mainProcesses.every((row, index, rows) => index === 0 || processMemoryBytes(rows[index - 1] ?? {}) >= processMemoryBytes(row)); const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? []; const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id); const providerGatewayRuntimeGuard = providerGatewayRuntimeGuardDetail(dockerStatus); @@ -1128,7 +1129,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2 addSelectedCheck(checks, options, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); addSelectedCheck(checks, options, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); addSelectedCheck(checks, options, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id)); - addSelectedCheck(checks, options, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) }); + addSelectedCheck(checks, options, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && typeof mainSystem.current.processSummary.memoryMode === "string" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.memoryBytes) && typeof row.memoryMode === "string" && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) }); addSelectedCheck(checks, options, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id)); addSelectedCheck(checks, options, "provider:gateway-restart-policy", providerGatewayRuntimeGuard.ok, providerGatewayRuntimeGuard); const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean; proxyMode?: string }; deployment?: { mode?: string }; runtime?: { orchestrator?: string; providerStatus?: string; container?: { name?: string; state?: string } | null } }> } }).body?.microservices ?? []; diff --git a/src/bun.lock b/src/bun.lock index 7d197635..2458d4b8 100644 --- a/src/bun.lock +++ b/src/bun.lock @@ -30,7 +30,7 @@ }, "components/provider-gateway": { "name": "@unidesk/provider-gateway", - "version": "0.2.1", + "version": "0.2.23", }, "components/shared": { "name": "@unidesk/shared", diff --git a/src/components/frontend/src/app.tsx b/src/components/frontend/src/app.tsx index 522ff926..26a1e0a9 100644 --- a/src/components/frontend/src/app.tsx +++ b/src/components/frontend/src/app.tsx @@ -845,7 +845,7 @@ function NodeMonitorPage({ nodes, systemStatuses, tasks, onRaw, refresh }: AnyRe h("article", null, h("b", null, "CPU"), h("span", null, "从 /proc/stat 计算相邻采样差值,首个采样用 load/cores 近似")), h("article", null, h("b", null, "Memory"), h("span", null, "实际内存 = MemTotal - MemFree - Buffers - Cached - SReclaimable + Shmem,不把 page cache / buffer 计入占用")), h("article", null, h("b", null, "Disk"), h("span", null, "使用 df -PB1 对配置路径采样,默认监控根文件系统")), - h("article", null, h("b", null, "Process"), h("span", null, "从 /proc/[pid] 采集进程 CPU、实际内存 RSS、线程数和磁盘 I/O 速率;表格默认按内存占用降序")), + h("article", null, h("b", null, "Process"), h("span", null, "从 /proc/[pid] 采集进程 CPU、实际内存 PSS、RSS、线程数和磁盘 I/O 速率;PSS 不重复计算共享内存,表格默认按内存占用降序")), ), ), ), @@ -855,8 +855,12 @@ function NodeMonitorPage({ nodes, systemStatuses, tasks, onRaw, refresh }: AnyRe type ProcessSortKey = "memory" | "cpu" | "disk" | "pid" | "name" | "user" | "threads" | "runtime"; +function processMemoryBytes(row: AnyRecord): number { + return asNumber(row.memoryBytes, asNumber(row.pssBytes, asNumber(row.rssBytes))); +} + function processSortValue(row: AnyRecord, key: ProcessSortKey): string | number { - if (key === "memory") return asNumber(row.rssBytes); + if (key === "memory") return processMemoryBytes(row); if (key === "cpu") return asNumber(row.cpuPercent); if (key === "disk") return asNumber(row.readBytesPerSecond) + asNumber(row.writeBytesPerSecond); if (key === "pid") return asNumber(row.pid); @@ -879,6 +883,8 @@ function ProcessResourceTable({ current, onRaw }: AnyRecord) { const contextLoading = React.useContext(LoadingContext); const processSummary = current?.processSummary && typeof current.processSummary === "object" ? current.processSummary : {}; const processes = Array.isArray(current?.processes) ? current.processes : []; + const memoryMode = String(processSummary.memoryMode || ""); + const memoryModeLabel = memoryMode === "pss_smaps_rollup" ? "PSS" : "RSS fallback"; const rows = useMemo(() => { const direction = sort.direction === "asc" ? 1 : -1; return [...processes].sort((left: AnyRecord, right: AnyRecord) => { @@ -913,6 +919,7 @@ function ProcessResourceTable({ current, onRaw }: AnyRecord) { ), h("div", { className: "process-resource-actions" }, h("span", { className: "data-chip" }, "默认按内存排序"), + h("span", { className: "data-chip" }, `内存口径 ${memoryModeLabel}`), h("span", { className: "data-chip" }, `${asNumber(processSummary.visible, rows.length)} / ${asNumber(processSummary.total, rows.length)} 进程`), h(RawButton, { title: "Process Resource Snapshot", data: { processSummary, processes }, onOpen: onRaw, testId: "raw-process-resources" }), ), @@ -927,17 +934,18 @@ function ProcessResourceTable({ current, onRaw }: AnyRecord) { h("th", null, "状态"), sortHeader("CPU", "cpu"), sortHeader("内存", "memory"), - h("th", null, "RSS"), + h("th", null, "PSS / RSS"), sortHeader("磁盘 I/O", "disk"), sortHeader("线程", "threads"), sortHeader("运行时长", "runtime"), )), h("tbody", null, rows.map((row: AnyRecord) => { const diskRate = asNumber(row.readBytesPerSecond) + asNumber(row.writeBytesPerSecond); + const memoryBytes = processMemoryBytes(row); return h("tr", { key: `${row.pid}-${row.startedAt}`, "data-testid": `process-row-${safeId(row.pid)}`, - "data-memory-bytes": String(asNumber(row.rssBytes)), + "data-memory-bytes": String(memoryBytes), "data-cpu-percent": String(asNumber(row.cpuPercent)), "data-disk-bps": String(diskRate), "data-pid": String(asNumber(row.pid)), @@ -953,7 +961,12 @@ function ProcessResourceTable({ current, onRaw }: AnyRecord) { h("td", null, h("span", { className: `process-state state-${safeId(row.state || "unknown")}` }, row.state || "?")), h("td", null, h(ProcessMeter, { value: row.cpuPercent, label: fmtLoosePercent(row.cpuPercent), tone: "cpu" })), h("td", null, h(ProcessMeter, { value: row.memoryPercent, label: fmtPercent(row.memoryPercent), tone: "memory" })), - h("td", null, fmtBytes(row.rssBytes)), + h("td", null, + h("div", { className: "process-io-cell" }, + h("strong", null, fmtBytes(memoryBytes)), + h("span", null, `RSS ${fmtBytes(row.rssBytes)}`), + ), + ), h("td", null, h("div", { className: "process-io-cell" }, h("strong", null, fmtBytesRate(diskRate)), h("span", null, `R ${fmtBytesRate(row.readBytesPerSecond)} / W ${fmtBytesRate(row.writeBytesPerSecond)}`), diff --git a/src/components/provider-gateway/package.json b/src/components/provider-gateway/package.json index 41c1b161..cf4b4ea6 100644 --- a/src/components/provider-gateway/package.json +++ b/src/components/provider-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@unidesk/provider-gateway", - "version": "0.2.22", + "version": "0.2.23", "private": true, "type": "module", "scripts": { diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index e27f8735..e817280b 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -914,6 +914,41 @@ function readProcessIo(pid: number): { readBytes: number; writeBytes: number } { } } +function readProcessMemory(pid: number, rssBytes: number): { + memoryBytes: number; + memoryMode: string; + pssBytes?: number; + privateBytes?: number; + sharedBytes?: number; + swapPssBytes?: number; +} { + try { + const values = new Map(); + for (const line of readFileSync(`/proc/${pid}/smaps_rollup`, "utf8").split("\n")) { + const match = line.match(/^([A-Za-z_]+):\s+(\d+)\s+kB/); + if (!match) continue; + values.set(match[1], Number(match[2]) * 1024); + } + const pssBytes = values.get("Pss"); + if (typeof pssBytes !== "number" || !Number.isFinite(pssBytes) || pssBytes < 0) { + throw new Error("missing Pss"); + } + const privateBytes = (values.get("Private_Clean") ?? 0) + (values.get("Private_Dirty") ?? 0); + const sharedBytes = (values.get("Shared_Clean") ?? 0) + (values.get("Shared_Dirty") ?? 0); + const swapPssBytes = values.get("SwapPss") ?? 0; + return { + memoryBytes: pssBytes, + memoryMode: "pss_smaps_rollup", + pssBytes, + privateBytes, + sharedBytes, + swapPssBytes, + }; + } catch { + return { memoryBytes: rssBytes, memoryMode: "rss_fallback" }; + } +} + function readUptimeSeconds(): number { const uptime = Number(readFileSync("/proc/uptime", "utf8").trim().split(/\s+/)[0]); return Number.isFinite(uptime) ? uptime : 0; @@ -989,6 +1024,7 @@ async function collectProcessResources(totalMemoryBytes: number, cpuCores: numbe const readBytesPerSecond = previous && ioSeconds > 0 ? Math.max(0, io.readBytes - previous.readBytes) / ioSeconds : 0; const writeBytesPerSecond = previous && ioSeconds > 0 ? Math.max(0, io.writeBytes - previous.writeBytes) / ioSeconds : 0; const rssBytes = Math.max(0, stat.rssPages * pageSize); + const processMemory = readProcessMemory(pid, rssBytes); const name = status.name || stat.name; const uid = status.uid >= 0 ? status.uid : 0; rows.push({ @@ -1000,8 +1036,14 @@ async function collectProcessResources(totalMemoryBytes: number, cpuCores: numbe command: readProcessCommand(pid, name), state: stat.state, cpuPercent: roundMetric(Math.min(cpuPercent, Math.max(1, cpuCores) * 100)), - memoryPercent: totalMemoryBytes > 0 ? clampPercent((rssBytes / totalMemoryBytes) * 100) : 0, + memoryPercent: totalMemoryBytes > 0 ? clampPercent((processMemory.memoryBytes / totalMemoryBytes) * 100) : 0, + memoryBytes: processMemory.memoryBytes, + memoryMode: processMemory.memoryMode, rssBytes, + pssBytes: processMemory.pssBytes, + privateBytes: processMemory.privateBytes, + sharedBytes: processMemory.sharedBytes, + swapPssBytes: processMemory.swapPssBytes, vmsBytes: Math.max(0, stat.vmsBytes), threads: status.threads || stat.threads, readBytes: io.readBytes, @@ -1024,7 +1066,8 @@ async function collectProcessResources(totalMemoryBytes: number, cpuCores: numbe } previousProcessSamples = new Map([...previousProcessSamples].filter(([pid]) => seen.has(pid))); - rows.sort((a, b) => b.rssBytes - a.rssBytes || b.cpuPercent - a.cpuPercent || a.pid - b.pid); + rows.sort((a, b) => b.memoryBytes - a.memoryBytes || b.cpuPercent - a.cpuPercent || a.pid - b.pid); + const pssRows = rows.filter((row) => row.memoryMode === "pss_smaps_rollup").length; return { processes: rows.slice(0, 120), summary: { @@ -1033,6 +1076,9 @@ async function collectProcessResources(totalMemoryBytes: number, cpuCores: numbe skipped, defaultSort: "memory_desc", scope: "provider_pid_namespace", + memoryMode: pssRows > 0 ? "pss_smaps_rollup" : "rss_fallback", + pssRows, + rssFallbackRows: rows.length - pssRows, cpuPercentMode: hasPreviousProcessSample ? "delta_ticks_per_sample" : "lifetime_average_first_sample", diskIoMode: "proc_pid_io_delta_bytes_per_second", }, diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index c82191c8..cf2332ed 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -28,7 +28,13 @@ export interface ProcessResourceSummary { state: string; cpuPercent: number; memoryPercent: number; + memoryBytes: number; + memoryMode: string; rssBytes: number; + pssBytes?: number; + privateBytes?: number; + sharedBytes?: number; + swapPssBytes?: number; vmsBytes: number; threads: number; readBytes: number;