From 6a07904ca0bb13b8af3c41d410e9038395fe13de Mon Sep 17 00:00:00 2001 From: CieanYau Date: Sat, 7 Nov 2026 04:12:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E7=BC=BA=E6=B5=8B=E8=AF=AD=E4=B9=89=E3=80=81?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E6=97=A5=E6=9C=9F=E4=B8=8E=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/instance/CommonUpgrade.vue | 8 +- app/components/instance/DcimMonitor.vue | 26 +- app/components/instance/InstanceDisks.vue | 6 +- app/components/instance/InstanceMonitor.vue | 39 +- app/components/instance/InstanceOverview.vue | 55 +- app/lib/finance-resource-chart.js | 2 +- tests/review-instance-metrics.e2e.test.mjs | 661 +++++++++++++++++++ 7 files changed, 726 insertions(+), 71 deletions(-) create mode 100644 tests/review-instance-metrics.e2e.test.mjs diff --git a/app/components/instance/CommonUpgrade.vue b/app/components/instance/CommonUpgrade.vue index 368ea77..0dc7b86 100644 --- a/app/components/instance/CommonUpgrade.vue +++ b/app/components/instance/CommonUpgrade.vue @@ -32,6 +32,7 @@ import { r as configuration_display_r } from "~/lib/configuration-display.js"; import { t as configuration_display_t } from "~/lib/configuration-display.js"; import { t as common_advanced_rules_t } from "~/lib/common-advanced-rules.js"; import multiselect from "openvue/multiselect"; +import { t as finance_resource_chart_t } from "~/lib/finance-resource-chart.js"; var j = (e) => (e && typeof e == `object` && !Array.isArray(e) ? e : {}), M = (e) => e === !0 || e === 1 || e === `1`, N = (e) => e === !1 || e === 0 || e === `0`, @@ -163,12 +164,7 @@ function z(e) { name: a[t] || `数据 ${t + 1}`, type: `line`, showSymbol: !1, - data: c.map((e) => { - let t = n.get(e); - return t != null && t !== `` && Number.isFinite(Number(t)) - ? Number(t) - : null; - }), + data: c.map((e) => finance_resource_chart_t(n.get(e))), }; }); return { diff --git a/app/components/instance/DcimMonitor.vue b/app/components/instance/DcimMonitor.vue index f4ad8fc..4a9e702 100644 --- a/app/components/instance/DcimMonitor.vue +++ b/app/components/instance/DcimMonitor.vue @@ -26,6 +26,8 @@ import button from "openvue/button"; import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js"; import skeleton from "openvue/skeleton"; import selectbutton from "openvue/selectbutton"; +import { r as dict_r } from "~/lib/dict.js"; +import { t as finance_resource_chart_t } from "~/lib/finance-resource-chart.js"; var T = defineAsyncComponent(() => loadModule( () => import("~/components/ChartBox.vue").then((e) => e.default || e), @@ -113,8 +115,10 @@ var T = defineAsyncComponent(() => start_time: Math.floor(Date.now() / 1e3) - S.value, }); if (R || e !== L || t !== h.hostId || n !== h.module) return; - ((F.value = (Array.isArray(r.list) ? r.list : []).filter((e) => - Number.isFinite(Number(e.time)), + ((F.value = (Array.isArray(r.list) ? r.list : []).filter( + (e) => + finance_resource_chart_t(e.time) !== null && + Number.isFinite(new Date(Number(e.time) * 1e3).getTime()), )), (I.value = typeof r.unit == `string` ? r.unit : ``)); } catch (t) { @@ -126,12 +130,8 @@ var T = defineAsyncComponent(() => } } // 缺失采样转为 null 而不是 0,配合 connectNulls:false 保留采集中断的空隙。 - function V(e) { - return e != null && e !== `` && Number.isFinite(Number(e)) - ? Number(e) - : null; - } - let H = computed(() => + let V = finance_resource_chart_t, + H = computed(() => F.value.some((e) => V(e.in_bw) !== null || V(e.out_bw) !== null), ), U = computed(() => ({ @@ -150,12 +150,10 @@ var T = defineAsyncComponent(() => xAxis: { type: `category`, boundaryGap: !1, - data: F.value.map((e) => - new Date(Number(e.time) * 1e3).toLocaleTimeString(`zh-CN`, { - hour: `2-digit`, - minute: `2-digit`, - }), - ), + data: F.value.map((e) => dict_r(Number(e.time))), + axisLabel: { + formatter: (e) => String(e).slice(11, 16), + }, }, yAxis: { type: `value`, diff --git a/app/components/instance/InstanceDisks.vue b/app/components/instance/InstanceDisks.vue index e100d12..ef48322 100644 --- a/app/components/instance/InstanceDisks.vue +++ b/app/components/instance/InstanceDisks.vue @@ -1055,13 +1055,14 @@ var ge = [`aria-busy`], createElementVNode(`div`, ke, [ (n[17] ||= createElementVNode( `label`, - null, + { for: `disk-resize-capacity` }, `扩容至`, -1, )), createVNode( fe, { + inputId: `disk-resize-capacity`, modelValue: unref(P), "onUpdate:modelValue": [ (n[1] ||= (e) => @@ -1232,13 +1233,14 @@ var ge = [`aria-busy`], createElementVNode(`div`, Fe, [ (n[21] ||= createElementVNode( `label`, - null, + { for: `disk-add-capacity` }, `容量`, -1, )), createVNode( fe, { + inputId: `disk-add-capacity`, modelValue: unref(B), "onUpdate:modelValue": [ (n[5] ||= (e) => diff --git a/app/components/instance/InstanceMonitor.vue b/app/components/instance/InstanceMonitor.vue index 012c329..9463df6 100644 --- a/app/components/instance/InstanceMonitor.vue +++ b/app/components/instance/InstanceMonitor.vue @@ -30,6 +30,7 @@ import ListSegments from "~/components/ListSegments.vue"; import skeleton from "openvue/skeleton"; import { n as finance_resource_chart_n } from "~/lib/finance-resource-chart.js"; import { t as finance_resource_chart_t } from "~/lib/finance-resource-chart.js"; +import { r as dict_r } from "~/lib/dict.js"; var O = defineAsyncComponent(() => loadModule( () => import("~/components/ChartBox.vue").then((e) => e.default || e), @@ -125,8 +126,10 @@ var O = defineAsyncComponent(() => ], Z = ref(86400), Q = (e) => { - let t = new Date(e * 1e3); - return `${String(t.getHours()).padStart(2, `0`)}:${String(t.getMinutes()).padStart(2, `0`)}`; + let t = finance_resource_chart_t(e); + return t !== null && Number.isFinite(new Date(t * 1e3).getTime()) + ? dict_r(t) + : ``; }; async function te(e, t, n = `mf_cloud`) { // 云模块各指标并行读取并分别展示失败;物理智简只请求实际支持的带宽图。 @@ -151,10 +154,13 @@ var O = defineAsyncComponent(() => pick: [ { name: `内存`, - fn: (e) => - e.total - ? (Number(e.used ?? 0) / Number(e.total)) * 100 - : void 0, + fn: (e) => { + let t = finance_resource_chart_t(e.total), + n = finance_resource_chart_t(e.used); + return t !== null && t > 0 && n !== null + ? (n / t) * 100 + : null; + }, color: U.value, unit: `%`, }, @@ -228,10 +234,7 @@ var O = defineAsyncComponent(() => }; let r = n.value.list ?? [], a = e.pick.map((e) => - r.map((t) => { - let n = e.fn(t); - return n === void 0 || Number.isNaN(n) ? null : Number(n); - }), + r.map((t) => finance_resource_chart_t(e.fn(t))), ), s = e.scale === `bits` @@ -317,9 +320,7 @@ var O = defineAsyncComponent(() => ], }; } - let a = (r.list?.[0] ?? []).map((e) => - String(e.time ?? ``).slice(11, 16), - ), + let a = (r.list?.[0] ?? []).map((e) => String(e.time ?? ``)), o = (r.list ?? []).map((e, t) => ({ name: String(r.label?.[t] ?? `系列 ${t + 1}`).replace( /\(.*\)$/, @@ -341,9 +342,7 @@ var O = defineAsyncComponent(() => async function re(e) { // 快快云监控时间范围由接口决定,带宽值按其既有口径从千分单位转换为 Mbps。 let t = await y.get(`/kky_ecs/monitor/${e}`), - n = (t.sample_info ?? []).map((e) => - String(e.timestamp ?? ``).slice(11, 16), - ), + n = (t.sample_info ?? []).map((e) => String(e.timestamp ?? ``)), r = (e, t, r, i, a, o = 1) => ({ key: e, title: t, @@ -352,9 +351,10 @@ var O = defineAsyncComponent(() => name: e.title, unit: a, color: i[t % i.length], - data: (e.value ?? []).map((e) => - typeof e == `number` ? Number((e / o).toFixed(2)) : null, - ), + data: (e.value ?? []).map((e) => { + let t = finance_resource_chart_t(e); + return t === null ? null : Number((t / o).toFixed(2)); + }), })), }); return [ @@ -448,6 +448,7 @@ var O = defineAsyncComponent(() => axisLabel: { color: t, fontSize: 11, + formatter: (e) => String(e).slice(11, 16), }, }, yAxis: { diff --git a/app/components/instance/InstanceOverview.vue b/app/components/instance/InstanceOverview.vue index 2b4e14d..c5fe476 100644 --- a/app/components/instance/InstanceOverview.vue +++ b/app/components/instance/InstanceOverview.vue @@ -43,6 +43,7 @@ import FlowPacketPanel from "~/components/instance/FlowPacketPanel.vue"; import InstanceStatusTag from "~/components/instance/InstanceStatusTag.vue"; import InstanceTraffic from "~/components/instance/InstanceTraffic.vue"; import { n as finance_resource_chart_n } from "~/lib/finance-resource-chart.js"; +import { t as finance_resource_chart_t } from "~/lib/finance-resource-chart.js"; var le = defineAsyncComponent(() => loadModule( () => import("~/components/ChartBox.vue").then((e) => e.default || e), @@ -293,13 +294,8 @@ var le = defineAsyncComponent(() => ), K = 0, q = !1; - function J(e) { - if (typeof e != `number` && (typeof e != `string` || !e.trim())) - return null; - let t = Number(e); - return Number.isFinite(t) ? t : null; - } - let xt = (e) => (e.status === `fulfilled` ? e.value : null), + let J = finance_resource_chart_t, + xt = (e) => (e.status === `fulfilled` ? e.value : null), St = (e) => (e instanceof Error ? e.message : `网络监控加载失败`); async function Y() { // 不同模块的监控时间单位和返回结构不同,分支完成协议转换;实例切换后拒绝旧响应。 @@ -374,7 +370,7 @@ var le = defineAsyncComponent(() => (e) => { H.value = { labels: (e?.list?.[0] ?? []).map((e) => - String(e.time ?? ``).slice(11, 16), + String(e.time ?? ``), ), unit: e?.unit?.trim() || ``, series: (e?.list ?? []).map((t, n) => ({ @@ -474,8 +470,11 @@ var le = defineAsyncComponent(() => : 0; H.value = { labels: t.map((e) => { - let t = new Date(e.time * 1e3); - return `${String(t.getHours()).padStart(2, `0`)}:${String(t.getMinutes()).padStart(2, `0`)}`; + let t = J(e.time); + return t !== null && + Number.isFinite(new Date(t * 1e3).getTime()) + ? dict_r(t) + : ``; }), unit: [`bps`, `Kbps`, `Mbps`, `Gbps`, `Tbps`][i], series: n.map((e, t) => ({ @@ -498,32 +497,34 @@ var le = defineAsyncComponent(() => ]); if (!r()) return; let n = xt(e); - z.value = n?.cpu?.[0]?.value ?? []; + z.value = (n?.cpu?.[0]?.value ?? []).map(J); let i = n?.memory?.find((e) => !/free/i.test(e.name)), a = n?.memory?.find((e) => /free/i.test(e.name)); ((B.value = i && a ? i.value.map((e, t) => { - let n = Number(a.value?.[t] ?? 0); - return e > 0 - ? Math.max(0, Math.min(100, ((e - n) / e) * 100)) - : 0; + let n = J(a.value?.[t]), + r = J(e); + return r !== null && r > 0 && n !== null + ? Math.max(0, Math.min(100, ((r - n) / r) * 100)) + : null; }) : []), e.status === `rejected` ? (W.value = St(e.reason)) : (H.value = { labels: (n?.sample_info ?? []).map((e) => - String(e.timestamp ?? ``).slice(11, 16), + String(e.timestamp ?? ``), ), unit: `Mbps`, series: (n?.bandwidth ?? []).map((e, t) => ({ name: e.title || e.name || `带宽系列 ${t + 1}`, - data: (e.value ?? []).map((e) => - typeof e == `number` && Number.isFinite(e) - ? Number((e / 1e3).toFixed(2)) - : null, - ), + data: (e.value ?? []).map((e) => { + let t = J(e); + return t === null + ? null + : Number((t / 1e3).toFixed(2)); + }), })), })); } @@ -546,23 +547,19 @@ var le = defineAsyncComponent(() => x.module === `jy_cloud` || x.module === `jy_dcim` ? (J(R.value?.cpu_usage) ?? z.value.at(-1) ?? null) : x.module === `mf_cloud` || x.module === `remf_cloud` - ? X(R.value?.cpu_usage) + ? J(R.value?.cpu_usage) : z.value.length ? z.value[z.value.length - 1] - : x.module === `remf_finance` - ? null - : 0, + : null, ), wt = computed(() => x.module === `jy_cloud` || x.module === `jy_dcim` ? (J(R.value?.memory_usage) ?? B.value.at(-1) ?? null) : x.module === `mf_cloud` || x.module === `remf_cloud` - ? X(R.value?.memory_usage) + ? J(R.value?.memory_usage) : B.value.length ? B.value[B.value.length - 1] - : x.module === `remf_finance` - ? null - : 0, + : null, ); function Tt(e) { // CPU/内存趋势以百分比绘制;null 表示缺测,禁止连接缺失点伪造连续数据。 diff --git a/app/lib/finance-resource-chart.js b/app/lib/finance-resource-chart.js index c990520..a381648 100644 --- a/app/lib/finance-resource-chart.js +++ b/app/lib/finance-resource-chart.js @@ -15,7 +15,7 @@ function t(t, n) { ? null : Number(Math.min(100, Math.max(0, n)).toFixed(2)); }, - s = (e) => e.map((e) => String(e.time ?? ``).slice(11, 16)); + s = (e) => e.map((e) => String(e.time ?? ``)); if (n === `cpu` || /%|percent/i.test(t.unit ?? ``)) { let e = r[n === `memory` && a >= 0 ? a : 0] ?? []; return { diff --git a/tests/review-instance-metrics.e2e.test.mjs b/tests/review-instance-metrics.e2e.test.mjs new file mode 100644 index 0000000..c693d65 --- /dev/null +++ b/tests/review-instance-metrics.e2e.test.mjs @@ -0,0 +1,661 @@ +/** + * 回归缺口:缺测值不能伪造为0,多日采样不能丢掉日期,磁盘输入的可见标签必须命名原生控件。 + * 只替换已配置的外部HTTP边界;经真实登录、实例标签、图表指针与磁盘表单完成验收。 + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + backendOrigin, + eventually, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +async function instanceBoundary( + page, + { module = "mf_cloud", value = 50, time = 1793836800 } = {}, +) { + const state = { value, quotes: [], chartReads: 0 }; + const times = [ + "2026-11-05 08:00:00", + "2026-11-06 08:00:00", + "2026-11-07 08:00:00", + ]; + const epoch = time; + await page.context().route(`${backendOrigin}/console/v1/**`, (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/console\/v1/, ""); + const reply = (data) => + route.fulfill({ json: { status: 200, msg: "", data } }); + if (path === "/host/5") + return reply({ + host: { + id: 5, + product_id: 123, + module, + name: "本地指标验收实例", + status: "Active", + billing_cycle: "monthly", + upgrade_allowed: true, + }, + }); + if (path === "/host/5/view") return reply({ module }); + if (path === "/host/5/ip") return reply({ list: [] }); + if (path === "/idcsmart_common/host/5/configoption") + return reply({ + configoptions: [], + client_button: [], + chart: [{ type: "bandwidth", title: "通用带宽监控", select: [] }], + }); + if (path === "/idcsmart_common/host/5/provision/status") + return reply({ status: "on" }); + if (path === "/idcsmart_common/host/5/configoption/chart") { + state.chartReads++; + return reply({ + unit: "Mbps", + label: ["入带宽", "出带宽"], + list: [ + times.map((time) => ({ time, value: state.value })), + times.map((time) => ({ time, value: state.value })), + ], + }); + } + if (path === `/${module}/5`) + return reply({ + image: { id: 6, name: "Ubuntu 24.04" }, + port: 22, + username: "root", + cpu: 2, + memory: 4, + config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 }, + }); + if (path === `/${module}/5/status`) + return reply({ status: "on", rescue: false }); + if (path === `/${module}/5/remote_info`) + return reply({ username: "root", port: 22 }); + if ( + path === "/remf_finance/5/snapshot" || + path === "/remf_finance/5/backup" + ) + return reply({ disk: [] }); + if (path === `/${module}/5/disk`) + return reply({ + list: [ + { + id: 10, + name: "验收数据盘", + size: 50, + type: "SSD", + type2: "data", + status: 1, + }, + ], + }); + if (path === `/${module}/5/flow`) + return reply({ total: 100, used: 20, leave: 80 }); + if (path === `/${module}/5/real_data`) + return reply({ cpu_usage: state.value, memory_usage: state.value }); + if (path === `/${module}/5/chart`) { + state.chartReads++; + if (module === "remf_finance") { + const type = url.searchParams.get("type"); + return reply({ + unit: type === "cpu" || type === "memory" ? "%" : "KB/s", + label: [type === "memory" ? "已用" : "采样"], + list: [times.map((time) => ({ time, value: state.value }))], + }); + } + return reply({ + list: [0, 1, 2].map((index) => ({ + time: epoch === null ? null : epoch + index * 86400, + value: state.value, + total: 100, + used: state.value, + in_bw: state.value, + out_bw: state.value, + read_bytes: state.value, + write_bytes: state.value, + })), + }); + } + if (path === "/kky_ecs/disk/5") return reply({ list: [] }); + if (path === "/kky_ecs/5/traffic") + return reply({ + billing: { mode: "bandwidth", monthly_quota: 0, unit_price: "0.00" }, + usage_status: "available", + quota_status: "not_applicable", + }); + if (path === "/kky_ecs/monitor/5") { + state.chartReads++; + return reply({ + sample_info: times.map((timestamp) => ({ timestamp })), + cpu: [{ title: "CPU", value: times.map(() => state.value) }], + memory: [ + { title: "total", name: "total", value: [100, 100, 100] }, + { + title: "free", + name: "free", + value: times.map(() => + typeof state.value === "number" ? 100 - state.value : state.value, + ), + }, + ], + bandwidth: [{ title: "入", value: [50000, 50000, 50000] }], + disk: [{ title: "读", value: times.map(() => state.value) }], + }); + } + if (path === `/product/123/${module}/order_page`) + return reply({ + data_disk: [ + { + type: "step", + min_value: 10, + max_value: 500, + other_config: { disk_type: "SSD" }, + }, + ], + config: { disk_limit_num: 4 }, + }); + if ( + path === `/${module}/5/disk/resize` || + path === `/${module}/5/disk/price` + ) { + state.quotes.push({ + path, + body: request.postDataJSON(), + authenticated: !!request.headers().authorization, + }); + return reply({ price: "10.00" }); + } + return route.fallback(); + }); + return state; +} + +async function openMonitor(page) { + await page.getByRole("tab", { name: "监控", exact: true }).click(); + await eventually( + () => page.locator(".mon .chart-card").count(), + (count) => count === 4, + "The real monitor must finish mounting its four metric cards", + ); + await eventually( + () => page.getByRole("button", { name: "刷新", exact: true }).isEnabled(), + Boolean, + "The current HTTP refresh must finish", + ); +} + +async function hoverMeasurement(page, viewport, expectedValue) { + await viewport.locator("canvas").waitFor(); + const box = await viewport.boundingBox(); + assert.ok(box, "The rendered chart must have a visible viewport"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + return eventually( + () => viewport.innerText(), + (text) => text.includes(expectedValue), + "A real pointer must expose the supplied measurement in the tooltip", + ); +} + +test( + "overview distinguishes missing live measurements from an observed zero", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await instanceBoundary(page, { value: null }); + await login(page, "/servers/server?id=5"); + await eventually( + async () => + (await page.locator(".ov .res .ring-value").count()) + + (await page.locator(".ov .resource-unavailable").count()), + (count) => count === 2, + "Resource measurements must finish loading", + ); + assert.equal( + await page.locator(".ov .res .ring-value").count(), + 0, + "Null is not a measured0%", + ); + assert.equal(await page.locator(".ov .resource-unavailable").count(), 2); + return { missingLiveMeasurements: 2, fabricatedZeroRings: 0 }; + }), +); + +test( + "monitor keeps null blank and malformed samples unavailable instead of drawing zero", + { timeout: 45000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const state = await instanceBoundary(page, { value: null }); + await login(page, "/servers/server?id=5"); + await openMonitor(page); + const observations = []; + for (const value of [null, "", " ", "invalid", false]) { + if (observations.length) { + state.value = value; + const before = state.chartReads; + await page.getByRole("button", { name: "刷新", exact: true }).click(); + await eventually( + () => state.chartReads, + (count) => count === before + 4, + "Refresh must read every metric throughHTTP", + ); + await eventually( + () => + page + .getByRole("button", { name: "刷新", exact: true }) + .isEnabled(), + Boolean, + "Current measurement request must settle", + ); + } + assert.equal( + await page.locator(".mon .chart-viewport canvas").count(), + 0, + "Missing or malformed values must not become a real measurement curve", + ); + assert.equal(await page.locator(".mon .chart-empty").count(), 4); + observations.push({ input: value, unavailableCharts: 4 }); + } + return { cases: observations }; + }), +); + +for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) { + test( + `real zero ${module} measurements remain visible in overview and monitor`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await instanceBoundary(page, { module, value: 0 }); + await login(page, "/servers/server?id=5"); + await eventually( + () => page.locator(".ov .res .ring-value").allTextContents(), + (values) => + values.length === 2 && values.every((value) => value === "0%"), + "Actual zero must remain an observed resource value", + ); + await openMonitor(page); + await eventually( + () => page.locator(".mon .chart-viewport canvas").count(), + (count) => count === 4, + "Observed zero measurements must finish drawing every metric chart", + ); + const tooltip = await hoverMeasurement( + page, + page.locator(".mon .chart-viewport").first(), + "0%", + ); + return { module, observedZeroRings: 2, observedZeroCharts: 4, tooltip }; + }), + ); +} + +for (const module of ["mf_cloud", "remf_finance", "kky_ecs"]) { + test( + `multi-day ${module} monitoring tooltip identifies the sample date`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await instanceBoundary(page, { module }); + await login(page, "/servers/server?id=5"); + const overviewTooltip = await hoverMeasurement( + page, + page.locator(".ov .network-chart-viewport"), + "50", + ); + assert.match(overviewTooltip, /2026[-/年]11[-/月]0?6/); + await openMonitor(page); + if (module !== "kky_ecs") { + await page.getByRole("button", { name: "7天", exact: true }).click(); + await eventually( + () => + page + .getByRole("button", { name: "刷新", exact: true }) + .isEnabled(), + Boolean, + "Weekly range must finish reading", + ); + } + const tooltips = []; + for (const viewport of await page + .locator(".mon .chart-viewport") + .all()) { + const tooltip = await hoverMeasurement(page, viewport, "50"); + assert.match( + tooltip, + /2026[-/年]11[-/月]0?6/, + "Equal HH:mm samples on different days require the actual date in the tooltip", + ); + assert.match(tooltip, /08:00/); + tooltips.push(tooltip); + } + return { + module, + suppliedMiddleDate: "2026-11-06", + overviewTooltip, + tooltips, + dateIdentifiesSample: true, + }; + }), + ); +} + +test( + "KKY overview keeps an absent CPU and free-memory sample unavailable", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await instanceBoundary(page, { module: "kky_ecs", value: null }); + await login(page, "/servers/server?id=5"); + await eventually( + () => page.locator(".ov .resource-unavailable").count(), + (count) => count === 2, + "Missing CPU and partial memory history cannot fabricate usage", + ); + assert.equal(await page.locator(".ov .res .ring-value").count(), 0); + return { module: "kky_ecs", missingHistoricalMeasurements: 2 }; + }), +); + +test( + "KKY overview keeps a successful empty history unavailable", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await instanceBoundary(page, { module: "kky_ecs" }); + await page + .context() + .route(`${backendOrigin}/console/v1/kky_ecs/monitor/5`, (route) => + route.fulfill({ + json: { + status: 200, + msg: "", + data: { cpu: [], memory: [], bandwidth: [], sample_info: [] }, + }, + }), + ); + await login(page, "/servers/server?id=5"); + await eventually( + () => page.locator(".ov .resource-unavailable").count(), + (count) => count === 2, + "An empty measurement history is not two observed zero values", + ); + assert.equal(await page.locator(".ov .res .ring-value").count(), 0); + return { + module: "kky_ecs", + emptyHistory: true, + unavailableMeasurements: 2, + }; + }), +); + +test( + "physical-server monitoring tooltip retains the actual sample date", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await instanceBoundary(page, { module: "mf_dcim" }); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "监控", exact: true }).click(); + await page.getByRole("button", { name: "7 天", exact: true }).click(); + const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); + await eventually( + () => viewport.getAttribute("aria-busy"), + (busy) => busy === "false", + "Physical-server sampling request must settle", + ); + const tooltip = await hoverMeasurement(page, viewport, "50"); + assert.match( + tooltip, + /2026[-/年]11[-/月]0?6/, + "Physical-server tooltip must identify the day as well as the time", + ); + assert.match(tooltip, /08:00/); + return { module: "mf_dcim", tooltip, dateIdentifiesSample: true }; + }), +); + +for (const input of [" ", false]) { + test( + `physical-server monitoring rejects non-measurement ${JSON.stringify(input)}`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await instanceBoundary(page, { module: "mf_dcim", value: input }); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "监控", exact: true }).click(); + const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); + await eventually( + () => viewport.getAttribute("aria-busy"), + (busy) => busy === "false", + "Physical-server sampling request must settle", + ); + assert.equal( + await viewport.locator("canvas").count(), + 0, + "Whitespace and booleans are not bandwidth samples", + ); + assert.equal( + await viewport + .getByText("所选时段暂无带宽采样", { exact: true }) + .count(), + 1, + ); + return { input, unavailableBandwidthChart: true }; + }), + ); +} + +test( + "physical-server monitoring does not turn absent sample times into 1970", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await instanceBoundary(page, { module: "mf_dcim", time: null }); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "监控", exact: true }).click(); + const viewport = page.locator(".monitor:not(.compact) .chart-viewport"); + await eventually( + () => viewport.getAttribute("aria-busy"), + (busy) => busy === "false", + "Physical-server sampling request must settle", + ); + assert.equal( + await viewport.locator("canvas").count(), + 0, + "A value without a sample time must not become a 1970 measurement", + ); + assert.equal( + await viewport + .getByText("所选时段暂无带宽采样", { exact: true }) + .count(), + 1, + ); + return { missingTimestamp: null, fabricatedEpochChart: false }; + }), +); + +test( + "generic-instance charts distinguish unavailable samples from a measured zero stroke", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + const state = await instanceBoundary(page, { + module: "idcsmart_common", + value: 0, + }); + await login(page, "/servers/server?id=5"); + await page.getByRole("button", { name: "监控", exact: true }).click(); + const chart = page.locator(".common-chart"); + await chart.locator("canvas").waitFor(); + // richText 提示也绘制在 Canvas 内;验收实际图像中的数据线,不读取 ECharts 私有实例或 option。 + const measurementPixels = () => + chart + .locator("canvas") + .first() + .evaluate((canvas) => { + const copy = document.createElement("canvas"); + copy.width = canvas.width; + copy.height = canvas.height; + const context = copy.getContext("2d"); + context.drawImage(canvas, 0, 0); + const { data } = context.getImageData( + 0, + 0, + copy.width, + copy.height, + ); + const scale = copy.width / canvas.clientWidth; + let colored = 0; + for ( + let y = Math.ceil(48 * scale); + y < copy.height - 40 * scale; + y++ + ) { + for ( + let x = Math.ceil(60 * scale); + x < copy.width - 25 * scale; + x++ + ) { + const index = (y * copy.width + x) * 4; + const channels = [ + data[index], + data[index + 1], + data[index + 2], + ]; + if ( + data[index + 3] > 80 && + Math.max(...channels) - Math.min(...channels) > 40 + ) + colored++; + } + } + return colored; + }); + await eventually( + measurementPixels, + (pixels) => pixels > 100, + "Measured zero must draw a real colored series, not an empty chart", + ); + const cases = []; + for (const value of [" ", false, null, "invalid"]) { + state.value = value; + const before = state.chartReads; + await page.getByRole("button", { name: "刷新", exact: true }).click(); + await eventually( + () => state.chartReads, + (count) => count === before + 1, + "Generic monitoring refresh must use the configured HTTP boundary", + ); + await eventually( + () => + page.getByRole("button", { name: "刷新", exact: true }).isEnabled(), + Boolean, + "Generic monitoring refresh must settle", + ); + await eventually( + measurementPixels, + (pixels) => pixels === 0, + "Unavailable input must not leave a fabricated colored zero curve", + ); + cases.push({ input: value, hasMeasurementStroke: false }); + } + return { + module: "idcsmart_common", + measuredZeroHasStroke: true, + unavailableCases: cases, + }; + }), +); + +test( + "disk resize visible label names the editable capacity and preserves quote inputs", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const state = await instanceBoundary(page); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "硬盘", exact: true }).click(); + await page.getByRole("button", { name: "扩容", exact: true }).click(); + const dialog = page.getByRole("dialog", { + name: "磁盘扩容", + exact: true, + }); + await dialog.getByRole("spinbutton").waitFor(); + assert.equal( + await dialog + .getByRole("spinbutton", { name: "扩容至", exact: true }) + .count(), + 1, + "Visible resize label must name the native spinbutton", + ); + const field = dialog.getByRole("spinbutton", { + name: "扩容至", + exact: true, + }); + await field.fill("80"); + await field.press("Tab"); + await eventually( + () => state.quotes.at(-1)?.body.resize_data_disk?.[0]?.size, + (size) => size === 80, + "Keyboard edit must reach the real resize quote", + ); + assert.equal(state.quotes.at(-1).authenticated, true); + return { + accessibleName: "扩容至", + quotedCapacityGb: 80, + authenticatedQuote: true, + }; + }), +); + +test( + "new disk capacity visible label names its editable field", + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const state = await instanceBoundary(page); + await login(page, "/servers/server?id=5"); + await page.getByRole("tab", { name: "硬盘", exact: true }).click(); + await page + .getByRole("button", { name: "购买数据盘", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: "购买数据盘", + exact: true, + }); + await dialog.getByRole("spinbutton").waitFor(); + assert.equal( + await dialog + .getByRole("spinbutton", { name: "容量", exact: true }) + .count(), + 1, + "Visible add-capacity label must name the native spinbutton", + ); + const field = dialog.getByRole("spinbutton", { + name: "容量", + exact: true, + }); + await field.fill("40"); + await field.press("Tab"); + await eventually( + () => state.quotes.at(-1)?.body.add_disk?.[0]?.size, + (size) => size === 40, + "Keyboard edit must reach the real add-disk quote", + ); + assert.equal(state.quotes.at(-1).authenticated, true); + return { + accessibleName: "容量", + quotedCapacityGb: 40, + authenticatedQuote: true, + }; + }), +);