diff --git a/webclient-vue/package-lock.json b/webclient-vue/package-lock.json index 8001ff8..dd51383 100644 --- a/webclient-vue/package-lock.json +++ b/webclient-vue/package-lock.json @@ -11,6 +11,7 @@ "@phosphor-icons/web": "^2.1.2", "gnexus-ui-kit": "git+https://git.gnexus.space/root/gnexus-ui-kit.git", "pinia": "^2.3.1", + "prismjs": "^1.30.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -2823,6 +2824,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", diff --git a/webclient-vue/package.json b/webclient-vue/package.json index dd17b1f..21fd255 100644 --- a/webclient-vue/package.json +++ b/webclient-vue/package.json @@ -15,6 +15,7 @@ "@phosphor-icons/web": "^2.1.2", "gnexus-ui-kit": "git+https://git.gnexus.space/root/gnexus-ui-kit.git", "pinia": "^2.3.1", + "prismjs": "^1.30.0", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/webclient-vue/src/api/__tests__/modules.spec.js b/webclient-vue/src/api/__tests__/modules.spec.js index 15e60c6..53184ea 100644 --- a/webclient-vue/src/api/__tests__/modules.spec.js +++ b/webclient-vue/src/api/__tests__/modules.spec.js @@ -154,6 +154,6 @@ it("setScopeState encodes name", async () => { const { apiGet } = await import("../client.js"); await scriptsApi.setScopeState("MyScope", true); - expect(apiGet).toHaveBeenCalledWith("/api/v1/scripts/actions/scope/MyScope/enable"); + expect(apiGet).toHaveBeenCalledWith("/api/v1/scripts/scopes/name/MyScope/enable"); }); }); diff --git a/webclient-vue/src/api/modules/devices.js b/webclient-vue/src/api/modules/devices.js index 3f858a7..45a2dad 100644 --- a/webclient-vue/src/api/modules/devices.js +++ b/webclient-vue/src/api/modules/devices.js @@ -52,4 +52,32 @@ setupNewDevice(payload) { return apiPost("/api/v1/devices/setup/new-device", payload); }, + + detail(id) { + return apiGet(`/api/v1/devices/id/${safeId(id)}`); + }, + + updateName(id, name) { + return apiPost("/api/v1/devices/update-name", { device_id: id, name }); + }, + + updateDescription(id, description) { + return apiPost("/api/v1/devices/update-description", { device_id: id, description }); + }, + + updateAlias(id, newAlias) { + return apiPost("/api/v1/devices/update-alias", { device_id: id, new_alias: newAlias }); + }, + + remove(id) { + return apiGet(`/api/v1/devices/id/${safeId(id)}/remove`); + }, + + unassign(id) { + return apiGet(`/api/v1/devices/id/${safeId(id)}/unassign-from-area`); + }, + + placeInArea(payload) { + return apiPost("/api/v1/devices/place-in-area", payload); + }, }; diff --git a/webclient-vue/src/api/modules/scripts.js b/webclient-vue/src/api/modules/scripts.js index c18eabb..1434525 100644 --- a/webclient-vue/src/api/modules/scripts.js +++ b/webclient-vue/src/api/modules/scripts.js @@ -26,6 +26,18 @@ }, setScopeState(name, enabled) { - return apiGet(`/api/v1/scripts/actions/scope/${encodeURIComponent(name)}/${enabled ? "enable" : "disable"}`); + return apiGet(`/api/v1/scripts/scopes/name/${encodeURIComponent(name)}/${enabled ? "enable" : "disable"}`); + }, + + scopeCode(name) { + return apiGet(`/api/v1/scripts/scopes/name/${encodeURIComponent(name)}`); + }, + + placeInArea(payload) { + return apiPost("/api/v1/scripts/place-in-area", payload); + }, + + unassign(id) { + return apiGet(`/api/v1/scripts/id/${encodeURIComponent(String(id))}/unassign-from-area`); }, }; diff --git a/webclient-vue/src/components/area/AreaAssignSection.vue b/webclient-vue/src/components/area/AreaAssignSection.vue new file mode 100644 index 0000000..38fec6d --- /dev/null +++ b/webclient-vue/src/components/area/AreaAssignSection.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/webclient-vue/src/components/area/AreaBadgeLink.vue b/webclient-vue/src/components/area/AreaBadgeLink.vue new file mode 100644 index 0000000..03665b1 --- /dev/null +++ b/webclient-vue/src/components/area/AreaBadgeLink.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/webclient-vue/src/components/layout/AppShell.vue b/webclient-vue/src/components/layout/AppShell.vue index 1bf7f05..c8d4ac6 100644 --- a/webclient-vue/src/components/layout/AppShell.vue +++ b/webclient-vue/src/components/layout/AppShell.vue @@ -26,6 +26,7 @@ "areas-tree": "Areas", "area-detail": "Area", devices: "Devices", + "device-detail": "Device", "devices-scanning": "Scanning", "scripts-actions": "Actions", "scripts-regular": "Regular", diff --git a/webclient-vue/src/composables/useAreaAssign.js b/webclient-vue/src/composables/useAreaAssign.js new file mode 100644 index 0000000..532295f --- /dev/null +++ b/webclient-vue/src/composables/useAreaAssign.js @@ -0,0 +1,52 @@ +import { ref, computed } from "vue"; +import { useAreasStore } from "../stores/areas"; + +export function useAreaAssign() { + const areasStore = useAreasStore(); + + const areaOptions = computed(() => + areasStore.areas.map((a) => ({ + value: String(a.id), + label: `${a.display_name} (${a.type})`, + })) + ); + + const showAssignModal = ref(false); + const selectedAreaId = ref(""); + const assignLoading = ref(false); + const assignError = ref(""); + + function openAssign(currentAreaId) { + selectedAreaId.value = currentAreaId ? String(currentAreaId) : ""; + assignError.value = ""; + showAssignModal.value = true; + } + + async function submitAssign(itemId, assignFn) { + if (!itemId || !assignFn) { + return; + } + assignLoading.value = true; + assignError.value = ""; + + const result = await assignFn(itemId, selectedAreaId.value); + assignLoading.value = false; + + if (!result.ok) { + assignError.value = result.error?.message || "Failed to assign area"; + return; + } + + showAssignModal.value = false; + } + + return { + areaOptions, + showAssignModal, + selectedAreaId, + assignLoading, + assignError, + openAssign, + submitAssign, + }; +} diff --git a/webclient-vue/src/features/devices/pages/DeviceDetailPage.vue b/webclient-vue/src/features/devices/pages/DeviceDetailPage.vue new file mode 100644 index 0000000..133fc33 --- /dev/null +++ b/webclient-vue/src/features/devices/pages/DeviceDetailPage.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/webclient-vue/src/features/devices/pages/DevicesListPage.vue b/webclient-vue/src/features/devices/pages/DevicesListPage.vue index 864e9e3..50df986 100644 --- a/webclient-vue/src/features/devices/pages/DevicesListPage.vue +++ b/webclient-vue/src/features/devices/pages/DevicesListPage.vue @@ -43,34 +43,42 @@ - @@ -63,6 +63,7 @@ import { ref, onMounted, computed } from "vue"; import { useRouter } from "vue-router"; import { useScriptsStore } from "../../../stores/scripts"; +import { useAreasStore } from "../../../stores/areas"; import { GnPageHeader, GnBadge, @@ -76,8 +77,14 @@ const router = useRouter(); const scriptsStore = useScriptsStore(); +const areasStore = useAreasStore(); const resultAlert = ref(null); +function areaFor(script) { + if (!script.area_id) return null; + return areasStore.areas.find((a) => a.id === script.area_id) || null; +} + const resultAlertComputed = computed(() => { const r = scriptsStore.lastRunResult; if (!r) return null; @@ -109,6 +116,9 @@ onMounted(() => { scriptsStore.loadActions(); + if (areasStore.areas.length === 0) { + areasStore.loadAreas(); + } }); @@ -127,4 +137,5 @@ .result-alert { margin-top: 24px; } + diff --git a/webclient-vue/src/features/scripts/pages/ScriptsRegularPage.vue b/webclient-vue/src/features/scripts/pages/ScriptsRegularPage.vue index 476bd4e..fda4439 100644 --- a/webclient-vue/src/features/scripts/pages/ScriptsRegularPage.vue +++ b/webclient-vue/src/features/scripts/pages/ScriptsRegularPage.vue @@ -36,6 +36,24 @@ + + + + diff --git a/webclient-vue/src/router/routes.js b/webclient-vue/src/router/routes.js index 72279ac..41fe5fc 100644 --- a/webclient-vue/src/router/routes.js +++ b/webclient-vue/src/router/routes.js @@ -3,6 +3,7 @@ import AreaDetailPage from "../features/areas/pages/AreaDetailPage.vue"; import DevicesListPage from "../features/devices/pages/DevicesListPage.vue"; import DevicesScanningPage from "../features/devices/pages/DevicesScanningPage.vue"; +import DeviceDetailPage from "../features/devices/pages/DeviceDetailPage.vue"; import ScriptsActionsPage from "../features/scripts/pages/ScriptsActionsPage.vue"; import ScriptsRegularPage from "../features/scripts/pages/ScriptsRegularPage.vue"; import ScriptsScopesPage from "../features/scripts/pages/ScriptsScopesPage.vue"; @@ -39,6 +40,11 @@ component: DevicesScanningPage, }, { + path: "/devices/:id", + name: "device-detail", + component: DeviceDetailPage, + }, + { path: "/scripts/actions", name: "scripts-actions", component: ScriptsActionsPage, diff --git a/webclient-vue/src/stores/devices.js b/webclient-vue/src/stores/devices.js index 72dd72f..68049b1 100644 --- a/webclient-vue/src/stores/devices.js +++ b/webclient-vue/src/stores/devices.js @@ -72,6 +72,11 @@ rebootingIds: new Set(), _listAbortController: null, lastLoadedAt: null, + currentDevice: null, + currentDeviceStatus: null, + isLoadingDetail: false, + errorDetail: null, + _detailAbortController: null, }), getters: { @@ -184,5 +189,105 @@ 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; + } + 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: [], + }; + return result; + } + + const payload = result.data?.data?.device || {}; + const response = payload.device_response || {}; + this.currentDeviceStatus = { + ok: true, + channels: response.channels || [], + raw: response, + }; + 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; + }, + + async updateDeviceDescription(id, description) { + const result = await devicesApi.updateDescription(id, description); + if (result.ok && this.currentDevice) { + this.currentDevice = { ...this.currentDevice, description }; + } + 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 removeDevice(id) { + return devicesApi.remove(id); + }, + + async unassignDevice(id) { + const result = await devicesApi.unassign(id); + if (result.ok && this.currentDevice) { + this.currentDevice = { ...this.currentDevice, area_id: null }; + } + return result; + }, + + 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; + }, + + clearDeviceDetail() { + this.currentDevice = null; + this.currentDeviceStatus = null; + this.errorDetail = null; + this._detailAbortController?.abort(); + this._detailAbortController = null; + }, }, }); diff --git a/webclient-vue/src/stores/scripts.js b/webclient-vue/src/stores/scripts.js index ef4038f..3fe60fa 100644 --- a/webclient-vue/src/stores/scripts.js +++ b/webclient-vue/src/stores/scripts.js @@ -17,6 +17,10 @@ _actionsAbortController: null, _regularAbortController: null, _scopesAbortController: null, + currentScopeCode: "", + isLoadingScopeCode: false, + errorScopeCode: null, + _scopeCodeAbortController: null, }), getters: { @@ -164,5 +168,67 @@ return result; }, + + async assignToArea(scriptId, areaId) { + const result = await scriptsApi.placeInArea({ target_id: scriptId, place_in_area_id: areaId }); + 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 }); + } + } + 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; + }, + + 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; + }, }, }); diff --git a/webclient-vue/src/styles/main.css b/webclient-vue/src/styles/main.css index c5a7735..f304aca 100644 --- a/webclient-vue/src/styles/main.css +++ b/webclient-vue/src/styles/main.css @@ -1,4 +1,6 @@ @import "gnexus-ui-kit/dist/css/kit.css"; +@import "prismjs/themes/prism.css"; +@import "./prism-theme.css"; :root { color-scheme: dark; @@ -161,6 +163,12 @@ gap: 8px; } +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; +} + @media (max-width: 720px) { .area-tree-card { grid-template-columns: 1fr; diff --git a/webclient-vue/src/styles/prism-theme.css b/webclient-vue/src/styles/prism-theme.css new file mode 100644 index 0000000..4554bbb --- /dev/null +++ b/webclient-vue/src/styles/prism-theme.css @@ -0,0 +1,99 @@ +/* PrismJS custom theme — matches gnexus-ui-kit dark palette */ + +pre[class*="language-"], +code[class*="language-"] { + color: #c0caf5; + background: none; + font-family: "IBM Plex Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.6; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + tab-size: 2; + hyphens: none; +} + +pre[class*="language-"] { + padding: 14px; + margin: 0; + overflow: auto; + background: #11131a; + border: 1px solid rgba(192, 202, 245, 0.12); +} + +/* Tokens */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #565f89; +} + +.token.punctuation { + color: #7aa2f7; +} + +.token.namespace { + opacity: 0.7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #ff9e64; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #9ece6a; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #89ddff; + background: none; +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #bb9af7; +} + +.token.function, +.token.class-name { + color: #0db9d7; +} + +.token.regex, +.token.important, +.token.variable { + color: #e0af68; +} + +.token.important, +.token.bold { + font-weight: 700; +} + +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/webclient-vue/src/test/mocks/handlers.js b/webclient-vue/src/test/mocks/handlers.js index 5ec2e59..241ec8f 100644 --- a/webclient-vue/src/test/mocks/handlers.js +++ b/webclient-vue/src/test/mocks/handlers.js @@ -37,25 +37,106 @@ }); } + if (path === "/api/v1/devices/scanning/all") { + return HttpResponse.json({ + status: true, + data: { + devices: [ + { + device_name: "Kitchen Relay", + device_type: "relay", + ip_address: "192.168.1.10", + mac_address: "A4:CF:12:9B:3F:00", + firmware_version: "1.2", + status: "normal", + }, + { + device_name: "New Device", + device_type: "relay", + ip_address: "192.168.1.50", + mac_address: "A4:CF:12:9B:3F:D2", + firmware_version: "1.0", + status: "setup", + }, + ], + }, + }); + } + if (path === "/api/v1/devices/list") { return HttpResponse.json({ status: true, data: { devices: [ - { id: 1, name: "Relay 1", alias: "relay_1", device_type: "relay", device_ip: "192.168.1.10", connection_status: "active" }, + { id: 1, name: "Relay 1", alias: "relay_1", device_type: "relay", device_ip: "192.168.1.10", connection_status: "active", area_id: 1 }, ], total: 1, }, }); } + if (path?.startsWith("/api/v1/devices/id/") && !path.includes("/status") && !path.endsWith("/remove") && !path.endsWith("/unassign-from-area") && !path.endsWith("/reboot")) { + return HttpResponse.json({ + status: true, + data: { + device: { + id: 1, + alias: "relay_1", + name: "Relay 1", + device_type: "relay", + device_ip: "192.168.1.10", + device_mac: "A4:CF:12:9B:3F:00", + device_hard_id: "abc123", + firmware_version: "1.2", + connection_status: "active", + status: "active", + description: "Test relay device", + last_contact: "2026-06-01 12:00:00", + create_at: "2026-01-01 00:00:00", + area_id: 1, + }, + }, + }); + } + + if (path?.startsWith("/api/v1/devices/id/") && path.endsWith("/status")) { + return HttpResponse.json({ + status: true, + data: { + device: { + id: 1, + alias: "relay_1", + device_response: { + status: "ok", + channels: [ + { channel: 0, state: "on", type: "relay" }, + { channel: 1, state: "off", type: "relay" }, + ], + }, + }, + }, + }); + } + + if (path?.startsWith("/api/v1/devices/id/") && path.endsWith("/reboot")) { + return HttpResponse.json({ status: true }); + } + + if (path?.startsWith("/api/v1/devices/id/") && path.endsWith("/remove")) { + return HttpResponse.json({ status: true }); + } + + if (path?.startsWith("/api/v1/devices/id/") && path.endsWith("/unassign-from-area")) { + return HttpResponse.json({ status: true }); + } + if (path === "/api/v1/scripts/actions/list") { return HttpResponse.json({ status: true, data: { scripts: [ - { alias: "kitchen_light", name: "Kitchen Light", icon: '', state: "enabled", author: "Test", scope: "KitchenScope" }, - { alias: "hall_light", name: "Hall Light", icon: '', state: "enabled", author: "Test", scope: "HallScope" }, + { id: 1, alias: "kitchen_light", name: "Kitchen Light", icon: '', state: "enabled", author: "Test", scope: "KitchenScope", area_id: 1 }, + { id: 2, alias: "hall_light", name: "Hall Light", icon: '', state: "enabled", author: "Test", scope: "HallScope" }, ], total: 2, }, @@ -67,7 +148,7 @@ status: true, data: { scripts: [ - { alias: "auto_off", name: "Auto Off", state: "enabled", filename: "auto_off.php", scope: "KitchenScope" }, + { id: 3, alias: "auto_off", name: "Auto Off", state: "enabled", filename: "auto_off.php", scope: "KitchenScope", area_id: 1 }, ], total: 1, }, @@ -95,6 +176,22 @@ return HttpResponse.json({ status: true }); } + if (path?.startsWith("/api/v1/scripts/scopes/name/") && (path.endsWith("/enable") || path.endsWith("/disable"))) { + return HttpResponse.json({ status: true }); + } + + if (path?.startsWith("/api/v1/scripts/scopes/name/")) { + return HttpResponse.text("