diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 2d04e55..f8765b2 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -363,7 +363,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口) - **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`。 ### M5-T06 — 前端:设备管理 UI(Energy 视图) -- **Status**: `todo` · **Depends**: M5-T01, M5-T05 +- **Status**: `done` · **Depends**: M5-T01, M5-T05 - **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` - **Steps**: `useQuery`/`useMutation` 接 `/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled)+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2aa217..5b54d8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ * / → ProtectedRoute → AppLayout → HomePage * /config → ProtectedRoute → AppLayout → ConfigPage * /records → ProtectedRoute → AppLayout → RecordsPage + * /energy → ProtectedRoute → AppLayout → EnergyPage * * AppLayout renders a sidebar (AppSidebar) on the left; page content via on the right. * /login and /change-password have no sidebar layout. @@ -29,6 +30,7 @@ import { LoginPage } from './pages/LoginPage' import { HomePage } from './pages/HomePage' import { ConfigPage } from './pages/ConfigPage' import { RecordsPage } from './pages/RecordsPage' +import { EnergyPage } from './pages/EnergyPage' import { ChangePasswordPage } from './pages/ChangePasswordPage' import { AppSidebar } from './components/AppSidebar' @@ -125,6 +127,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index a78f6e4..f24a518 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -210,6 +210,199 @@ export interface paths { patch: operations["patch_poo_api_poo__timestamp__patch"]; 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": { parameters: { query?: never; @@ -620,6 +813,202 @@ export interface components { /** Totp Code */ 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: { /** Current Password */ @@ -661,6 +1050,16 @@ export interface components { /** Longitude */ longitude?: number | null; }; + /** + * ProfileSummary + * @description One entry in the GET /api/modbus/profiles response. + */ + ProfileSummary: { + /** Name */ + name: string; + /** Description */ + description: string; + }; /** 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: { parameters: { query?: never; diff --git a/frontend/src/components/AppSidebar.test.tsx b/frontend/src/components/AppSidebar.test.tsx index 88aef44..ed32808 100644 --- a/frontend/src/components/AppSidebar.test.tsx +++ b/frontend/src/components/AppSidebar.test.tsx @@ -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 * AppShell.Navbar) + MemoryRouter so useLocation() works. * * 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 '/'. * 3. Records nav item is active when pathname is '/records'. * 4. Config nav item is active when pathname is '/config'. * 5. Home is NOT active when on '/records'. + * 6. Energy nav item is active when on '/energy'. */ import { describe, it, expect, vi } from 'vitest' @@ -76,10 +77,11 @@ describe('AppSidebar', () => { // 1. All nav items render // ------------------------------------------------------------------------- - it('renders Home, Records, and Config nav items', () => { + it('renders Home, Records, Energy, and Config nav items', () => { renderSidebar('/') expect(screen.getByTestId('nav-home')).toBeInTheDocument() expect(screen.getByTestId('nav-records')).toBeInTheDocument() + expect(screen.getByTestId('nav-energy')).toBeInTheDocument() expect(screen.getByTestId('nav-config')).toBeInTheDocument() }) @@ -123,4 +125,14 @@ describe('AppSidebar', () => { const homeLink = screen.getByTestId('nav-home') 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') + }) }) diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index c2e0f94..e4465d2 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -1,18 +1,16 @@ /** * AppSidebar — vertical sidebar navigation for all protected pages. * - * Nav items: Home / Records / Config + * Nav items: Home / Records / Energy / Config * Utilities: ColorSchemeToggle + LogoutButton (at bottom) * * Current route is highlighted via useLocation(). * 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 { 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 apiClient from '../api/client' @@ -40,6 +38,12 @@ const NAV_ENTRIES: NavEntry[] = [ icon: , testId: 'nav-records', }, + { + to: '/energy', + label: 'Energy', + icon: , + testId: 'nav-energy', + }, { to: '/config', label: 'Config', diff --git a/frontend/src/energy/DeviceForm.test.tsx b/frontend/src/energy/DeviceForm.test.tsx new file mode 100644 index 0000000..3949a8a --- /dev/null +++ b/frontend/src/energy/DeviceForm.test.tsx @@ -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(, { + initialPath: '/', + }) +} + +function renderEditForm(onClose = vi.fn(), onSaved = vi.fn()) { + return renderWithProviders(, { + 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' }), + }) + }) + }) +}) diff --git a/frontend/src/energy/DeviceForm.tsx b/frontend/src/energy/DeviceForm.tsx new file mode 100644 index 0000000..a50e71b --- /dev/null +++ b/frontend/src/energy/DeviceForm.tsx @@ -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(device?.port ?? 502) + const [unitId, setUnitId] = useState(device?.unit_id ?? 1) + const [profile, setProfile] = useState(device?.profile ?? null) + const [pollIntervalS, setPollIntervalS] = useState(device?.poll_interval_s ?? 5) + const [enabled, setEnabled] = useState(device?.enabled ?? true) + const [error, setError] = useState(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 ( + + {profilesQuery.isLoading && ( +
+ +
+ )} + + {profilesQuery.isError && ( + + Failed to load profiles. Cannot create device. + + )} + + {!profilesQuery.isLoading && ( +
+ + {isEdit && ( + + UUID: {device!.uuid} + + )} + + setFriendlyName(e.currentTarget.value)} + data-testid="device-friendly-name" + /> + + setHost(e.currentTarget.value)} + data-testid="device-host" + /> + + setPort(val)} + data-testid="device-port" + /> + + setUnitId(val)} + data-testid="device-unit-id" + /> + +