diff --git a/webclient-vue/src/api/__tests__/modules.spec.js b/webclient-vue/src/api/__tests__/modules.spec.js index 53184ea..18aa6a1 100644 --- a/webclient-vue/src/api/__tests__/modules.spec.js +++ b/webclient-vue/src/api/__tests__/modules.spec.js @@ -65,16 +65,16 @@ expect(apiGet).toHaveBeenCalledWith("/api/v1/areas/id/5/list", undefined); }); - it("devices encodes areaId", async () => { + it("devices encodes areaId and passes options", async () => { const { apiGet } = await import("../client.js"); await areasApi.devices(3); - expect(apiGet).toHaveBeenCalledWith("/api/v1/areas/id/3/devices"); + expect(apiGet).toHaveBeenCalledWith("/api/v1/areas/id/3/devices", undefined); }); - it("scripts encodes areaId", async () => { + it("scripts encodes areaId and passes options", async () => { const { apiGet } = await import("../client.js"); await areasApi.scripts(3); - expect(apiGet).toHaveBeenCalledWith("/api/v1/areas/id/3/scripts"); + expect(apiGet).toHaveBeenCalledWith("/api/v1/areas/id/3/scripts", undefined); }); it("newArea posts payload", async () => { diff --git a/webclient-vue/src/api/modules/areas.js b/webclient-vue/src/api/modules/areas.js index d6b717f..530fb2a 100644 --- a/webclient-vue/src/api/modules/areas.js +++ b/webclient-vue/src/api/modules/areas.js @@ -17,12 +17,12 @@ return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/remove`); }, - devices(areaId) { - return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/devices`); + devices(areaId, options) { + return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/devices`, options); }, - scripts(areaId) { - return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/scripts`); + scripts(areaId, options) { + return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/scripts`, options); }, updateDisplayName(payload) { diff --git a/webclient-vue/src/api/modules/devices.js b/webclient-vue/src/api/modules/devices.js index 45a2dad..f184168 100644 --- a/webclient-vue/src/api/modules/devices.js +++ b/webclient-vue/src/api/modules/devices.js @@ -53,8 +53,8 @@ return apiPost("/api/v1/devices/setup/new-device", payload); }, - detail(id) { - return apiGet(`/api/v1/devices/id/${safeId(id)}`); + detail(id, options) { + return apiGet(`/api/v1/devices/id/${safeId(id)}`, options); }, updateName(id, name) { diff --git a/webclient-vue/src/api/modules/scripts.js b/webclient-vue/src/api/modules/scripts.js index 1434525..051f685 100644 --- a/webclient-vue/src/api/modules/scripts.js +++ b/webclient-vue/src/api/modules/scripts.js @@ -29,8 +29,8 @@ return apiGet(`/api/v1/scripts/scopes/name/${encodeURIComponent(name)}/${enabled ? "enable" : "disable"}`); }, - scopeCode(name) { - return apiGet(`/api/v1/scripts/scopes/name/${encodeURIComponent(name)}`); + scopeCode(name, options) { + return apiGet(`/api/v1/scripts/scopes/name/${encodeURIComponent(name)}`, options); }, placeInArea(payload) { diff --git a/webclient-vue/src/composables/__tests__/useAsyncRequest.spec.js b/webclient-vue/src/composables/__tests__/useAsyncRequest.spec.js new file mode 100644 index 0000000..9af4aa9 --- /dev/null +++ b/webclient-vue/src/composables/__tests__/useAsyncRequest.spec.js @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useAsyncRequest } from "../useAsyncRequest"; + +describe("useAsyncRequest", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + it("sets isLoading to true during execution and false after", async () => { + const { isLoading, execute } = useAsyncRequest(); + + const promise = execute(async () => { + expect(isLoading.value).toBe(true); + return { ok: true, data: [] }; + }); + + expect(isLoading.value).toBe(true); + await promise; + expect(isLoading.value).toBe(false); + }); + + it("clears previous error before new execution", async () => { + const { error, execute } = useAsyncRequest(); + + await execute(async () => ({ ok: false, error: { message: "fail" } })); + expect(error.value).toEqual({ message: "fail" }); + + await execute(async () => ({ ok: true, data: [] })); + expect(error.value).toBeNull(); + }); + + it("sets error on failed result", async () => { + const { error, execute } = useAsyncRequest(); + + const result = await execute(async () => ({ + ok: false, + error: { type: "api_error", message: "Bad request" }, + })); + + expect(result.ok).toBe(false); + expect(error.value).toEqual({ type: "api_error", message: "Bad request" }); + }); + + it("ignores timeout error by default", async () => { + const { error, execute } = useAsyncRequest(); + + const result = await execute(async () => ({ + ok: false, + error: { type: "timeout", message: "Aborted" }, + })); + + expect(result.ok).toBe(false); + expect(error.value).toBeNull(); + }); + + it("records timeout error when ignoreTimeout is disabled", async () => { + const { error, execute } = useAsyncRequest({ ignoreTimeout: false }); + + const result = await execute(async () => ({ + ok: false, + error: { type: "timeout", message: "Aborted" }, + })); + + expect(result.ok).toBe(false); + expect(error.value).toEqual({ type: "timeout", message: "Aborted" }); + }); + + it("sets network_error on thrown exception", async () => { + const { error, execute } = useAsyncRequest(); + + const result = await execute(async () => { + throw new Error("Connection refused"); + }); + + expect(result.ok).toBe(false); + expect(result.error.type).toBe("network_error"); + expect(error.value.message).toBe("Connection refused"); + }); + + it("aborts previous request before starting a new one", async () => { + const { abortController, execute } = useAsyncRequest(); + + let firstSignal; + execute(async (signal) => { + firstSignal = signal; + await new Promise((resolve) => setTimeout(resolve, 100)); + return { ok: true, data: [] }; + }); + + expect(abortController.value).not.toBeNull(); + expect(firstSignal.aborted).toBe(false); + + const secondPromise = execute(async () => ({ ok: true, data: [] })); + expect(firstSignal.aborted).toBe(true); + + await secondPromise; + }); + + it("abort() cancels the current request", async () => { + const { abortController, execute, abort } = useAsyncRequest(); + + let signal; + execute(async (s) => { + signal = s; + await new Promise((resolve) => setTimeout(resolve, 100)); + return { ok: true, data: [] }; + }); + + abort(); + expect(signal.aborted).toBe(true); + expect(abortController.value).toBeNull(); + }); + + it("clear() resets error and aborts current request", async () => { + const { error, abortController, execute, clear } = useAsyncRequest(); + + await execute(async () => ({ + ok: false, + error: { message: "fail" }, + })); + expect(error.value).not.toBeNull(); + + let signal; + execute(async (s) => { + signal = s; + await new Promise((resolve) => setTimeout(resolve, 100)); + return { ok: true, data: [] }; + }); + + clear(); + expect(error.value).toBeNull(); + expect(signal.aborted).toBe(true); + expect(abortController.value).toBeNull(); + }); +}); diff --git a/webclient-vue/src/composables/useAsyncRequest.js b/webclient-vue/src/composables/useAsyncRequest.js new file mode 100644 index 0000000..68193c3 --- /dev/null +++ b/webclient-vue/src/composables/useAsyncRequest.js @@ -0,0 +1,68 @@ +import { ref } from "vue"; + +/** + * Composable for managing async request lifecycle: + * isLoading, error, and AbortController cancellation. + * + * @param {Object} options + * @param {boolean} options.ignoreTimeout – when true (default), timeout errors + * are NOT written to `error` because they usually come from request + * cancellation on route leave / modal close. + */ +export function useAsyncRequest(options = {}) { + const abortController = ref(null); + const isLoading = ref(false); + const error = ref(null); + + async function execute(apiCall) { + abortController.value?.abort(); + const controller = new AbortController(); + abortController.value = controller; + + isLoading.value = true; + error.value = null; + + try { + const result = await apiCall(controller.signal); + abortController.value = null; + isLoading.value = false; + + if (!result.ok) { + if (options.ignoreTimeout !== false && result.error?.type === "timeout") { + return result; + } + error.value = result.error; + return result; + } + + return result; + } catch (err) { + abortController.value = null; + isLoading.value = false; + error.value = { + type: "network_error", + message: err?.message || "Network error", + }; + return { ok: false, error: error.value }; + } + } + + function abort() { + abortController.value?.abort(); + abortController.value = null; + } + + function clear() { + error.value = null; + abort(); + } + + return { + abortController, + isLoading, + error, + execute, + abort, + clear, + }; +} diff --git a/webclient-vue/src/stores/areas.js b/webclient-vue/src/stores/areas.js index ced4ba3..0c7106a 100644 --- a/webclient-vue/src/stores/areas.js +++ b/webclient-vue/src/stores/areas.js @@ -1,5 +1,7 @@ +import { ref, computed } from "vue"; import { defineStore } from "pinia"; import { areasApi } from "../api/modules/areas"; +import { useAsyncRequest } from "../composables/useAsyncRequest"; function buildAreaTree(areas) { const map = {}; @@ -28,178 +30,193 @@ return roots; } -export const useAreasStore = defineStore("areas", { - state: () => ({ - areas: [], - isLoading: false, - error: null, - _listAbortController: null, - currentArea: null, - currentAreaDevices: [], - currentAreaScripts: [], - isLoadingAreaDetail: false, - errorAreaDetail: null, - _areaDetailAbortController: null, - }), +export const useAreasStore = defineStore("areas", () => { + const areas = ref([]); + const currentArea = ref(null); + const currentAreaDevices = ref([]); + const currentAreaScripts = ref([]); - getters: { - areasById(state) { - return Object.fromEntries(state.areas.map((area) => [String(area.id), area])); - }, + const listRequest = useAsyncRequest(); + const detailRequest = useAsyncRequest(); - areaTree(state) { - return buildAreaTree(state.areas); - }, - }, + const areasById = computed(() => + Object.fromEntries(areas.value.map((area) => [String(area.id), area])) + ); - actions: { - async loadAreas() { - this._listAbortController?.abort(); - const controller = new AbortController(); - this._listAbortController = controller; + const areaTree = computed(() => buildAreaTree(areas.value)); - this.isLoading = true; - this.error = null; - - const result = await areasApi.list({ signal: controller.signal }); - this._listAbortController = null; - this.isLoading = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.error = result.error; - return result; + async function loadAreas() { + return listRequest.execute(async (signal) => { + const result = await areasApi.list({ signal }); + if (result.ok) { + areas.value = result.data?.data?.areas || []; } - - this.areas = result.data?.data?.areas || []; return result; - }, + }); + } - async loadAreaDetail(areaId) { - this._areaDetailAbortController?.abort(); - const controller = new AbortController(); - this._areaDetailAbortController = controller; + async function loadAreaDetail(areaId) { + detailRequest.abort(); + const controller = new AbortController(); + detailRequest.abortController.value = controller; - this.isLoadingAreaDetail = true; - this.errorAreaDetail = null; - this.currentArea = this.areasById[String(areaId)] || null; - this.currentAreaDevices = []; - this.currentAreaScripts = []; + detailRequest.isLoading.value = true; + detailRequest.error.value = null; + currentArea.value = areasById.value[String(areaId)] || null; + currentAreaDevices.value = []; + currentAreaScripts.value = []; + try { const [devicesResult, scriptsResult] = await Promise.all([ areasApi.devices(areaId, { signal: controller.signal }), areasApi.scripts(areaId, { signal: controller.signal }), ]); - this._areaDetailAbortController = null; - this.isLoadingAreaDetail = false; + detailRequest.abortController.value = null; + detailRequest.isLoading.value = false; if (!devicesResult.ok) { if (devicesResult.error?.type !== "timeout") { - this.errorAreaDetail = devicesResult.error; + detailRequest.error.value = devicesResult.error; } - return { ok: false, error: this.errorAreaDetail }; + return { ok: false, error: detailRequest.error.value }; } if (!scriptsResult.ok) { if (scriptsResult.error?.type !== "timeout") { - this.errorAreaDetail = scriptsResult.error; + detailRequest.error.value = scriptsResult.error; } - return { ok: false, error: this.errorAreaDetail }; + return { ok: false, error: detailRequest.error.value }; } - this.currentAreaDevices = devicesResult.data?.data?.devices || []; - this.currentAreaScripts = scriptsResult.data?.data?.scripts || []; + currentAreaDevices.value = devicesResult.data?.data?.devices || []; + currentAreaScripts.value = scriptsResult.data?.data?.scripts || []; return { ok: true }; - }, + } catch (err) { + detailRequest.abortController.value = null; + detailRequest.isLoading.value = false; + detailRequest.error.value = { + type: "network_error", + message: err?.message || "Network error", + }; + return { ok: false, error: detailRequest.error.value }; + } + } - clearAreaDetail() { - this.currentArea = null; - this.currentAreaDevices = []; - this.currentAreaScripts = []; - this.errorAreaDetail = null; - this._areaDetailAbortController?.abort(); - this._areaDetailAbortController = null; - }, + function clearAreaDetail() { + currentArea.value = null; + currentAreaDevices.value = []; + currentAreaScripts.value = []; + detailRequest.clear(); + } - async loadAreaDevices(areaId) { - const result = await areasApi.devices(areaId); - if (result.ok) { - this.currentAreaDevices = result.data?.data?.devices || []; + async function loadAreaDevices(areaId) { + const result = await areasApi.devices(areaId); + if (result.ok) { + currentAreaDevices.value = result.data?.data?.devices || []; + } + return result; + } + + async function loadAreaScripts(areaId) { + const result = await areasApi.scripts(areaId); + if (result.ok) { + currentAreaScripts.value = result.data?.data?.scripts || []; + } + return result; + } + + async function createArea(payload) { + const result = await areasApi.newArea(payload); + + if (result.ok) { + const newArea = result.data?.data?.area; + if (newArea) { + areas.value.push(newArea); } - return result; - }, + } - async loadAreaScripts(areaId) { - const result = await areasApi.scripts(areaId); - if (result.ok) { - this.currentAreaScripts = result.data?.data?.scripts || []; + return result; + } + + async function renameArea(areaId, displayName) { + const result = await areasApi.updateDisplayName({ + area_id: areaId, + display_name: displayName, + }); + + if (result.ok) { + const idx = areas.value.findIndex((a) => a.id === areaId); + if (idx !== -1) { + areas.value[idx] = { ...areas.value[idx], display_name: displayName }; } - return result; - }, + } - async createArea(payload) { - const result = await areasApi.newArea(payload); + return result; + } - if (result.ok) { - const newArea = result.data?.data?.area; - if (newArea) { - this.areas.push(newArea); - } + async function removeArea(areaId) { + const result = await areasApi.remove(areaId); + + if (result.ok) { + areas.value = areas.value.filter((a) => a.id !== areaId); + } + + return result; + } + + async function assignToArea(areaId, parentAreaId) { + const result = await areasApi.placeInArea({ + target_id: areaId, + place_in_area_id: parentAreaId, + }); + + if (result.ok) { + const idx = areas.value.findIndex((a) => a.id === areaId); + if (idx !== -1) { + areas.value.splice(idx, 1, { + ...areas.value[idx], + parent_id: Number(parentAreaId), + }); } + } - return result; - }, + return result; + } - async renameArea(areaId, displayName) { - const result = await areasApi.updateDisplayName({ area_id: areaId, display_name: displayName }); + async function unassignArea(areaId) { + const result = await areasApi.unassign(areaId); - if (result.ok) { - const idx = this.areas.findIndex((a) => a.id === areaId); - if (idx !== -1) { - this.areas[idx] = { ...this.areas[idx], display_name: displayName }; - } + if (result.ok) { + const idx = areas.value.findIndex((a) => a.id === areaId); + if (idx !== -1) { + areas.value.splice(idx, 1, { ...areas.value[idx], parent_id: 0 }); } + } - return result; - }, + return result; + } - async removeArea(areaId) { - const result = await areasApi.remove(areaId); - - if (result.ok) { - this.areas = this.areas.filter((a) => a.id !== areaId); - } - - return result; - }, - - async assignToArea(areaId, parentAreaId) { - const result = await areasApi.placeInArea({ target_id: areaId, place_in_area_id: parentAreaId }); - - if (result.ok) { - const idx = this.areas.findIndex((a) => a.id === areaId); - if (idx !== -1) { - this.areas.splice(idx, 1, { ...this.areas[idx], parent_id: Number(parentAreaId) }); - } - } - - return result; - }, - - async unassignArea(areaId) { - const result = await areasApi.unassign(areaId); - - if (result.ok) { - const idx = this.areas.findIndex((a) => a.id === areaId); - if (idx !== -1) { - this.areas.splice(idx, 1, { ...this.areas[idx], parent_id: 0 }); - } - } - - return result; - }, - }, + return { + areas, + isLoading: listRequest.isLoading, + error: listRequest.error, + currentArea, + currentAreaDevices, + currentAreaScripts, + isLoadingAreaDetail: detailRequest.isLoading, + errorAreaDetail: detailRequest.error, + areasById, + areaTree, + loadAreas, + loadAreaDetail, + clearAreaDetail, + loadAreaDevices, + loadAreaScripts, + createArea, + renameArea, + removeArea, + assignToArea, + unassignArea, + }; }); diff --git a/webclient-vue/src/stores/devices.js b/webclient-vue/src/stores/devices.js index 68049b1..bc108a0 100644 --- a/webclient-vue/src/stores/devices.js +++ b/webclient-vue/src/stores/devices.js @@ -1,5 +1,7 @@ +import { ref, computed } from "vue"; import { defineStore } from "pinia"; import { devicesApi } from "../api/modules/devices"; +import { useAsyncRequest } from "../composables/useAsyncRequest"; const DEFAULT_STATE_CONCURRENCY = 4; @@ -34,7 +36,8 @@ function normalizeStatusError(device, result) { const raw = result.error?.raw || {}; - const connectionStatus = raw?.data?.connection_status || device?.connection_status || "unknown"; + const connectionStatus = + raw?.data?.connection_status || device?.connection_status || "unknown"; return makeDeviceStatePatch(device, { status: "error", @@ -60,234 +63,221 @@ 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, - rebootingIds: new Set(), - _listAbortController: null, - lastLoadedAt: null, - currentDevice: null, - currentDeviceStatus: null, - isLoadingDetail: false, - errorDetail: null, - _detailAbortController: null, - }), +export const useDevicesStore = defineStore("devices", () => { + const devices = ref([]); + const stateByDeviceId = ref({}); + const stateRunId = ref(0); + const rebootingIds = ref(new Set()); + const currentDevice = ref(null); + const currentDeviceStatus = ref(null); + const lastLoadedAt = ref(null); + const isLoadingStates = ref(false); + const stateError = ref(null); - getters: { - total(state) { - return state.devices.length; - }, - isRebooting: (state) => (id) => state.rebootingIds.has(id), - }, + const listRequest = useAsyncRequest(); + const detailRequest = useAsyncRequest(); - actions: { - async loadDevices() { - this._listAbortController?.abort(); - const controller = new AbortController(); - this._listAbortController = controller; + const total = computed(() => devices.value.length); + const isRebooting = computed(() => (id) => rebootingIds.value.has(String(id))); - this.isLoading = true; - this.error = null; - - const result = await devicesApi.list({ signal: controller.signal }); - this._listAbortController = null; - this.isLoading = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.error = result.error; - return result; + async function loadDevices() { + return listRequest.execute(async (signal) => { + const result = await devicesApi.list({ signal }); + if (result.ok) { + devices.value = result.data?.data?.devices || []; + lastLoadedAt.value = new Date().toISOString(); } - - this.devices = result.data?.data?.devices || []; - this.lastLoadedAt = new Date().toISOString(); return result; - }, + }); + } - setDeviceState(device, patch) { - const deviceId = getDeviceId(device); + function setDeviceState(device, patch) { + const deviceId = getDeviceId(device); + if (!deviceId) { + return; + } + stateByDeviceId.value = { + ...stateByDeviceId.value, + [deviceId]: makeDeviceStatePatch(device, patch), + }; + } - if (!deviceId) { - return; - } + async function loadDeviceStates(options = {}) { + const runId = stateRunId.value + 1; + stateRunId.value = runId; + isLoadingStates.value = true; + stateError.value = null; + stateByDeviceId.value = {}; - this.stateByDeviceId = { - ...this.stateByDeviceId, - [deviceId]: makeDeviceStatePatch(device, patch), - }; - }, + const targets = []; - 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), - }; + for (const device of devices.value) { + if (device.connection_status === "lost") { + setDeviceState(device, { + status: "skipped", + message: "Connection lost", + connectionStatus: "lost", }); - } 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; - } + } else { + setDeviceState(device, { + status: "loading", + message: "Loading", + }); + targets.push(device); } - }, + } - async rebootDevice(id) { - const deviceId = String(id); - this.rebootingIds.add(deviceId); + try { + await runLimited(targets, options.concurrency || DEFAULT_STATE_CONCURRENCY, async (device) => { + const result = await devicesApi.status(device.id); - const result = await devicesApi.reboot(id); - - this.rebootingIds.delete(deviceId); - return result; - }, - - async loadDeviceDetail(id) { - this._detailAbortController?.abort(); - const controller = new AbortController(); - this._detailAbortController = controller; - - this.isLoadingDetail = true; - this.errorDetail = null; - this.currentDevice = null; - this.currentDeviceStatus = null; - - const result = await devicesApi.detail(id); - this._detailAbortController = null; - this.isLoadingDetail = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; + if (stateRunId.value !== runId) { + return; } - this.errorDetail = result.error; - return result; - } - this.currentDevice = result.data?.data?.device || null; - return result; - }, - - async loadDeviceStatus(id) { - const result = await devicesApi.status(id); - - if (!result.ok) { - this.currentDeviceStatus = { - ok: false, - error: result.error, - channels: [], + stateByDeviceId.value = { + ...stateByDeviceId.value, + [getDeviceId(device)]: result.ok + ? normalizeStatusSuccess(device, result) + : normalizeStatusError(device, result), }; - return result; + }); + } catch (error) { + if (stateRunId.value === runId) { + stateError.value = { + type: "state_loader_error", + message: error?.message || "Device states loader failed", + }; } + } finally { + if (stateRunId.value === runId) { + isLoadingStates.value = false; + } + } + } - const payload = result.data?.data?.device || {}; - const response = payload.device_response || {}; - this.currentDeviceStatus = { - ok: true, - channels: response.channels || [], - raw: response, + async function rebootDevice(id) { + const deviceId = String(id); + rebootingIds.value = new Set(rebootingIds.value).add(deviceId); + + const result = await devicesApi.reboot(id); + + const next = new Set(rebootingIds.value); + next.delete(deviceId); + rebootingIds.value = next; + return result; + } + + async function loadDeviceDetail(id) { + return detailRequest.execute(async (signal) => { + const result = await devicesApi.detail(id, { signal }); + if (result.ok) { + currentDevice.value = result.data?.data?.device || null; + } + return result; + }); + } + + async function loadDeviceStatus(id) { + const result = await devicesApi.status(id); + + if (!result.ok) { + currentDeviceStatus.value = { + ok: false, + error: result.error, + channels: [], }; return result; - }, + } - async updateDeviceName(id, name) { - const result = await devicesApi.updateName(id, name); - if (result.ok && this.currentDevice) { - this.currentDevice = { ...this.currentDevice, name }; - } - return result; - }, + const payload = result.data?.data?.device || {}; + const response = payload.device_response || {}; + currentDeviceStatus.value = { + ok: true, + channels: response.channels || [], + raw: response, + }; + return result; + } - async updateDeviceDescription(id, description) { - const result = await devicesApi.updateDescription(id, description); - if (result.ok && this.currentDevice) { - this.currentDevice = { ...this.currentDevice, description }; - } - return result; - }, + async function updateDeviceName(id, name) { + const result = await devicesApi.updateName(id, name); + if (result.ok && currentDevice.value) { + currentDevice.value = { ...currentDevice.value, name }; + } + return result; + } - async updateDeviceAlias(id, newAlias) { - const result = await devicesApi.updateAlias(id, newAlias); - if (result.ok && this.currentDevice) { - this.currentDevice = { ...this.currentDevice, alias: newAlias }; - } - return result; - }, + async function updateDeviceDescription(id, description) { + const result = await devicesApi.updateDescription(id, description); + if (result.ok && currentDevice.value) { + currentDevice.value = { ...currentDevice.value, description }; + } + return result; + } - async removeDevice(id) { - return devicesApi.remove(id); - }, + async function updateDeviceAlias(id, newAlias) { + const result = await devicesApi.updateAlias(id, newAlias); + if (result.ok && currentDevice.value) { + currentDevice.value = { ...currentDevice.value, alias: newAlias }; + } + return result; + } - async unassignDevice(id) { - const result = await devicesApi.unassign(id); - if (result.ok && this.currentDevice) { - this.currentDevice = { ...this.currentDevice, area_id: null }; - } - return result; - }, + async function removeDevice(id) { + return devicesApi.remove(id); + } - async assignToArea(id, areaId) { - const result = await devicesApi.placeInArea({ target_id: id, place_in_area_id: areaId }); - if (result.ok && this.currentDevice) { - this.currentDevice = { ...this.currentDevice, area_id: areaId }; - } - return result; - }, + async function unassignDevice(id) { + const result = await devicesApi.unassign(id); + if (result.ok && currentDevice.value) { + currentDevice.value = { ...currentDevice.value, area_id: null }; + } + return result; + } - clearDeviceDetail() { - this.currentDevice = null; - this.currentDeviceStatus = null; - this.errorDetail = null; - this._detailAbortController?.abort(); - this._detailAbortController = null; - }, - }, + async function assignToArea(id, areaId) { + const result = await devicesApi.placeInArea({ target_id: id, place_in_area_id: areaId }); + if (result.ok && currentDevice.value) { + currentDevice.value = { ...currentDevice.value, area_id: areaId }; + } + return result; + } + + function clearDeviceDetail() { + currentDevice.value = null; + currentDeviceStatus.value = null; + detailRequest.clear(); + } + + return { + devices, + isLoading: listRequest.isLoading, + error: listRequest.error, + isLoadingStates, + stateError, + stateByDeviceId, + stateRunId, + rebootingIds, + lastLoadedAt, + currentDevice, + currentDeviceStatus, + isLoadingDetail: detailRequest.isLoading, + errorDetail: detailRequest.error, + total, + isRebooting, + loadDevices, + setDeviceState, + loadDeviceStates, + rebootDevice, + loadDeviceDetail, + loadDeviceStatus, + updateDeviceName, + updateDeviceDescription, + updateDeviceAlias, + removeDevice, + unassignDevice, + assignToArea, + clearDeviceDetail, + }; }); diff --git a/webclient-vue/src/stores/scanning.js b/webclient-vue/src/stores/scanning.js index 0c18397..f8908a8 100644 --- a/webclient-vue/src/stores/scanning.js +++ b/webclient-vue/src/stores/scanning.js @@ -1,58 +1,48 @@ +import { ref, computed } from "vue"; import { defineStore } from "pinia"; import { devicesApi } from "../api/modules/devices"; +import { useAsyncRequest } from "../composables/useAsyncRequest"; -export const useScanningStore = defineStore("scanning", { - state: () => ({ - mode: "setup", - devices: [], - isLoading: false, - error: null, - _scanAbortController: null, - }), +export const useScanningStore = defineStore("scanning", () => { + const mode = ref("setup"); + const devices = ref([]); - getters: { - total(state) { - return state.devices.length; - }, - }, + const scanRequest = useAsyncRequest(); - actions: { - async scan() { - this._scanAbortController?.abort(); - const controller = new AbortController(); - this._scanAbortController = controller; + const total = computed(() => devices.value.length); - this.isLoading = true; - this.error = null; - + async function scan() { + return scanRequest.execute(async (signal) => { const result = - this.mode === "setup" - ? await devicesApi.scanningSetup({ signal: controller.signal }) - : await devicesApi.scanningAll({ signal: controller.signal }); + mode.value === "setup" + ? await devicesApi.scanningSetup({ signal }) + : await devicesApi.scanningAll({ signal }); - this._scanAbortController = null; - this.isLoading = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.error = result.error; - return result; + if (result.ok) { + devices.value = result.data?.data?.devices || []; } - - this.devices = result.data?.data?.devices || []; return result; - }, + }); + } - setMode(mode) { - this.mode = mode; - this.devices = []; - this.error = null; - }, + function setMode(newMode) { + mode.value = newMode; + devices.value = []; + scanRequest.clear(); + } - async setupDevice(payload) { - return devicesApi.setupNewDevice(payload); - }, - }, + async function setupDevice(payload) { + return devicesApi.setupNewDevice(payload); + } + + return { + mode, + devices, + isLoading: scanRequest.isLoading, + error: scanRequest.error, + total, + scan, + setMode, + setupDevice, + }; }); diff --git a/webclient-vue/src/stores/scripts.js b/webclient-vue/src/stores/scripts.js index 3fe60fa..2e3f3de 100644 --- a/webclient-vue/src/stores/scripts.js +++ b/webclient-vue/src/stores/scripts.js @@ -1,234 +1,203 @@ +import { ref, computed } from "vue"; import { defineStore } from "pinia"; import { scriptsApi } from "../api/modules/scripts"; +import { useAsyncRequest } from "../composables/useAsyncRequest"; -export const useScriptsStore = defineStore("scripts", { - state: () => ({ - actions: [], - regular: [], - scopes: [], - isLoadingActions: false, - isLoadingRegular: false, - isLoadingScopes: false, - errorActions: null, - errorRegular: null, - errorScopes: null, - runningAliases: new Set(), - lastRunResult: null, - _actionsAbortController: null, - _regularAbortController: null, - _scopesAbortController: null, - currentScopeCode: "", - isLoadingScopeCode: false, - errorScopeCode: null, - _scopeCodeAbortController: null, - }), +export const useScriptsStore = defineStore("scripts", () => { + const actions = ref([]); + const regular = ref([]); + const scopes = ref([]); + const runningAliases = ref(new Set()); + const lastRunResult = ref(null); + const currentScopeCode = ref(""); - getters: { - totalActions: (state) => state.actions.length, - totalRegular: (state) => state.regular.length, - totalScopes: (state) => state.scopes.length, - isRunning: (state) => (alias) => state.runningAliases.has(alias), - actionByAlias: (state) => (alias) => state.actions.find((s) => s.alias === alias) || null, - regularByAlias: (state) => (alias) => state.regular.find((s) => s.alias === alias) || null, - scopeByName: (state) => (name) => state.scopes.find((s) => s.name === name) || null, - actionsByScope: (state) => (scopeName) => state.actions.filter((s) => s.scope === scopeName), - regularByScope: (state) => (scopeName) => state.regular.filter((s) => s.scope === scopeName), - }, + const actionsRequest = useAsyncRequest(); + const regularRequest = useAsyncRequest(); + const scopesRequest = useAsyncRequest(); + const scopeCodeRequest = useAsyncRequest(); - actions: { - async loadActions() { - this._actionsAbortController?.abort(); - const controller = new AbortController(); - this._actionsAbortController = controller; + const totalActions = computed(() => actions.value.length); + const totalRegular = computed(() => regular.value.length); + const totalScopes = computed(() => scopes.value.length); + const isRunning = computed(() => (alias) => runningAliases.value.has(alias)); + const actionByAlias = computed(() => (alias) => actions.value.find((s) => s.alias === alias) || null); + const regularByAlias = computed(() => (alias) => regular.value.find((s) => s.alias === alias) || null); + const scopeByName = computed(() => (name) => scopes.value.find((s) => s.name === name) || null); + const actionsByScope = computed(() => (scopeName) => actions.value.filter((s) => s.scope === scopeName)); + const regularByScope = computed(() => (scopeName) => regular.value.filter((s) => s.scope === scopeName)); - this.isLoadingActions = true; - this.errorActions = null; - - const result = await scriptsApi.actionsList({ signal: controller.signal }); - this._actionsAbortController = null; - this.isLoadingActions = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.errorActions = result.error; - return result; - } - - this.actions = result.data?.data?.scripts || []; - return result; - }, - - async loadRegular() { - this._regularAbortController?.abort(); - const controller = new AbortController(); - this._regularAbortController = controller; - - this.isLoadingRegular = true; - this.errorRegular = null; - - const result = await scriptsApi.regularList({ signal: controller.signal }); - this._regularAbortController = null; - this.isLoadingRegular = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.errorRegular = result.error; - return result; - } - - this.regular = result.data?.data?.scripts || []; - return result; - }, - - async loadScopes() { - this._scopesAbortController?.abort(); - const controller = new AbortController(); - this._scopesAbortController = controller; - - this.isLoadingScopes = true; - this.errorScopes = null; - - const result = await scriptsApi.scopesList({ signal: controller.signal }); - this._scopesAbortController = null; - this.isLoadingScopes = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.errorScopes = result.error; - return result; - } - - this.scopes = result.data?.data?.scopes || []; - return result; - }, - - async runScript(alias, params = {}) { - this.runningAliases.add(alias); - this.lastRunResult = null; - - const result = await scriptsApi.runAction(alias, params); - - this.runningAliases.delete(alias); - - if (!result.ok) { - this.lastRunResult = { alias, ok: false, error: result.error }; - return result; - } - - this.lastRunResult = { - alias, - ok: true, - data: result.data?.data?.return?.result, - execTime: result.data?.data?.return?.exec_time, - }; - return result; - }, - - async setActionState(alias, enabled) { - const result = await scriptsApi.setActionState(alias, enabled); - + async function loadActions() { + return actionsRequest.execute(async (signal) => { + const result = await scriptsApi.actionsList({ signal }); if (result.ok) { - const idx = this.actions.findIndex((s) => s.alias === alias); - if (idx !== -1) { - this.actions[idx] = { ...this.actions[idx], state: enabled ? "enabled" : "disabled" }; - } + actions.value = result.data?.data?.scripts || []; } - return result; - }, + }); + } - async setRegularState(alias, enabled) { - const result = await scriptsApi.setRegularState(alias, enabled); - + async function loadRegular() { + return regularRequest.execute(async (signal) => { + const result = await scriptsApi.regularList({ signal }); if (result.ok) { - const idx = this.regular.findIndex((s) => s.alias === alias); - if (idx !== -1) { - this.regular[idx] = { ...this.regular[idx], state: enabled ? "enabled" : "disabled" }; - } + regular.value = result.data?.data?.scripts || []; } - return result; - }, + }); + } - async setScopeState(name, enabled) { - const result = await scriptsApi.setScopeState(name, enabled); - + async function loadScopes() { + return scopesRequest.execute(async (signal) => { + const result = await scriptsApi.scopesList({ signal }); if (result.ok) { - const idx = this.scopes.findIndex((s) => s.name === name); - if (idx !== -1) { - this.scopes[idx] = { ...this.scopes[idx], state: enabled ? "enabled" : "disabled" }; - } + scopes.value = result.data?.data?.scopes || []; } - return result; - }, + }); + } - async assignToArea(scriptId, areaId) { - const result = await scriptsApi.placeInArea({ target_id: scriptId, place_in_area_id: areaId }); + async function runScript(alias, params = {}) { + runningAliases.value = new Set(runningAliases.value).add(alias); + lastRunResult.value = null; + + const result = await scriptsApi.runAction(alias, params); + + const next = new Set(runningAliases.value); + next.delete(alias); + runningAliases.value = next; + + if (!result.ok) { + lastRunResult.value = { alias, ok: false, error: result.error }; + return result; + } + + lastRunResult.value = { + alias, + ok: true, + data: result.data?.data?.return?.result, + execTime: result.data?.data?.return?.exec_time, + }; + return result; + } + + async function setActionState(alias, enabled) { + const result = await scriptsApi.setActionState(alias, enabled); + + if (result.ok) { + const idx = actions.value.findIndex((s) => s.alias === alias); + if (idx !== -1) { + actions.value[idx] = { ...actions.value[idx], state: enabled ? "enabled" : "disabled" }; + } + } + + return result; + } + + async function setRegularState(alias, enabled) { + const result = await scriptsApi.setRegularState(alias, enabled); + + if (result.ok) { + const idx = regular.value.findIndex((s) => s.alias === alias); + if (idx !== -1) { + regular.value[idx] = { ...regular.value[idx], state: enabled ? "enabled" : "disabled" }; + } + } + + return result; + } + + async function setScopeState(name, enabled) { + const result = await scriptsApi.setScopeState(name, enabled); + + if (result.ok) { + const idx = scopes.value.findIndex((s) => s.name === name); + if (idx !== -1) { + scopes.value[idx] = { ...scopes.value[idx], state: enabled ? "enabled" : "disabled" }; + } + } + + return result; + } + + async function assignToArea(scriptId, areaId) { + const result = await scriptsApi.placeInArea({ target_id: scriptId, place_in_area_id: areaId }); + if (result.ok) { + const actionIdx = actions.value.findIndex((s) => s.id === scriptId); + if (actionIdx !== -1) { + actions.value.splice(actionIdx, 1, { ...actions.value[actionIdx], area_id: areaId }); + } + const regularIdx = regular.value.findIndex((s) => s.id === scriptId); + if (regularIdx !== -1) { + regular.value.splice(regularIdx, 1, { ...regular.value[regularIdx], area_id: areaId }); + } + } + return result; + } + + async function unassignFromArea(scriptId) { + const result = await scriptsApi.unassign(scriptId); + if (result.ok) { + const actionIdx = actions.value.findIndex((s) => s.id === scriptId); + if (actionIdx !== -1) { + actions.value.splice(actionIdx, 1, { ...actions.value[actionIdx], area_id: null }); + } + const regularIdx = regular.value.findIndex((s) => s.id === scriptId); + if (regularIdx !== -1) { + regular.value.splice(regularIdx, 1, { ...regular.value[regularIdx], area_id: null }); + } + } + return result; + } + + async function loadScopeCode(name) { + return scopeCodeRequest.execute(async (signal) => { + const result = await scriptsApi.scopeCode(name, { signal }); if (result.ok) { - const actionIdx = this.actions.findIndex((s) => s.id === scriptId); - if (actionIdx !== -1) { - this.actions.splice(actionIdx, 1, { ...this.actions[actionIdx], area_id: areaId }); - } - const regularIdx = this.regular.findIndex((s) => s.id === scriptId); - if (regularIdx !== -1) { - this.regular.splice(regularIdx, 1, { ...this.regular[regularIdx], area_id: areaId }); - } + currentScopeCode.value = typeof result.data === "string" ? result.data : ""; } return result; - }, + }); + } - async unassignFromArea(scriptId) { - const result = await scriptsApi.unassign(scriptId); - if (result.ok) { - const actionIdx = this.actions.findIndex((s) => s.id === scriptId); - if (actionIdx !== -1) { - this.actions.splice(actionIdx, 1, { ...this.actions[actionIdx], area_id: null }); - } - const regularIdx = this.regular.findIndex((s) => s.id === scriptId); - if (regularIdx !== -1) { - this.regular.splice(regularIdx, 1, { ...this.regular[regularIdx], area_id: null }); - } - } - return result; - }, + function clearScopeCode() { + currentScopeCode.value = ""; + scopeCodeRequest.clear(); + } - async loadScopeCode(name) { - this._scopeCodeAbortController?.abort(); - const controller = new AbortController(); - this._scopeCodeAbortController = controller; - - this.isLoadingScopeCode = true; - this.errorScopeCode = null; - this.currentScopeCode = ""; - - const result = await scriptsApi.scopeCode(name); - this._scopeCodeAbortController = null; - this.isLoadingScopeCode = false; - - if (!result.ok) { - if (result.error?.type === "timeout") { - return result; - } - this.errorScopeCode = result.error; - return result; - } - - this.currentScopeCode = typeof result.data === "string" ? result.data : ""; - return result; - }, - - clearScopeCode() { - this.currentScopeCode = ""; - this.errorScopeCode = null; - this._scopeCodeAbortController?.abort(); - this._scopeCodeAbortController = null; - }, - }, + return { + actions, + regular, + scopes, + isLoadingActions: actionsRequest.isLoading, + isLoadingRegular: regularRequest.isLoading, + isLoadingScopes: scopesRequest.isLoading, + errorActions: actionsRequest.error, + errorRegular: regularRequest.error, + errorScopes: scopesRequest.error, + runningAliases, + lastRunResult, + currentScopeCode, + isLoadingScopeCode: scopeCodeRequest.isLoading, + errorScopeCode: scopeCodeRequest.error, + totalActions, + totalRegular, + totalScopes, + isRunning, + actionByAlias, + regularByAlias, + scopeByName, + actionsByScope, + regularByScope, + loadActions, + loadRegular, + loadScopes, + runScript, + setActionState, + setRegularState, + setScopeState, + assignToArea, + unassignFromArea, + loadScopeCode, + clearScopeCode, + }; });