From 68165f0e019aaee49e8f72c7213a759ff800568b Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 22 Jun 2026 14:17:59 +0200 Subject: [PATCH] M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows --- app/api/routes/api/modbus.py | 17 +- docs/design/m5-iot-energy.md | 2 +- frontend/package-lock.json | 389 +++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/energy/EnergyCharts.test.tsx | 256 ++++++++++++++ frontend/src/energy/EnergyCharts.tsx | 333 ++++++++++++++++++ frontend/src/energy/hooks.test.tsx | 133 ++++++++ frontend/src/energy/hooks.ts | 86 +++++ frontend/src/pages/EnergyPage.test.tsx | 8 +- frontend/src/pages/EnergyPage.tsx | 185 +++++++++- openapi/openapi.json | 2 +- openapi/openapi.yaml | 19 +- tests/test_api_modbus.py | 44 +++ 13 files changed, 1454 insertions(+), 23 deletions(-) create mode 100644 frontend/src/energy/EnergyCharts.test.tsx create mode 100644 frontend/src/energy/EnergyCharts.tsx diff --git a/app/api/routes/api/modbus.py b/app/api/routes/api/modbus.py index 5cbbd41..fd580b9 100644 --- a/app/api/routes/api/modbus.py +++ b/app/api/routes/api/modbus.py @@ -304,8 +304,16 @@ def get_readings( ) -> ModbusReadingsResponse: """Return time-range readings for a device. - Results are ordered by ``recorded_at`` ascending and capped by ``limit`` - (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports. + When the window contains more rows than ``limit``, the **most recent** N rows + are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to + ascending order before being sent to the client. This ensures that for long + time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data + rather than the oldest segment of the window. + + When the window has fewer rows than ``limit`` the full window is returned in + ascending order — behaviour is identical to a plain ascending query. + + The response schema is unchanged: items are always ``recorded_at`` ascending. The query uses the ``(device_id, recorded_at)`` composite index for efficient time-window scans. @@ -320,7 +328,7 @@ def get_readings( stmt = ( select(ModbusReading) .where(ModbusReading.device_id == device.id) - .order_by(ModbusReading.recorded_at) + .order_by(ModbusReading.recorded_at.desc()) .limit(limit) ) @@ -329,7 +337,8 @@ def get_readings( if end is not None: stmt = stmt.where(ModbusReading.recorded_at <= end) - rows = db.execute(stmt).scalars().all() + # Fetch most-recent N rows, then reverse to restore ascending order for the response. + rows = list(reversed(db.execute(stmt).scalars().all())) items = [ModbusReadingResponse(recorded_at=r.recorded_at, payload=r.payload) for r in rows] return ModbusReadingsResponse(items=items) diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index f8765b2..be3e6d0 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -375,7 +375,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) - **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。 ### M5-T07 — 前端:读数展示 + Recharts 走势图 -- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳) +- **Status**: `done` · **Depends**: M5-T05(数据), M5-T06(页面壳) - **Context**: 在 Energy 页展示每设备最新读数 + 时间序列走势。 - **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts`,`package-lock.json` 同步);`create` 对应测试 - **Steps**: 最新读数卡片(接 `latest`,字段标签/单位取自 `/metrics`);时间范围选择 + 折线图(电压/电流/功率/电能,从 `payload` 按 key 取序列),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ceafa9d..6b71af0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,8 @@ "react-dom": "^18.3.1", "react-feather": "^2.0.10", "react-leaflet": "^4.2.1", - "react-router-dom": "^6.30.4" + "react-router-dom": "^6.30.4", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -1446,6 +1447,42 @@ "node": ">=10" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -1855,7 +1892,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@tanstack/query-core": { @@ -2058,6 +2100,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2131,6 +2236,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -3120,6 +3231,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3213,6 +3445,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -3565,6 +3803,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", + "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3847,6 +4095,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4312,6 +4566,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4377,6 +4641,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", @@ -5712,7 +5985,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/react-leaflet": { @@ -5739,6 +6011,29 @@ "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5867,6 +6162,36 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5881,6 +6206,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5935,6 +6275,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6458,6 +6804,12 @@ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6873,6 +7225,37 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 792d5d3..0cfae17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,8 @@ "react-dom": "^18.3.1", "react-feather": "^2.0.10", "react-leaflet": "^4.2.1", - "react-router-dom": "^6.30.4" + "react-router-dom": "^6.30.4", + "recharts": "^3.8.1" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/frontend/src/energy/EnergyCharts.test.tsx b/frontend/src/energy/EnergyCharts.test.tsx new file mode 100644 index 0000000..821752b --- /dev/null +++ b/frontend/src/energy/EnergyCharts.test.tsx @@ -0,0 +1,256 @@ +/** + * Tests for energy/EnergyCharts.tsx + * + * Coverage: + * 1. Renders chart title with device name. + * 2. Shows loading state while readings/metrics are loading. + * 3. Shows error state when either query fails. + * 4. Shows empty state when no readings are in the time window. + * 5. Renders chart container when data is available. + * 6. Tolerates missing keys in payload (no crash when key absent). + * 7. Time-range segmented control is rendered. + * + * NOTE: Recharts itself is NOT mocked — we let it render (jsdom-compatible + * rendering), but we only assert on data-testid wrappers, not Recharts internals. + * Recharts SVG rendering may produce warnings in jsdom; these are expected and + * benign (jsdom lacks ResizeObserver / SVG layout). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' +import { EnergyCharts } from './EnergyCharts' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const UUID = 'device-uuid-test' +const DEVICE_NAME = 'SDM120 Main' + +const METRICS_RESP = { + profile: 'sdm120', + metrics: [ + { key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }, + { key: 'current', label: 'Current', unit: 'A', device_class: 'current' }, + ], +} + +const READING = { + recorded_at: '2026-06-22T10:00:00Z', + payload: { voltage: 230.2, current: 1.3 }, +} + +const READINGS_RESP = { + items: [READING], +} + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + GET: (...args: unknown[]) => mockGet(...args), + POST: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function setupDefaultMocks() { + mockGet.mockImplementation((path: string) => { + if (path === '/api/modbus/devices/{uuid}/metrics') { + return Promise.resolve({ data: METRICS_RESP }) + } + if (path === '/api/modbus/devices/{uuid}/readings') { + return Promise.resolve({ data: READINGS_RESP }) + } + return Promise.resolve({ data: null }) + }) +} + +function renderChart() { + return renderWithProviders( + , + { initialPath: '/energy' }, + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('EnergyCharts — rendering', () => { + beforeEach(() => { + vi.clearAllMocks() + setupDefaultMocks() + }) + + it('renders chart title with device name', async () => { + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-title-${UUID}`)).toBeInTheDocument() + }) + + expect(screen.getByTestId(`energy-charts-title-${UUID}`).textContent).toBe(DEVICE_NAME) + }) + + it('renders time-range segmented control', async () => { + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-preset-${UUID}`)).toBeInTheDocument() + }) + }) + + it('shows loading state while data is being fetched', () => { + // Never resolve + mockGet.mockReturnValue(new Promise(() => {})) + renderChart() + + expect(screen.getByTestId(`energy-charts-loading-${UUID}`)).toBeInTheDocument() + }) + + it('shows error state when readings query fails', async () => { + mockGet.mockRejectedValue(new Error('Network error')) + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-error-${UUID}`)).toBeInTheDocument() + }) + }) + + it('shows empty state when no readings in time window', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/api/modbus/devices/{uuid}/metrics') { + return Promise.resolve({ data: METRICS_RESP }) + } + if (path === '/api/modbus/devices/{uuid}/readings') { + return Promise.resolve({ data: { items: [] } }) + } + return Promise.resolve({ data: null }) + }) + + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-empty-${UUID}`)).toBeInTheDocument() + }) + }) + + it('renders chart container when data is available', async () => { + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument() + }) + }) +}) + +describe('EnergyCharts — truncation notice', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shows truncation notice when readings count equals the limit cap (1000)', async () => { + // Build exactly 1000 synthetic readings to trigger the truncation hint. + const truncatedItems = Array.from({ length: 1000 }, (_, i) => ({ + recorded_at: new Date(Date.now() - (999 - i) * 5000).toISOString(), + payload: { voltage: 230 + i * 0.01 }, + })) + + mockGet.mockImplementation((path: string) => { + if (path === '/api/modbus/devices/{uuid}/metrics') { + return Promise.resolve({ data: METRICS_RESP }) + } + if (path === '/api/modbus/devices/{uuid}/readings') { + return Promise.resolve({ data: { items: truncatedItems } }) + } + return Promise.resolve({ data: null }) + }) + + renderChart() + + await waitFor(() => { + expect( + screen.getByTestId(`energy-charts-truncated-${UUID}`), + ).toBeInTheDocument() + }) + }) + + it('does NOT show truncation notice when readings count is below limit', async () => { + // Default mock has exactly 1 item — well below 1000. + setupDefaultMocks() + renderChart() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument() + }) + + expect(screen.queryByTestId(`energy-charts-truncated-${UUID}`)).not.toBeInTheDocument() + }) +}) + +describe('EnergyCharts — payload key tolerance', () => { + beforeEach(() => vi.clearAllMocks()) + + it('does not crash when payload is missing a metric key', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/api/modbus/devices/{uuid}/metrics') { + return Promise.resolve({ data: METRICS_RESP }) + } + if (path === '/api/modbus/devices/{uuid}/readings') { + // payload is missing 'current' key + return Promise.resolve({ + data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] }, + }) + } + return Promise.resolve({ data: null }) + }) + + // Should render without throwing + expect(() => renderChart()).not.toThrow() + + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument() + }) + }) + + it('does not crash when payload is entirely empty', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/api/modbus/devices/{uuid}/metrics') { + return Promise.resolve({ data: METRICS_RESP }) + } + if (path === '/api/modbus/devices/{uuid}/readings') { + return Promise.resolve({ + data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: {} }] }, + }) + } + return Promise.resolve({ data: null }) + }) + + expect(() => renderChart()).not.toThrow() + + // All values null → empty state (no chart) or chart with no data points shown. + await waitFor(() => { + expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/energy/EnergyCharts.tsx b/frontend/src/energy/EnergyCharts.tsx new file mode 100644 index 0000000..3a78721 --- /dev/null +++ b/frontend/src/energy/EnergyCharts.tsx @@ -0,0 +1,333 @@ +/** + * EnergyCharts — self-contained Recharts wrapper for Modbus device trend charts. + * + * Design decisions (M5 decision 11): + * - Recharts is imported ONLY in this file (isolation, easy to swap later). + * - The component is fully self-contained: it owns its own data fetching via + * useReadings / useMetrics hooks and renders loading/error/empty states. + * - Time-range selection is internal; readings are always fetched with a window + * + limit cap — never a full-table pull. + * - Metric labels and units come from GET /metrics; missing keys in payload are + * tolerated (a null data point is emitted so the line simply has a gap). + */ + +// --------------------------------------------------------------------------- +// Recharts imports — keep ALL recharts imports inside this file. +// --------------------------------------------------------------------------- + +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + Legend, + ResponsiveContainer, +} from 'recharts' + +import { useState, useMemo } from 'react' +import { + Stack, + Group, + Text, + Loader, + Alert, + SegmentedControl, + Paper, + Title, + Badge, + Box, +} from '@mantine/core' + +import { useReadings, useMetrics } from './hooks' +import type { MetricInfo } from './hooks' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Must match the limit passed to useReadings below. */ +const CHART_READINGS_LIMIT = 1000 + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface EnergyChartsProps { + /** UUID of the Modbus device to chart. */ + uuid: string + /** Friendly name for the chart title. */ + deviceName: string +} + +// --------------------------------------------------------------------------- +// Time-range presets +// --------------------------------------------------------------------------- + +interface TimePreset { + label: string + value: string + /** How many milliseconds of history to show. */ + spanMs: number +} + +const TIME_PRESETS: TimePreset[] = [ + { label: '1 h', value: '1h', spanMs: 60 * 60 * 1000 }, + { label: '6 h', value: '6h', spanMs: 6 * 60 * 60 * 1000 }, + { label: '24 h', value: '24h', spanMs: 24 * 60 * 60 * 1000 }, +] + +const DEFAULT_PRESET = '1h' + +/** Derive ISO start/end strings for a given preset. */ +function presetWindow(spanMs: number): { start: string; end: string } { + const end = new Date() + const start = new Date(end.getTime() - spanMs) + return { start: start.toISOString(), end: end.toISOString() } +} + +// --------------------------------------------------------------------------- +// Metric colour palette — cycles through a fixed set +// --------------------------------------------------------------------------- + +const LINE_COLORS = [ + '#4c9cdb', + '#f59f00', + '#51cf66', + '#f03e3e', + '#cc5de8', + '#20c997', + '#fd7e14', + '#74c0fc', +] + +function lineColor(index: number): string { + return LINE_COLORS[index % LINE_COLORS.length] +} + +// --------------------------------------------------------------------------- +// Helper: format a recorded_at timestamp for the X-axis tick +// --------------------------------------------------------------------------- + +function formatTimeTick(isoString: string): string { + try { + const d = new Date(isoString) + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + } catch { + return isoString + } +} + +// --------------------------------------------------------------------------- +// Helper: safe numeric read from payload — returns null if key absent/non-numeric +// --------------------------------------------------------------------------- + +function safePayloadValue( + payload: Record | null | undefined, + key: string, +): number | null { + if (!payload) return null + const raw = payload[key] + if (raw === null || raw === undefined) return null + const n = Number(raw) + return Number.isFinite(n) ? n : null +} + +// --------------------------------------------------------------------------- +// EnergyCharts component +// --------------------------------------------------------------------------- + +export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { + const [activePreset, setActivePreset] = useState(DEFAULT_PRESET) + + const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0] + const { start, end } = useMemo( + () => presetWindow(selectedPreset.spanMs), + // Recompute whenever the preset changes; intentionally excludes Date.now() + // so the window doesn't drift on every render — it only resets on tab change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [activePreset], + ) + + const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }) + const metricsQuery = useMetrics(uuid) + + // ------------------------------------------------------------------------- + // Derive chart data: [{recorded_at, voltage: 230.2, current: 1.3, ...}, ...] + // ------------------------------------------------------------------------- + + const metricsData = metricsQuery.data?.metrics + + const chartData = useMemo(() => { + const metrics: MetricInfo[] = metricsData ?? [] + const readings = readingsQuery.data?.items ?? [] + return readings.map((row) => { + const point: Record = { + recorded_at: row.recorded_at, + } + for (const m of metrics) { + // Tolerate missing keys — null produces a gap in the line, not a crash. + point[m.key] = safePayloadValue(row.payload as Record, m.key) + } + return point + }) + }, [readingsQuery.data, metricsData]) + + const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? [] + + // ------------------------------------------------------------------------- + // States: loading / error / empty + // ------------------------------------------------------------------------- + + const isLoading = readingsQuery.isLoading || metricsQuery.isLoading + const isError = readingsQuery.isError || metricsQuery.isError + + /** + * True when the returned row count equals the limit cap, meaning the window + * contains more data than was fetched. The backend returns the most-recent N + * rows in this case, so the chart shows the latest segment — but a hint is + * shown so the user knows the full window is not displayed. + */ + const isTruncated = + !isLoading && + !isError && + (readingsQuery.data?.items.length ?? 0) >= CHART_READINGS_LIMIT + + return ( + + + {/* Header row */} + + + {deviceName} + + ({ value: p.value, label: p.label }))} + data-testid={`energy-charts-preset-${uuid}`} + /> + + + {/* Loading */} + {isLoading && ( + + + + Loading readings… + + + )} + + {/* Error */} + {!isLoading && isError && ( + + Failed to load readings or metrics. Please try again. + + )} + + {/* Empty */} + {!isLoading && !isError && chartData.length === 0 && ( + + No readings in this time window. + + )} + + {/* Truncation notice — shown when window has more data than the fetch limit */} + {isTruncated && ( + + 显示最近 {CHART_READINGS_LIMIT} 条;完整长时段走势待降采样 + + )} + + {/* Chart */} + {!isLoading && !isError && chartData.length > 0 && ( + + {/* Render one chart per metric for clarity (avoids mixed Y-axis units) */} + {metrics.map((metric, idx) => { + // Check if this metric has any non-null data points; skip if all null. + const hasData = chartData.some((pt) => pt[metric.key] !== null) + if (!hasData) return null + + return ( + + + + {metric.label} + + {metric.unit && ( + + {metric.unit} + + )} + + + + + + + metric.unit ? `${v} ${metric.unit}` : String(v) + } + /> + { + const n = value as number | null | undefined + if (n == null) return ['–', metric.label] as [string, string] + return [ + `${n}${metric.unit ? ' ' + metric.unit : ''}`, + metric.label, + ] as [string, string] + }} + labelFormatter={(label) => { + try { + return new Date(String(label)).toLocaleString() + } catch { + return String(label) + } + }} + /> + + + + + + ) + })} + + )} + + + ) +} diff --git a/frontend/src/energy/hooks.test.tsx b/frontend/src/energy/hooks.test.tsx index 1a6a74d..51e5679 100644 --- a/frontend/src/energy/hooks.test.tsx +++ b/frontend/src/energy/hooks.test.tsx @@ -212,3 +212,136 @@ describe('useTestReadDevice', () => { }) }) }) + +describe('useLatestReading', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /api/modbus/devices/{uuid}/latest with path param', async () => { + mockGet.mockResolvedValue({ + data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }, + }) + + const { Wrapper } = makeWrapper() + const { useLatestReading } = await import('./hooks') + const { result } = renderHook(() => useLatestReading('test-uuid-1'), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', { + params: { path: { uuid: 'test-uuid-1' } }, + }) + expect(result.current.data?.found).toBe(true) + expect(result.current.data?.payload).toEqual({ voltage: 230.2 }) + }) + + it('returns found=false when device has no readings', async () => { + mockGet.mockResolvedValue({ + data: { found: false, recorded_at: null, payload: null }, + }) + + const { Wrapper } = makeWrapper() + const { useLatestReading } = await import('./hooks') + const { result } = renderHook(() => useLatestReading('test-uuid-2'), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.found).toBe(false) + expect(result.current.data?.payload).toBeNull() + }) +}) + +describe('useMetrics', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => { + mockGet.mockResolvedValue({ + data: { + profile: 'sdm120', + metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }], + }, + }) + + const { Wrapper } = makeWrapper() + const { useMetrics } = await import('./hooks') + const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', { + params: { path: { uuid: 'test-uuid-1' } }, + }) + expect(result.current.data?.metrics).toHaveLength(1) + expect(result.current.data?.metrics[0].key).toBe('voltage') + }) +}) + +describe('useReadings', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', async () => { + mockGet.mockResolvedValue({ + data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] }, + }) + + const { Wrapper } = makeWrapper() + const { useReadings } = await import('./hooks') + const params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 } + const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', { + params: { + path: { uuid: 'test-uuid-1' }, + query: { + start: '2026-06-22T09:00:00Z', + end: '2026-06-22T10:00:00Z', + limit: 500, + }, + }, + }) + expect(result.current.data?.items).toHaveLength(1) + }) + + it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => { + mockGet.mockResolvedValue({ data: { items: [] } }) + + const { Wrapper } = makeWrapper() + const { useReadings } = await import('./hooks') + const { result } = renderHook( + () => useReadings('test-uuid-1', { limit: 99999 }), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + // The query param limit should be capped at 1000. + expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.objectContaining({ limit: 1000 }), + }), + }), + ) + }) + + it('omits start/end from query params when not provided', async () => { + mockGet.mockResolvedValue({ data: { items: [] } }) + + const { Wrapper } = makeWrapper() + const { useReadings } = await import('./hooks') + const { result } = renderHook( + () => useReadings('test-uuid-1', {}), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', + expect.objectContaining({ + params: expect.objectContaining({ + query: expect.not.objectContaining({ start: expect.anything() }), + }), + }), + ) + }) +}) diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts index d933005..54fac76 100644 --- a/frontend/src/energy/hooks.ts +++ b/frontend/src/energy/hooks.ts @@ -8,6 +8,9 @@ * ['modbus-devices'] — device list * ['modbus-device', uuid] — single device * ['modbus-profiles'] — profile list (rarely changes) + * ['modbus-latest', uuid] — latest reading per device + * ['modbus-metrics', uuid] — profile metric metadata per device + * ['modbus-readings', uuid, params] — time-range readings per device * * On success, mutations invalidate the device list so the UI refreshes. */ @@ -25,6 +28,22 @@ export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate'] export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate'] export type ProfileSummary = components['schemas']['ProfileSummary'] export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse'] +export type ModbusLatestResponse = components['schemas']['ModbusLatestResponse'] +export type ModbusMetricsResponse = components['schemas']['ModbusMetricsResponse'] +export type MetricInfo = components['schemas']['MetricInfo'] +export type ModbusReadingResponse = components['schemas']['ModbusReadingResponse'] +export type ModbusReadingsResponse = components['schemas']['ModbusReadingsResponse'] + +// --------------------------------------------------------------------------- +// Reading query params +// --------------------------------------------------------------------------- + +export interface ReadingsQueryParams { + start?: string | null + end?: string | null + /** Capped server-side; default max is 1000 to avoid pulling full history. */ + limit?: number +} // --------------------------------------------------------------------------- // Query: list all devices @@ -112,3 +131,70 @@ export function useTestReadDevice() { }), }) } + +// --------------------------------------------------------------------------- +// Query: latest reading for a device +// --------------------------------------------------------------------------- + +export function useLatestReading(uuid: string) { + return useQuery({ + queryKey: ['modbus-latest', uuid], + queryFn: async () => { + const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', { + params: { path: { uuid } }, + }) + return res.data + }, + // Refresh every 10 s to show up-to-date readings. + refetchInterval: 10_000, + }) +} + +// --------------------------------------------------------------------------- +// Query: profile metric metadata for a device (label/unit per key) +// --------------------------------------------------------------------------- + +export function useMetrics(uuid: string) { + return useQuery({ + queryKey: ['modbus-metrics', uuid], + queryFn: async () => { + const res = await apiClient.GET('/api/modbus/devices/{uuid}/metrics', { + params: { path: { uuid } }, + }) + return res.data + }, + // Metrics are static (tied to profile version); 5 min stale time. + staleTime: 5 * 60 * 1000, + }) +} + +// --------------------------------------------------------------------------- +// Query: time-range readings for a device (window + limit — never full-table) +// --------------------------------------------------------------------------- + +/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */ +const READINGS_MAX_LIMIT = 1000 + +export function useReadings(uuid: string, params: ReadingsQueryParams) { + const { start, end, limit } = params + const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT) + + return useQuery({ + queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }], + queryFn: async () => { + const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', { + params: { + path: { uuid }, + query: { + ...(start ? { start } : {}), + ...(end ? { end } : {}), + limit: effectiveLimit, + }, + }, + }) + return res.data + }, + // Enabled only when we have valid uuid; start/end may be null (full window). + enabled: !!uuid, + }) +} diff --git a/frontend/src/pages/EnergyPage.test.tsx b/frontend/src/pages/EnergyPage.test.tsx index 05c194a..7c51f7a 100644 --- a/frontend/src/pages/EnergyPage.test.tsx +++ b/frontend/src/pages/EnergyPage.test.tsx @@ -107,9 +107,13 @@ describe('EnergyPage — device list', () => { expect(screen.getByTestId('devices-table')).toBeInTheDocument() }) - expect(screen.getByText('SDM120 Main')).toBeInTheDocument() + // Device name appears in multiple places now (table row + readings section + chart title). + const nameElements = screen.getAllByText('SDM120 Main') + expect(nameElements.length).toBeGreaterThan(0) expect(screen.getByText('192.168.1.100')).toBeInTheDocument() - expect(screen.getByText('sdm120')).toBeInTheDocument() + // 'sdm120' may also appear in multiple places (table badge + chart). + const profileElements = screen.getAllByText('sdm120') + expect(profileElements.length).toBeGreaterThan(0) }) it('shows empty state when no devices', async () => { diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx index d20ae45..dae0612 100644 --- a/frontend/src/pages/EnergyPage.tsx +++ b/frontend/src/pages/EnergyPage.tsx @@ -1,14 +1,16 @@ /** * EnergyPage — device management UI for the Energy (Modbus) domain. * - * Features: + * Features (T06 + T07): * - Device list with status indicators (enabled, last poll). * - Create / edit via DeviceForm modal. * - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead. * - "Test read" button per device — calls POST /devices/{uuid}/test, shows - * decoded payload or error without persisting to the database. - * - * Out of scope (T07): readings cards, trend charts, Recharts import. + * decoded payload or error without persisting to the database (T06 OBS-1: error + * now caught and displayed rather than producing an unhandled rejection). + * - Latest readings card per device (T07): fetches /latest and /metrics; tolerates + * missing payload keys. + * - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside). */ import { useState } from 'react' @@ -28,10 +30,14 @@ import { Modal, Code, Tooltip, + Paper, + SimpleGrid, + Divider, } from '@mantine/core' -import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks' +import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks' import { DeviceForm } from '../energy/DeviceForm' -import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks' +import { EnergyCharts } from '../energy/EnergyCharts' +import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks' import { ApiError } from '../api/client' // --------------------------------------------------------------------------- @@ -131,6 +137,8 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) // --------------------------------------------------------------------------- // Device row action: test-read button (self-contained per row) +// T06 OBS-1 fix: catch errors from mutateAsync so they don't produce unhandled +// promise rejections (e.g. 422 when profile cannot load). // --------------------------------------------------------------------------- interface TestReadButtonProps { @@ -140,12 +148,25 @@ interface TestReadButtonProps { function TestReadButton({ device }: TestReadButtonProps) { const testMutation = useTestReadDevice() const [result, setResult] = useState(null) + const [testError, setTestError] = useState(null) async function handleTest() { setResult(null) - const res = await testMutation.mutateAsync(device.uuid) - if (res.data) { - setResult(res.data) + setTestError(null) + try { + const res = await testMutation.mutateAsync(device.uuid) + if (res.data) { + setResult(res.data) + } + } catch (err) { + // Network or API errors (e.g. 422 profile load failure) — surface inline. + const message = + err instanceof ApiError + ? `Error ${err.status}: ${JSON.stringify(err.body?.detail ?? err.body ?? 'unknown error')}` + : err instanceof Error + ? err.message + : 'Unexpected error during test read.' + setTestError(message) } } @@ -164,6 +185,26 @@ function TestReadButton({ device }: TestReadButtonProps) { + {/* Show inline test error as a small alert (no modal needed for error path) */} + {testError && ( + setTestError(null)} + title={`Test read error — ${device.friendly_name}`} + size="sm" + data-testid="test-read-error-modal" + > + + {testError} + + + + + + )} + {result && ( + + + + Loading latest reading… + + + + ) + } + + if (latestQuery.isError || metricsQuery.isError) { + return ( + + Failed to load latest reading. + + ) + } + + if (!latest?.found || !latest.payload) { + return ( + + + No readings yet. + + + ) + } + + const payload = latest.payload as Record + + return ( + + + + + Latest reading + + {latest.recorded_at && ( + + {new Date(latest.recorded_at).toLocaleString()} + + )} + + + + {metrics.map((m) => { + const raw = payload[m.key] + const hasValue = raw !== null && raw !== undefined + const displayValue = hasValue + ? typeof raw === 'number' + ? raw.toFixed(raw % 1 === 0 ? 0 : 2) + : String(raw) + : '—' + + return ( + + + {m.label} + + + {displayValue} + {hasValue && m.unit ? ( + + {m.unit} + + ) : null} + + + ) + })} + + + + ) +} + // --------------------------------------------------------------------------- // Device list table // --------------------------------------------------------------------------- @@ -272,6 +409,33 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) { ) } +// --------------------------------------------------------------------------- +// Device readings section — latest card + trend chart per device (T07) +// --------------------------------------------------------------------------- + +interface DeviceReadingsSectionProps { + devices: ModbusDevice[] +} + +function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) { + if (devices.length === 0) return null + + return ( + + + {devices.map((device) => ( + + + {device.friendly_name} + + + + + ))} + + ) +} + // --------------------------------------------------------------------------- // EnergyPage — top-level // --------------------------------------------------------------------------- @@ -362,6 +526,9 @@ export function EnergyPage() { onEdit={openEdit} onDelete={openDelete} /> + + {/* Latest readings cards + trend charts (T07) */} + {/* Create form */} diff --git a/openapi/openapi.json b/openapi/openapi.json index 85f5d4a..6820a2f 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -936,7 +936,7 @@ "api-modbus" ], "summary": "Get Readings", - "description": "Return time-range readings for a device.\n\nResults are ordered by ``recorded_at`` ascending and capped by ``limit``\n(max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.\n\nThe query uses the ``(device_id, recorded_at)`` composite index for\nefficient time-window scans.\n\nQuery parameters:\n- ``start``: inclusive lower bound (ISO8601 datetime)\n- ``end``: inclusive upper bound (ISO8601 datetime)\n- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})", + "description": "Return time-range readings for a device.\n\nWhen the window contains more rows than ``limit``, the **most recent** N rows\nare returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to\nascending order before being sent to the client. This ensures that for long\ntime-range requests (e.g. 6 h / 24 h) the caller always sees the latest data\nrather than the oldest segment of the window.\n\nWhen the window has fewer rows than ``limit`` the full window is returned in\nascending order — behaviour is identical to a plain ascending query.\n\nThe response schema is unchanged: items are always ``recorded_at`` ascending.\n\nThe query uses the ``(device_id, recorded_at)`` composite index for\nefficient time-window scans.\n\nQuery parameters:\n- ``start``: inclusive lower bound (ISO8601 datetime)\n- ``end``: inclusive upper bound (ISO8601 datetime)\n- ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})", "operationId": "get_readings_api_modbus_devices__uuid__readings_get", "parameters": [ { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 69f53b9..083b1db 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -678,9 +678,24 @@ paths: description: 'Return time-range readings for a device. - Results are ordered by ``recorded_at`` ascending and capped by ``limit`` + When the window contains more rows than ``limit``, the **most recent** N rows - (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports. + are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to + + ascending order before being sent to the client. This ensures that for long + + time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data + + rather than the oldest segment of the window. + + + When the window has fewer rows than ``limit`` the full window is returned + in + + ascending order — behaviour is identical to a plain ascending query. + + + The response schema is unchanged: items are always ``recorded_at`` ascending. The query uses the ``(device_id, recorded_at)`` composite index for diff --git a/tests/test_api_modbus.py b/tests/test_api_modbus.py index 8e481f8..d881750 100644 --- a/tests/test_api_modbus.py +++ b/tests/test_api_modbus.py @@ -548,6 +548,7 @@ def test_readings_time_window_filters_correctly(modbus_client): def test_readings_limit_applied(modbus_client): + """When limit < total rows, exactly *limit* items are returned.""" client, engine = modbus_client _login(client) device = _make_device(engine) @@ -564,6 +565,49 @@ def test_readings_limit_applied(modbus_client): assert len(resp.json()["items"]) == 3 +def test_readings_limit_returns_most_recent_rows_ascending(modbus_client): + """When window rows > limit, the MOST RECENT limit rows are returned, ascending. + + This is the regression test for REWORK-1: previously the endpoint returned + the oldest N rows (ASC + LIMIT), causing 6h/24h charts to show stale data. + Now it returns the most recent N rows (DESC + LIMIT, then reversed to ASC). + """ + client, engine = modbus_client + _login(client) + device = _make_device(engine) + + now = datetime.now(UTC) + # Insert 5 readings spread over the last 5 minutes (newest first, but order shouldn't matter). + timestamps = [now - timedelta(minutes=i) for i in range(5)] # t0=now, t1=now-1m, ..., t4=now-4m + for idx, ts in enumerate(timestamps): + _make_reading(engine, device.id, ts, {"voltage": float(200 + idx)}) + # Chronologically: t4 < t3 < t2 < t1 < t0 + # Voltages mapped: 204 203 202 201 200 + + # Request only the most recent 3 rows (limit=3). + resp = client.get( + f"/api/modbus/devices/{device.uuid}/readings", + params={"limit": 3}, + ) + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 3 + + # Response must be in ascending recorded_at order. + recorded_ats = [item["recorded_at"] for item in items] + assert recorded_ats == sorted(recorded_ats), "Items must be ascending by recorded_at" + + # The 3 most recent are timestamps[0], [1], [2] → voltages 200, 201, 202. + voltages = [item["payload"]["voltage"] for item in items] + assert voltages == [202.0, 201.0, 200.0], ( + f"Expected most recent 3 rows (voltages 200/201/202 ascending), got {voltages}" + ) + + # The oldest row (voltage 204.0, at t4=now-4m) must NOT be in the response. + assert 204.0 not in voltages, "Oldest row must not appear when limit truncates the window" + assert 203.0 not in voltages, "Second-oldest row must not appear when limit truncates" + + def test_readings_limit_exceeds_max_returns_422(modbus_client): """A limit > 5000 must be rejected by FastAPI query validation (422).""" client, engine = modbus_client