diff --git a/webclient-vue/src/api/modules/devices.js b/webclient-vue/src/api/modules/devices.js new file mode 100644 index 0000000..38d0927 --- /dev/null +++ b/webclient-vue/src/api/modules/devices.js @@ -0,0 +1,43 @@ +import { apiGet, apiPost } from "../client"; +import { unifyDeviceFields } from "../mappers"; + +function safeId(id) { + return encodeURIComponent(String(id)); +} + +function mapDevicesResponse(result) { + if (!result.ok) { + return result; + } + + const devices = result.data?.data?.devices || []; + + return { + ...result, + data: { + ...result.data, + data: { + ...result.data?.data, + devices: devices.map(unifyDeviceFields), + }, + }, + }; +} + +export const devicesApi = { + async list() { + return mapDevicesResponse(await apiGet("/api/v1/devices/list")); + }, + + status(id, options) { + return apiGet(`/api/v1/devices/id/${safeId(id)}/status`, options); + }, + + reboot(id) { + return apiGet(`/api/v1/devices/id/${safeId(id)}/reboot`); + }, + + action(payload) { + return apiPost("/api/v1/devices/action", payload); + }, +}; diff --git a/webclient-vue/src/features/devices/components/DeviceConnectionBadge.vue b/webclient-vue/src/features/devices/components/DeviceConnectionBadge.vue new file mode 100644 index 0000000..badfade --- /dev/null +++ b/webclient-vue/src/features/devices/components/DeviceConnectionBadge.vue @@ -0,0 +1,28 @@ + + + diff --git a/webclient-vue/src/features/devices/components/DeviceStateCell.vue b/webclient-vue/src/features/devices/components/DeviceStateCell.vue new file mode 100644 index 0000000..6681b31 --- /dev/null +++ b/webclient-vue/src/features/devices/components/DeviceStateCell.vue @@ -0,0 +1,36 @@ + + + diff --git a/webclient-vue/src/features/devices/pages/DevicesListPage.vue b/webclient-vue/src/features/devices/pages/DevicesListPage.vue new file mode 100644 index 0000000..ccc4a01 --- /dev/null +++ b/webclient-vue/src/features/devices/pages/DevicesListPage.vue @@ -0,0 +1,109 @@ + + + diff --git a/webclient-vue/src/router/routes.js b/webclient-vue/src/router/routes.js index 91872ef..f692228 100644 --- a/webclient-vue/src/router/routes.js +++ b/webclient-vue/src/router/routes.js @@ -1,5 +1,6 @@ import AreaFavoritesPage from "../features/areas/pages/AreaFavoritesPage.vue"; import AreaTreePage from "../features/areas/pages/AreaTreePage.vue"; +import DevicesListPage from "../features/devices/pages/DevicesListPage.vue"; export const routes = [ { @@ -17,6 +18,11 @@ component: AreaTreePage, }, { + path: "/devices", + name: "devices", + component: DevicesListPage, + }, + { path: "/:pathMatch(.*)*", name: "not-found", component: () => import("../features/system/NotFoundPage.vue"), diff --git a/webclient-vue/src/stores/devices.js b/webclient-vue/src/stores/devices.js new file mode 100644 index 0000000..a9cfa61 --- /dev/null +++ b/webclient-vue/src/stores/devices.js @@ -0,0 +1,165 @@ +import { defineStore } from "pinia"; +import { devicesApi } from "../api/modules/devices"; + +const DEFAULT_STATE_CONCURRENCY = 4; + +function getDeviceId(device) { + return String(device?.id || ""); +} + +function makeDeviceStatePatch(device, patch) { + return { + deviceId: getDeviceId(device), + status: "idle", + message: "", + response: null, + connectionStatus: device?.connection_status || "unknown", + updatedAt: null, + ...patch, + }; +} + +function normalizeStatusSuccess(device, result) { + const payload = result.data?.data?.device || {}; + const response = payload.device_response || {}; + + return makeDeviceStatePatch(device, { + status: "ready", + message: response.status || "ok", + response, + connectionStatus: "active", + updatedAt: new Date().toISOString(), + }); +} + +function normalizeStatusError(device, result) { + const raw = result.error?.raw || {}; + const connectionStatus = raw?.data?.connection_status || device?.connection_status || "unknown"; + + return makeDeviceStatePatch(device, { + status: "error", + message: result.error?.message || "Device state is unavailable", + response: raw, + connectionStatus, + updatedAt: new Date().toISOString(), + }); +} + +async function runLimited(items, concurrency, worker) { + let nextIndex = 0; + const poolSize = Math.max(1, Math.min(concurrency, items.length)); + + async function runWorker() { + while (nextIndex < items.length) { + const item = items[nextIndex]; + nextIndex += 1; + await worker(item); + } + } + + await Promise.all(Array.from({ length: poolSize }, runWorker)); +} + +export const useDevicesStore = defineStore("devices", { + state: () => ({ + devices: [], + isLoading: false, + error: null, + isLoadingStates: false, + stateError: null, + stateByDeviceId: {}, + stateRunId: 0, + }), + + getters: { + total(state) { + return state.devices.length; + }, + }, + + actions: { + async loadDevices() { + this.isLoading = true; + this.error = null; + + const result = await devicesApi.list(); + this.isLoading = false; + + if (!result.ok) { + this.error = result.error; + return result; + } + + this.devices = result.data?.data?.devices || []; + return result; + }, + + setDeviceState(device, patch) { + const deviceId = getDeviceId(device); + + if (!deviceId) { + return; + } + + this.stateByDeviceId = { + ...this.stateByDeviceId, + [deviceId]: makeDeviceStatePatch(device, patch), + }; + }, + + async loadDeviceStates(options = {}) { + const runId = this.stateRunId + 1; + this.stateRunId = runId; + this.isLoadingStates = true; + this.stateError = null; + this.stateByDeviceId = {}; + + const devices = this.devices.slice(); + const targets = []; + + for (const device of devices) { + if (device.connection_status === "lost") { + this.setDeviceState(device, { + status: "skipped", + message: "Connection lost", + connectionStatus: "lost", + }); + } else { + this.setDeviceState(device, { + status: "loading", + message: "Loading", + }); + targets.push(device); + } + } + + try { + await runLimited(targets, options.concurrency || DEFAULT_STATE_CONCURRENCY, async (device) => { + const result = await devicesApi.status(device.id); + + if (this.stateRunId !== runId) { + return; + } + + this.stateByDeviceId = { + ...this.stateByDeviceId, + [getDeviceId(device)]: result.ok + ? normalizeStatusSuccess(device, result) + : normalizeStatusError(device, result), + }; + }); + } catch (error) { + if (this.stateRunId === runId) { + this.stateError = { + type: "state_loader_error", + message: error?.message || "Device states loader failed", + }; + } + } finally { + if (this.stateRunId === runId) { + this.isLoadingStates = false; + } + } + }, + }, +}); diff --git a/webclient-vue/src/styles/main.css b/webclient-vue/src/styles/main.css index 4ad54b5..6f2566e 100644 --- a/webclient-vue/src/styles/main.css +++ b/webclient-vue/src/styles/main.css @@ -243,10 +243,98 @@ color: #07080b; } +.ui-badge-success { + background: var(--color-accent); + color: #07080b; +} + +.ui-badge-danger { + background: var(--color-danger); + color: #07080b; +} + .ui-badge-neutral { background: var(--color-panel-strong); } +.devices-panel { + display: grid; + gap: 16px; +} + +.devices-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.devices-table-wrap { + overflow-x: auto; + border: var(--border); + background: var(--color-panel); +} + +.devices-table { + width: 100%; + min-width: 900px; + border-collapse: collapse; +} + +.devices-table th, +.devices-table td { + padding: 16px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid rgba(244, 244, 245, 0.18); +} + +.devices-table th { + color: var(--color-muted); + text-transform: uppercase; +} + +.devices-table tr:last-child td { + border-bottom: 0; +} + +.devices-table strong, +.devices-table small { + display: block; +} + +.devices-table small { + margin-top: 6px; + color: var(--color-muted); +} + +.devices-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.device-state { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--color-panel-strong); +} + +.device-state-ready { + background: var(--color-accent); + color: #07080b; +} + +.device-state-error { + background: var(--color-danger); + color: #07080b; +} + +.device-state-skipped { + color: var(--color-muted); +} + .state { color: var(--color-muted); }