M5-T06: add Energy device management UI and sidebar entry
This commit is contained in:
@@ -363,7 +363,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。
|
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。
|
||||||
|
|
||||||
### M5-T06 — 前端:设备管理 UI(Energy 视图)
|
### M5-T06 — 前端:设备管理 UI(Energy 视图)
|
||||||
- **Status**: `todo` · **Depends**: M5-T01, M5-T05
|
- **Status**: `done` · **Depends**: M5-T01, M5-T05
|
||||||
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
|
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
|
||||||
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/DeviceForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
|
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/DeviceForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
|
||||||
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
|
- **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* / → ProtectedRoute → AppLayout → HomePage
|
* / → ProtectedRoute → AppLayout → HomePage
|
||||||
* /config → ProtectedRoute → AppLayout → ConfigPage
|
* /config → ProtectedRoute → AppLayout → ConfigPage
|
||||||
* /records → ProtectedRoute → AppLayout → RecordsPage
|
* /records → ProtectedRoute → AppLayout → RecordsPage
|
||||||
|
* /energy → ProtectedRoute → AppLayout → EnergyPage
|
||||||
*
|
*
|
||||||
* AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
|
* AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
|
||||||
* /login and /change-password have no sidebar layout.
|
* /login and /change-password have no sidebar layout.
|
||||||
@@ -29,6 +30,7 @@ import { LoginPage } from './pages/LoginPage'
|
|||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { ConfigPage } from './pages/ConfigPage'
|
import { ConfigPage } from './pages/ConfigPage'
|
||||||
import { RecordsPage } from './pages/RecordsPage'
|
import { RecordsPage } from './pages/RecordsPage'
|
||||||
|
import { EnergyPage } from './pages/EnergyPage'
|
||||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||||
import { AppSidebar } from './components/AppSidebar'
|
import { AppSidebar } from './components/AppSidebar'
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ export default function App() {
|
|||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="/config" element={<ConfigPage />} />
|
<Route path="/config" element={<ConfigPage />} />
|
||||||
<Route path="/records" element={<RecordsPage />} />
|
<Route path="/records" element={<RecordsPage />} />
|
||||||
|
<Route path="/energy" element={<EnergyPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
Vendored
+703
@@ -210,6 +210,199 @@ export interface paths {
|
|||||||
patch: operations["patch_poo_api_poo__timestamp__patch"];
|
patch: operations["patch_poo_api_poo__timestamp__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/modbus/profiles": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Profiles
|
||||||
|
* @description List all available Modbus YAML profiles (name + description).
|
||||||
|
*
|
||||||
|
* Intended for the front-end's device-creation profile drop-down.
|
||||||
|
*/
|
||||||
|
get: operations["get_profiles_api_modbus_profiles_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List Devices
|
||||||
|
* @description Return all Modbus devices (no pagination — device counts stay small).
|
||||||
|
*/
|
||||||
|
get: operations["list_devices_api_modbus_devices_get"];
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Create Device
|
||||||
|
* @description Create a new Modbus device.
|
||||||
|
*
|
||||||
|
* - Validates that the referenced ``profile`` exists; returns 422 if not.
|
||||||
|
* - Returns 201 with the created device on success.
|
||||||
|
*/
|
||||||
|
post: operations["create_device_api_modbus_devices_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices/{uuid}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Device
|
||||||
|
* @description Return a single Modbus device by UUID.
|
||||||
|
*/
|
||||||
|
get: operations["get_device_api_modbus_devices__uuid__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Delete Device
|
||||||
|
* @description Delete a Modbus device.
|
||||||
|
*
|
||||||
|
* Returns 409 Conflict if the device has any associated readings; use
|
||||||
|
* ``enabled=false`` to disable it instead.
|
||||||
|
*
|
||||||
|
* **Application-layer safety**: the check is performed via an explicit
|
||||||
|
* SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
|
||||||
|
* which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
|
||||||
|
*/
|
||||||
|
delete: operations["delete_device_api_modbus_devices__uuid__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/**
|
||||||
|
* Patch Device
|
||||||
|
* @description Partially update a Modbus device (including enable/disable).
|
||||||
|
*
|
||||||
|
* Only fields explicitly provided in the request body are updated.
|
||||||
|
* Providing ``profile`` triggers a profile-existence check (422 if unknown).
|
||||||
|
*/
|
||||||
|
patch: operations["patch_device_api_modbus_devices__uuid__patch"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices/{uuid}/latest": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Latest Reading
|
||||||
|
* @description Return the most recent reading for a device.
|
||||||
|
*
|
||||||
|
* If no readings exist yet, returns ``{"found": false, "recorded_at": null,
|
||||||
|
* "payload": null}`` (200, not 404) so the front-end can distinguish
|
||||||
|
* "device exists but has no data" from "device not found".
|
||||||
|
*/
|
||||||
|
get: operations["get_latest_reading_api_modbus_devices__uuid__latest_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices/{uuid}/readings": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Readings
|
||||||
|
* @description 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.
|
||||||
|
*
|
||||||
|
* The query uses the ``(device_id, recorded_at)`` composite index for
|
||||||
|
* efficient time-window scans.
|
||||||
|
*
|
||||||
|
* Query parameters:
|
||||||
|
* - ``start``: inclusive lower bound (ISO8601 datetime)
|
||||||
|
* - ``end``: inclusive upper bound (ISO8601 datetime)
|
||||||
|
* - ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
|
||||||
|
*/
|
||||||
|
get: operations["get_readings_api_modbus_devices__uuid__readings_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices/{uuid}/metrics": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Metrics
|
||||||
|
* @description Return the metric catalogue for a device's profile.
|
||||||
|
*
|
||||||
|
* Each entry has ``key``, ``label`` (derived from key if the profile has
|
||||||
|
* none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
|
||||||
|
* This is the authoritative metadata source for front-end card labels and
|
||||||
|
* chart axis labels.
|
||||||
|
*/
|
||||||
|
get: operations["get_metrics_api_modbus_devices__uuid__metrics_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/modbus/devices/{uuid}/test": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Test Read
|
||||||
|
* @description Immediately read and decode the device once without persisting any data.
|
||||||
|
*
|
||||||
|
* Useful for validating gateway connectivity and Modbus address settings
|
||||||
|
* before relying on the background polling job.
|
||||||
|
*
|
||||||
|
* This is a write-class endpoint (it initiates network I/O on demand) and
|
||||||
|
* therefore requires a CSRF token. The response payload is never stored.
|
||||||
|
*/
|
||||||
|
post: operations["test_read_api_modbus_devices__uuid__test_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/session": {
|
"/api/session": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -620,6 +813,202 @@ export interface components {
|
|||||||
/** Totp Code */
|
/** Totp Code */
|
||||||
totp_code?: string | null;
|
totp_code?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* MetricInfo
|
||||||
|
* @description Metadata for a single measurable quantity in a device's profile.
|
||||||
|
*/
|
||||||
|
MetricInfo: {
|
||||||
|
/** Key */
|
||||||
|
key: string;
|
||||||
|
/** Label */
|
||||||
|
label: string;
|
||||||
|
/** Unit */
|
||||||
|
unit: string;
|
||||||
|
/** Device Class */
|
||||||
|
device_class: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusDeviceCreate
|
||||||
|
* @description Request body for POST /api/modbus/devices.
|
||||||
|
*/
|
||||||
|
ModbusDeviceCreate: {
|
||||||
|
/** Friendly Name */
|
||||||
|
friendly_name: string;
|
||||||
|
/**
|
||||||
|
* Transport
|
||||||
|
* @default tcp
|
||||||
|
*/
|
||||||
|
transport: string;
|
||||||
|
/** Host */
|
||||||
|
host: string;
|
||||||
|
/**
|
||||||
|
* Port
|
||||||
|
* @default 502
|
||||||
|
*/
|
||||||
|
port: number;
|
||||||
|
/**
|
||||||
|
* Unit Id
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
unit_id: number;
|
||||||
|
/** Profile */
|
||||||
|
profile: string;
|
||||||
|
/**
|
||||||
|
* Poll Interval S
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
poll_interval_s: number;
|
||||||
|
/**
|
||||||
|
* Enabled
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusDeviceListResponse
|
||||||
|
* @description Response schema for listing Modbus devices.
|
||||||
|
*/
|
||||||
|
ModbusDeviceListResponse: {
|
||||||
|
/** Items */
|
||||||
|
items: components["schemas"]["ModbusDeviceResponse"][];
|
||||||
|
/** Total */
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusDeviceResponse
|
||||||
|
* @description Response schema for a single Modbus device.
|
||||||
|
*/
|
||||||
|
ModbusDeviceResponse: {
|
||||||
|
/** Uuid */
|
||||||
|
uuid: string;
|
||||||
|
/** Friendly Name */
|
||||||
|
friendly_name: string;
|
||||||
|
/** Transport */
|
||||||
|
transport: string;
|
||||||
|
/** Host */
|
||||||
|
host: string;
|
||||||
|
/** Port */
|
||||||
|
port: number;
|
||||||
|
/** Unit Id */
|
||||||
|
unit_id: number;
|
||||||
|
/** Profile */
|
||||||
|
profile: string;
|
||||||
|
/** Poll Interval S */
|
||||||
|
poll_interval_s: number;
|
||||||
|
/** Enabled */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Last Poll At */
|
||||||
|
last_poll_at: string | null;
|
||||||
|
/** Last Poll Ok */
|
||||||
|
last_poll_ok: boolean | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Updated At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusDeviceUpdate
|
||||||
|
* @description Request body for PATCH /api/modbus/devices/{uuid} — all fields optional.
|
||||||
|
*/
|
||||||
|
ModbusDeviceUpdate: {
|
||||||
|
/** Friendly Name */
|
||||||
|
friendly_name?: string | null;
|
||||||
|
/** Transport */
|
||||||
|
transport?: string | null;
|
||||||
|
/** Host */
|
||||||
|
host?: string | null;
|
||||||
|
/** Port */
|
||||||
|
port?: number | null;
|
||||||
|
/** Unit Id */
|
||||||
|
unit_id?: number | null;
|
||||||
|
/** Profile */
|
||||||
|
profile?: string | null;
|
||||||
|
/** Poll Interval S */
|
||||||
|
poll_interval_s?: number | null;
|
||||||
|
/** Enabled */
|
||||||
|
enabled?: boolean | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusLatestResponse
|
||||||
|
* @description Response for the /latest endpoint.
|
||||||
|
*
|
||||||
|
* ``found`` is False and ``recorded_at``/``payload`` are None when the device
|
||||||
|
* has no readings yet. Callers should check ``found`` before using the values.
|
||||||
|
*/
|
||||||
|
ModbusLatestResponse: {
|
||||||
|
/** Found */
|
||||||
|
found: boolean;
|
||||||
|
/** Recorded At */
|
||||||
|
recorded_at: string | null;
|
||||||
|
/** Payload */
|
||||||
|
payload: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusMetricsResponse
|
||||||
|
* @description Response schema for GET /api/modbus/devices/{uuid}/metrics.
|
||||||
|
*/
|
||||||
|
ModbusMetricsResponse: {
|
||||||
|
/** Profile */
|
||||||
|
profile: string;
|
||||||
|
/** Metrics */
|
||||||
|
metrics: components["schemas"]["MetricInfo"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusProfilesResponse
|
||||||
|
* @description Response schema for GET /api/modbus/profiles.
|
||||||
|
*/
|
||||||
|
ModbusProfilesResponse: {
|
||||||
|
/** Profiles */
|
||||||
|
profiles: components["schemas"]["ProfileSummary"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusReadingResponse
|
||||||
|
* @description A single reading row: timestamp + decoded payload.
|
||||||
|
*/
|
||||||
|
ModbusReadingResponse: {
|
||||||
|
/**
|
||||||
|
* Recorded At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
recorded_at: string;
|
||||||
|
/** Payload */
|
||||||
|
payload: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusReadingsResponse
|
||||||
|
* @description Response schema for the readings time-range endpoint.
|
||||||
|
*/
|
||||||
|
ModbusReadingsResponse: {
|
||||||
|
/** Items */
|
||||||
|
items: components["schemas"]["ModbusReadingResponse"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ModbusTestReadResponse
|
||||||
|
* @description Response for POST /api/modbus/devices/{uuid}/test.
|
||||||
|
*
|
||||||
|
* On success ``ok=True`` and ``payload`` contains the decoded values.
|
||||||
|
* On failure ``ok=False`` and ``error`` describes the problem.
|
||||||
|
*/
|
||||||
|
ModbusTestReadResponse: {
|
||||||
|
/** Ok */
|
||||||
|
ok: boolean;
|
||||||
|
/** Payload */
|
||||||
|
payload?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/** Error */
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
/** PasswordChangeRequest */
|
/** PasswordChangeRequest */
|
||||||
PasswordChangeRequest: {
|
PasswordChangeRequest: {
|
||||||
/** Current Password */
|
/** Current Password */
|
||||||
@@ -661,6 +1050,16 @@ export interface components {
|
|||||||
/** Longitude */
|
/** Longitude */
|
||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ProfileSummary
|
||||||
|
* @description One entry in the GET /api/modbus/profiles response.
|
||||||
|
*/
|
||||||
|
ProfileSummary: {
|
||||||
|
/** Name */
|
||||||
|
name: string;
|
||||||
|
/** Description */
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
/** PublicIPCheckResponse */
|
/** PublicIPCheckResponse */
|
||||||
PublicIPCheckResponse: {
|
PublicIPCheckResponse: {
|
||||||
/**
|
/**
|
||||||
@@ -1177,6 +1576,310 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_profiles_api_modbus_profiles_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusProfilesResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
list_devices_api_modbus_devices_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_device_api_modbus_devices_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
"X-CSRF-Token"?: string | null;
|
||||||
|
};
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_device_api_modbus_devices__uuid__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_device_api_modbus_devices__uuid__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
"X-CSRF-Token"?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
patch_device_api_modbus_devices__uuid__patch: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
"X-CSRF-Token"?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusDeviceResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_latest_reading_api_modbus_devices__uuid__latest_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusLatestResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_readings_api_modbus_devices__uuid__readings_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
start?: string | null;
|
||||||
|
end?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusReadingsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_metrics_api_modbus_devices__uuid__metrics_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusMetricsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
test_read_api_modbus_devices__uuid__test_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: {
|
||||||
|
"X-CSRF-Token"?: string | null;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
uuid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ModbusTestReadResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_session_api_session_get: {
|
get_session_api_session_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for AppSidebar (M5-T01).
|
* Tests for AppSidebar (M5-T01, updated M5-T06).
|
||||||
*
|
*
|
||||||
* Strategy: render AppSidebar inside a minimal AppShell (required by
|
* Strategy: render AppSidebar inside a minimal AppShell (required by
|
||||||
* AppShell.Navbar) + MemoryRouter so useLocation() works.
|
* AppShell.Navbar) + MemoryRouter so useLocation() works.
|
||||||
*
|
*
|
||||||
* Coverage:
|
* Coverage:
|
||||||
* 1. All three nav items render (Home, Records, Config).
|
* 1. All four nav items render (Home, Records, Energy, Config).
|
||||||
* 2. Home nav item is active when pathname is '/'.
|
* 2. Home nav item is active when pathname is '/'.
|
||||||
* 3. Records nav item is active when pathname is '/records'.
|
* 3. Records nav item is active when pathname is '/records'.
|
||||||
* 4. Config nav item is active when pathname is '/config'.
|
* 4. Config nav item is active when pathname is '/config'.
|
||||||
* 5. Home is NOT active when on '/records'.
|
* 5. Home is NOT active when on '/records'.
|
||||||
|
* 6. Energy nav item is active when on '/energy'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
@@ -76,10 +77,11 @@ describe('AppSidebar', () => {
|
|||||||
// 1. All nav items render
|
// 1. All nav items render
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('renders Home, Records, and Config nav items', () => {
|
it('renders Home, Records, Energy, and Config nav items', () => {
|
||||||
renderSidebar('/')
|
renderSidebar('/')
|
||||||
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
|
expect(screen.getByTestId('nav-home')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
|
expect(screen.getByTestId('nav-records')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('nav-energy')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
|
expect(screen.getByTestId('nav-config')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,4 +125,14 @@ describe('AppSidebar', () => {
|
|||||||
const homeLink = screen.getByTestId('nav-home')
|
const homeLink = screen.getByTestId('nav-home')
|
||||||
expect(homeLink).not.toHaveAttribute('data-active', 'true')
|
expect(homeLink).not.toHaveAttribute('data-active', 'true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 6. Energy active on '/energy'
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('marks Energy nav item as active when on "/energy"', () => {
|
||||||
|
renderSidebar('/energy')
|
||||||
|
const energyLink = screen.getByTestId('nav-energy')
|
||||||
|
expect(energyLink).toHaveAttribute('data-active', 'true')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* AppSidebar — vertical sidebar navigation for all protected pages.
|
* AppSidebar — vertical sidebar navigation for all protected pages.
|
||||||
*
|
*
|
||||||
* Nav items: Home / Records / Config
|
* Nav items: Home / Records / Energy / Config
|
||||||
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
|
* Utilities: ColorSchemeToggle + LogoutButton (at bottom)
|
||||||
*
|
*
|
||||||
* Current route is highlighted via useLocation().
|
* Current route is highlighted via useLocation().
|
||||||
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
|
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
|
||||||
*
|
|
||||||
* NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
|
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
import { Home, List, Settings, Sun, Moon, LogOut, Zap } from 'react-feather'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
@@ -40,6 +38,12 @@ const NAV_ENTRIES: NavEntry[] = [
|
|||||||
icon: <List size={18} />,
|
icon: <List size={18} />,
|
||||||
testId: 'nav-records',
|
testId: 'nav-records',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/energy',
|
||||||
|
label: 'Energy',
|
||||||
|
icon: <Zap size={18} />,
|
||||||
|
testId: 'nav-energy',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/config',
|
to: '/config',
|
||||||
label: 'Config',
|
label: 'Config',
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/DeviceForm.tsx
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders empty form in create mode (title "New Device").
|
||||||
|
* 2. Renders pre-filled form in edit mode (title "Edit Device"), shows UUID.
|
||||||
|
* 3. Validation: missing friendly_name shows error.
|
||||||
|
* 4. Profile dropdown loads options from GET /api/modbus/profiles.
|
||||||
|
* 5. Submit in create mode calls POST /api/modbus/devices.
|
||||||
|
* 6. Submit in edit mode calls PATCH /api/modbus/devices/{uuid}.
|
||||||
|
* 7. Cancel button invokes onClose without submitting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { DeviceForm } from './DeviceForm'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEVICE = {
|
||||||
|
uuid: 'abc-123',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES_RESP = {
|
||||||
|
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
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 setupProfilesMock() {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESP })
|
||||||
|
}
|
||||||
|
// modbus-devices for invalidation
|
||||||
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCreateForm(onClose = vi.fn(), onSaved = vi.fn()) {
|
||||||
|
return renderWithProviders(<DeviceForm onClose={onClose} onSaved={onSaved} />, {
|
||||||
|
initialPath: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditForm(onClose = vi.fn(), onSaved = vi.fn()) {
|
||||||
|
return renderWithProviders(<DeviceForm device={DEVICE} onClose={onClose} onSaved={onSaved} />, {
|
||||||
|
initialPath: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DeviceForm — create mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupProfilesMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with title "New Device" and empty friendly-name input', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal title
|
||||||
|
expect(screen.getByText('New Device')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// UUID display should NOT be present in create mode
|
||||||
|
expect(screen.queryByTestId('device-uuid-display')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads profile options from GET /api/modbus/profiles', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Profile select should be rendered
|
||||||
|
expect(screen.getByTestId('device-profile')).toBeInTheDocument()
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows validation error when friendly name is empty', async () => {
|
||||||
|
renderCreateForm()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-form-error').textContent).toContain('Friendly name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls POST /api/modbus/devices with correct body (profile auto-selected)', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
const onSaved = vi.fn()
|
||||||
|
renderCreateForm(vi.fn(), onSaved)
|
||||||
|
|
||||||
|
// Wait for profiles to load; sdm120 is auto-selected as first profile.
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Fill form (profile auto-selected to 'sdm120')
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'My Meter' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('device-host'), {
|
||||||
|
target: { value: '10.0.0.1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/modbus/devices',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
friendly_name: 'My Meter',
|
||||||
|
host: '10.0.0.1',
|
||||||
|
transport: 'tcp',
|
||||||
|
profile: 'sdm120',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel calls onClose without POST', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
renderCreateForm(onClose)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form-cancel')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-form-cancel'))
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('DeviceForm — edit mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupProfilesMock()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders with title "Edit Device" and pre-filled friendly-name', async () => {
|
||||||
|
renderEditForm()
|
||||||
|
|
||||||
|
// Wait for modal to appear
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit Device')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Wait for form to appear (profiles must load first)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-uuid-display').textContent).toContain('abc-123')
|
||||||
|
|
||||||
|
// Friendly name input should be pre-filled
|
||||||
|
const input = screen.getByTestId('device-friendly-name') as HTMLInputElement
|
||||||
|
expect(input.value).toBe('SDM120 Main')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit calls PATCH /api/modbus/devices/{uuid}', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ data: DEVICE })
|
||||||
|
renderEditForm()
|
||||||
|
|
||||||
|
// Wait for profiles to load so the form is visible
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'SDM120 Updated' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'abc-123' } },
|
||||||
|
body: expect.objectContaining({ friendly_name: 'SDM120 Updated' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* DeviceForm — create / edit a Modbus device.
|
||||||
|
*
|
||||||
|
* Used for both new-device creation (no `device` prop) and editing an
|
||||||
|
* existing device (pass `device` prop with current values).
|
||||||
|
*
|
||||||
|
* Profile list is fetched from GET /api/modbus/profiles and rendered as a
|
||||||
|
* Select dropdown. All other fields are inline inputs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useProfiles, useCreateDevice, useUpdateDevice } from './hooks'
|
||||||
|
import type { ModbusDevice, ModbusDeviceCreate, ModbusDeviceUpdate } from './hooks'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeviceFormProps {
|
||||||
|
/** When provided, the form is in edit mode; otherwise create mode. */
|
||||||
|
device?: ModbusDevice
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function DeviceForm({ device, onClose, onSaved }: DeviceFormProps) {
|
||||||
|
const isEdit = !!device
|
||||||
|
|
||||||
|
// Form state — initialise from device if editing.
|
||||||
|
const [friendlyName, setFriendlyName] = useState(device?.friendly_name ?? '')
|
||||||
|
const [host, setHost] = useState(device?.host ?? '')
|
||||||
|
const [port, setPort] = useState<number | string>(device?.port ?? 502)
|
||||||
|
const [unitId, setUnitId] = useState<number | string>(device?.unit_id ?? 1)
|
||||||
|
const [profile, setProfile] = useState<string | null>(device?.profile ?? null)
|
||||||
|
const [pollIntervalS, setPollIntervalS] = useState<number | string>(device?.poll_interval_s ?? 5)
|
||||||
|
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const profilesQuery = useProfiles()
|
||||||
|
const createMutation = useCreateDevice()
|
||||||
|
const updateMutation = useUpdateDevice()
|
||||||
|
|
||||||
|
const isBusy = createMutation.isPending || updateMutation.isPending
|
||||||
|
|
||||||
|
// Build select options from profiles response.
|
||||||
|
const profileOptions =
|
||||||
|
profilesQuery.data?.profiles.map((p) => ({
|
||||||
|
value: p.name,
|
||||||
|
label: `${p.name} — ${p.description}`,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
// In create mode, if no explicit selection has been made and profiles are loaded,
|
||||||
|
// derive a default from the first available profile. We avoid useEffect+setState
|
||||||
|
// (causes cascading renders) by computing the effective value here instead.
|
||||||
|
const effectiveProfile: string | null =
|
||||||
|
profile !== null
|
||||||
|
? profile
|
||||||
|
: !isEdit && profileOptions.length > 0
|
||||||
|
? profileOptions[0].value
|
||||||
|
: null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (!friendlyName.trim()) return 'Friendly name is required.'
|
||||||
|
if (!host.trim()) return 'Host is required.'
|
||||||
|
const portNum = Number(port)
|
||||||
|
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||||
|
return 'Port must be an integer between 1 and 65535.'
|
||||||
|
const unitNum = Number(unitId)
|
||||||
|
if (!Number.isInteger(unitNum) || unitNum < 1 || unitNum > 247)
|
||||||
|
return 'Unit ID must be an integer between 1 and 247.'
|
||||||
|
if (!effectiveProfile) return 'Profile is required.'
|
||||||
|
const pollNum = Number(pollIntervalS)
|
||||||
|
if (!Number.isInteger(pollNum) || pollNum < 1)
|
||||||
|
return 'Poll interval must be a positive integer (seconds).'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Submit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const validationError = validate()
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && device) {
|
||||||
|
const body: ModbusDeviceUpdate = {
|
||||||
|
friendly_name: friendlyName.trim(),
|
||||||
|
host: host.trim(),
|
||||||
|
port: Number(port),
|
||||||
|
unit_id: Number(unitId),
|
||||||
|
profile: effectiveProfile!,
|
||||||
|
poll_interval_s: Number(pollIntervalS),
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
await updateMutation.mutateAsync({ uuid: device.uuid, body })
|
||||||
|
} else {
|
||||||
|
const body: ModbusDeviceCreate = {
|
||||||
|
friendly_name: friendlyName.trim(),
|
||||||
|
transport: 'tcp',
|
||||||
|
host: host.trim(),
|
||||||
|
port: Number(port),
|
||||||
|
unit_id: Number(unitId),
|
||||||
|
profile: effectiveProfile!,
|
||||||
|
poll_interval_s: Number(pollIntervalS),
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
await createMutation.mutateAsync(body)
|
||||||
|
}
|
||||||
|
onSaved()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save device. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEdit ? 'Edit Device' : 'New Device'}
|
||||||
|
size="md"
|
||||||
|
data-testid="device-form-modal"
|
||||||
|
>
|
||||||
|
{profilesQuery.isLoading && (
|
||||||
|
<Center py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profilesQuery.isError && (
|
||||||
|
<Alert color="red" mb="sm" data-testid="profiles-load-error">
|
||||||
|
Failed to load profiles. Cannot create device.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!profilesQuery.isLoading && (
|
||||||
|
<form onSubmit={handleSubmit} data-testid="device-form">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{isEdit && (
|
||||||
|
<Text size="xs" c="dimmed" data-testid="device-uuid-display">
|
||||||
|
UUID: {device!.uuid}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Friendly name"
|
||||||
|
required
|
||||||
|
value={friendlyName}
|
||||||
|
onChange={(e) => setFriendlyName(e.currentTarget.value)}
|
||||||
|
data-testid="device-friendly-name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Host"
|
||||||
|
description="Gateway IP address"
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.currentTarget.value)}
|
||||||
|
data-testid="device-host"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Port"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={port}
|
||||||
|
onChange={(val) => setPort(val)}
|
||||||
|
data-testid="device-port"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Unit ID"
|
||||||
|
description="Modbus slave address (Meter ID on device panel)"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
max={247}
|
||||||
|
value={unitId}
|
||||||
|
onChange={(val) => setUnitId(val)}
|
||||||
|
data-testid="device-unit-id"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Profile"
|
||||||
|
description="YAML device profile"
|
||||||
|
required
|
||||||
|
data={profileOptions}
|
||||||
|
value={effectiveProfile}
|
||||||
|
onChange={setProfile}
|
||||||
|
data-testid="device-profile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label="Poll interval (seconds)"
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
value={pollIntervalS}
|
||||||
|
onChange={(val) => setPollIntervalS(val)}
|
||||||
|
data-testid="device-poll-interval"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label="Enabled"
|
||||||
|
description="Whether this device is polled"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.currentTarget.checked)}
|
||||||
|
data-testid="device-enabled"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" data-testid="device-form-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} data-testid="device-form-cancel">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={isBusy} data-testid="device-form-submit">
|
||||||
|
{isEdit ? 'Save' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests for energy/hooks.ts — Modbus API wrappers.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. useDevices — calls GET /api/modbus/devices and returns data.
|
||||||
|
* 2. useProfiles — calls GET /api/modbus/profiles.
|
||||||
|
* 3. useCreateDevice — calls POST /api/modbus/devices with body.
|
||||||
|
* 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body.
|
||||||
|
* 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}.
|
||||||
|
* 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
DELETE: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEVICE = {
|
||||||
|
uuid: 'test-uuid-1',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: null,
|
||||||
|
last_poll_ok: null,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeWrapper() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
return { qc, Wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('useDevices', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/devices and returns device list', async () => {
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useDevices } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useDevices(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices')
|
||||||
|
expect(result.current.data?.items).toHaveLength(1)
|
||||||
|
expect(result.current.data?.items[0].uuid).toBe('test-uuid-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProfiles', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls GET /api/modbus/profiles and returns profiles', async () => {
|
||||||
|
mockGet.mockResolvedValue({
|
||||||
|
data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useProfiles } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
|
||||||
|
expect(result.current.data?.profiles[0].name).toBe('sdm120')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCreateDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/modbus/devices with the provided body', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
// LIST query to satisfy invalidation
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useCreateDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useUpdateDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => {
|
||||||
|
mockPatch.mockResolvedValue({ data: DEVICE })
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useUpdateDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
body: { enabled: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useDeleteDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls DELETE /api/modbus/devices/{uuid}', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: null })
|
||||||
|
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useDeleteDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('test-uuid-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useTestReadDevice', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: true, payload: { voltage: 230.2 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { Wrapper } = makeWrapper()
|
||||||
|
const { useTestReadDevice } = await import('./hooks')
|
||||||
|
const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper })
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync('test-uuid-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', {
|
||||||
|
params: { path: { uuid: 'test-uuid-1' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Energy / Modbus hooks — typed TanStack Query wrappers for /api/modbus/*.
|
||||||
|
*
|
||||||
|
* All write operations go through the typed apiClient (openapi-fetch), which
|
||||||
|
* injects CSRF via the csrfMiddleware already wired into the client.
|
||||||
|
*
|
||||||
|
* Query-key conventions:
|
||||||
|
* ['modbus-devices'] — device list
|
||||||
|
* ['modbus-device', uuid] — single device
|
||||||
|
* ['modbus-profiles'] — profile list (rarely changes)
|
||||||
|
*
|
||||||
|
* On success, mutations invalidate the device list so the UI refreshes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { components } from '../api/schema.d.ts'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Re-exported types for consumers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ModbusDevice = components['schemas']['ModbusDeviceResponse']
|
||||||
|
export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
|
||||||
|
export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
|
||||||
|
export type ProfileSummary = components['schemas']['ProfileSummary']
|
||||||
|
export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list all devices
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDevices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/devices')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Query: list available profiles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useProfiles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['modbus-profiles'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/modbus/profiles')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
// Profiles are static — 5 min stale time.
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: create device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useCreateDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ModbusDeviceCreate) =>
|
||||||
|
apiClient.POST('/api/modbus/devices', { body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: update (PATCH) device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useUpdateDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ uuid, body }: { uuid: string; body: ModbusDeviceUpdate }) =>
|
||||||
|
apiClient.PATCH('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: delete device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useDeleteDevice() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (uuid: string) =>
|
||||||
|
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mutation: test-read device (POST /devices/{uuid}/test, does NOT persist)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useTestReadDevice() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (uuid: string) =>
|
||||||
|
apiClient.POST('/api/modbus/devices/{uuid}/test', {
|
||||||
|
params: { path: { uuid } },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* Tests for EnergyPage (M5-T06).
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders device list from GET /api/modbus/devices.
|
||||||
|
* 2. Empty state when no devices.
|
||||||
|
* 3. "New Device" button opens DeviceForm modal.
|
||||||
|
* 4. Edit button opens DeviceForm modal pre-filled with device data.
|
||||||
|
* 5. Delete button opens confirmation modal; confirm calls DELETE.
|
||||||
|
* 6. Delete 409 error shows friendly hint in confirmation modal.
|
||||||
|
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
|
||||||
|
* 8. Test-read failure shows error in modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { EnergyPage } from './EnergyPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEVICE = {
|
||||||
|
uuid: 'abc-123',
|
||||||
|
friendly_name: 'SDM120 Main',
|
||||||
|
transport: 'tcp',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 502,
|
||||||
|
unit_id: 1,
|
||||||
|
profile: 'sdm120',
|
||||||
|
poll_interval_s: 5,
|
||||||
|
enabled: true,
|
||||||
|
last_poll_at: '2026-06-22T10:00:00Z',
|
||||||
|
last_poll_ok: true,
|
||||||
|
created_at: '2026-06-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROFILES_RESP = {
|
||||||
|
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
const mockDelete = vi.fn()
|
||||||
|
const mockPatch = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
PATCH: (...args: unknown[]) => mockPatch(...args),
|
||||||
|
DELETE: (...args: unknown[]) => mockDelete(...args),
|
||||||
|
},
|
||||||
|
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(devices = [DEVICE]) {
|
||||||
|
mockGet.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/modbus/devices') {
|
||||||
|
return Promise.resolve({ data: { items: devices, total: devices.length } })
|
||||||
|
}
|
||||||
|
if (path === '/api/modbus/profiles') {
|
||||||
|
return Promise.resolve({ data: PROFILES_RESP })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnergy() {
|
||||||
|
return renderWithProviders(<EnergyPage />, { initialPath: '/energy' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('EnergyPage — device list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders device list from GET /api/modbus/devices', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('sdm120')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no devices', async () => {
|
||||||
|
setupDefaultMocks([])
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('devices-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading state initially', () => {
|
||||||
|
// Delay resolution so we can catch the loading state
|
||||||
|
mockGet.mockReturnValue(new Promise(() => {}))
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('energy-loading')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — create device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens DeviceForm modal when New Device is clicked', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-new-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||||
|
|
||||||
|
// Wait for both modal AND the form (which appears after profiles load)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('device-host')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submit on new device form calls POST /api/modbus/devices', async () => {
|
||||||
|
mockPost.mockResolvedValue({ data: DEVICE })
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('device-new-button'))
|
||||||
|
|
||||||
|
// Wait for profiles to load so the form is visible (profiles auto-select sdm120)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Fill required fields (profile is auto-selected to 'sdm120')
|
||||||
|
fireEvent.change(screen.getByTestId('device-friendly-name'), {
|
||||||
|
target: { value: 'Test Device' },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('device-host'), {
|
||||||
|
target: { value: '10.0.0.1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('device-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/api/modbus/devices',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.objectContaining({
|
||||||
|
friendly_name: 'Test Device',
|
||||||
|
host: '10.0.0.1',
|
||||||
|
profile: 'sdm120',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — edit device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens DeviceForm modal with device data when Edit is clicked', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for profiles to load so form body is visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// UUID shown in edit mode
|
||||||
|
expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — delete device', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows confirmation modal on Delete click', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-delete-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls DELETE on confirm and closes modal', async () => {
|
||||||
|
mockDelete.mockResolvedValue({ data: null })
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
|
||||||
|
params: { path: { uuid: DEVICE.uuid } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel button closes confirmation modal', async () => {
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-delete-cancel')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-cancel'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows friendly 409 hint when device has readings', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' }))
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('device-delete-confirm'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EnergyPage — test-read', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setupDefaultMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success payload modal after successful test-read', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: true, payload: { voltage: 230.2, current: 1.3 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error modal after failed test-read', async () => {
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
data: { ok: false, error: 'Connection timed out' },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderEnergy()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-read-error')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* EnergyPage — device management UI for the Energy (Modbus) domain.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Badge,
|
||||||
|
ScrollArea,
|
||||||
|
Modal,
|
||||||
|
Code,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
|
||||||
|
import { DeviceForm } from '../energy/DeviceForm'
|
||||||
|
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Delete confirmation modal (with 409 "disable instead" hint)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfirmDeleteProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
loading: boolean
|
||||||
|
/** Set when the delete failed with 409. */
|
||||||
|
has409Error: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onCancel}
|
||||||
|
title="Confirm Delete"
|
||||||
|
size="sm"
|
||||||
|
data-testid="device-delete-modal"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text data-testid="device-delete-message">
|
||||||
|
Delete device <strong>{device.friendly_name}</strong>?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{has409Error && (
|
||||||
|
<Alert color="orange" data-testid="device-delete-409-hint">
|
||||||
|
This device has existing readings and cannot be deleted. Consider{' '}
|
||||||
|
<strong>disabling</strong> it instead (click Edit → uncheck Enabled).
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
loading={loading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="device-delete-confirm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test-read result modal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TestResultModalProps {
|
||||||
|
result: ModbusTestReadResponse
|
||||||
|
deviceName: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Test read — ${deviceName}`}
|
||||||
|
size="md"
|
||||||
|
data-testid="test-read-modal"
|
||||||
|
>
|
||||||
|
{result.ok ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Badge color="green" data-testid="test-read-ok">Success</Badge>
|
||||||
|
<Code block data-testid="test-read-payload">
|
||||||
|
{JSON.stringify(result.payload, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Badge color="red" data-testid="test-read-error-badge">Failed</Badge>
|
||||||
|
<Text c="red" data-testid="test-read-error">
|
||||||
|
{result.error ?? 'Unknown error'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device row action: test-read button (self-contained per row)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TestReadButtonProps {
|
||||||
|
device: ModbusDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestReadButton({ device }: TestReadButtonProps) {
|
||||||
|
const testMutation = useTestReadDevice()
|
||||||
|
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setResult(null)
|
||||||
|
const res = await testMutation.mutateAsync(device.uuid)
|
||||||
|
if (res.data) {
|
||||||
|
setResult(res.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Immediately read device (not saved)" position="top">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="blue"
|
||||||
|
loading={testMutation.isPending}
|
||||||
|
onClick={handleTest}
|
||||||
|
data-testid={`device-test-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Test read
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<TestResultModal
|
||||||
|
result={result}
|
||||||
|
deviceName={device.friendly_name}
|
||||||
|
onClose={() => setResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device list table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface DeviceTableProps {
|
||||||
|
devices: ModbusDevice[]
|
||||||
|
onEdit: (device: ModbusDevice) => void
|
||||||
|
onDelete: (device: ModbusDevice) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" size="sm" data-testid="devices-empty">
|
||||||
|
No devices configured yet. Click "New Device" to add one.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea>
|
||||||
|
<Table striped highlightOnHover withTableBorder data-testid="devices-table">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Host</Table.Th>
|
||||||
|
<Table.Th>Port</Table.Th>
|
||||||
|
<Table.Th>Unit ID</Table.Th>
|
||||||
|
<Table.Th>Profile</Table.Th>
|
||||||
|
<Table.Th>Poll (s)</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<Table.Tr key={device.uuid} data-testid={`device-row-${device.uuid}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{device.friendly_name}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{device.host}</Table.Td>
|
||||||
|
<Table.Td>{device.port}</Table.Td>
|
||||||
|
<Table.Td>{device.unit_id}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{device.profile}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{device.poll_interval_s}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color={device.enabled ? 'green' : 'gray'} variant="light" size="sm">
|
||||||
|
{device.enabled ? 'enabled' : 'disabled'}
|
||||||
|
</Badge>
|
||||||
|
{device.last_poll_ok !== null && (
|
||||||
|
<Badge
|
||||||
|
color={device.last_poll_ok ? 'teal' : 'red'}
|
||||||
|
variant="dot"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{device.last_poll_ok ? 'online' : 'offline'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<TestReadButton device={device} />
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onEdit(device)}
|
||||||
|
data-testid={`device-edit-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => onDelete(device)}
|
||||||
|
data-testid={`device-delete-${device.uuid}`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnergyPage — top-level
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EnergyPage() {
|
||||||
|
const devicesQuery = useDevices()
|
||||||
|
const deleteMutation = useDeleteDevice()
|
||||||
|
|
||||||
|
const [editDevice, setEditDevice] = useState<ModbusDevice | null>(null)
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [deleteDevice, setDeleteDevice] = useState<ModbusDevice | null>(null)
|
||||||
|
const [delete409, setDelete409] = useState(false)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditDevice(null)
|
||||||
|
setShowCreateForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(device: ModbusDevice) {
|
||||||
|
setShowCreateForm(false)
|
||||||
|
setEditDevice(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(device: ModbusDevice) {
|
||||||
|
setDeleteDevice(device)
|
||||||
|
setDelete409(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDelete() {
|
||||||
|
setDeleteDevice(null)
|
||||||
|
setDelete409(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteConfirm() {
|
||||||
|
if (!deleteDevice) return
|
||||||
|
setDelete409(false)
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteDevice.uuid)
|
||||||
|
closeDelete()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409) {
|
||||||
|
setDelete409(true)
|
||||||
|
}
|
||||||
|
// Leave modal open so user sees the hint.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Loading / error states
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (devicesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="xl" data-testid="energy-loading">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devicesQuery.isError || !devicesQuery.data) {
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl">
|
||||||
|
<Alert color="red" data-testid="energy-load-error">
|
||||||
|
Failed to load devices. Please refresh.
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = devicesQuery.data.items
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Title order={2}>Energy — Devices</Title>
|
||||||
|
<Button onClick={openCreate} data-testid="device-new-button">
|
||||||
|
New Device
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<DeviceTable
|
||||||
|
devices={devices}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={openDelete}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<DeviceForm
|
||||||
|
onClose={() => setShowCreateForm(false)}
|
||||||
|
onSaved={() => setShowCreateForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit form */}
|
||||||
|
{editDevice && (
|
||||||
|
<DeviceForm
|
||||||
|
device={editDevice}
|
||||||
|
onClose={() => setEditDevice(null)}
|
||||||
|
onSaved={() => setEditDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteDevice && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
device={deleteDevice}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={closeDelete}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
has409Error={delete409}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user