-
-
- Setup
-
-
- All
-
-
- Scan
-
-
+
+ Scan
+
+
+ Mode:
+
+ Setup
+
+
+ All
+
+
+
{{ scanningStore.total }} found
- Mode: {{ scanningStore.mode }}
-
- {{ row.device_name }}
- {{ row.device_type }}
+
+
+
+
+ {{ row.device_name || 'Unknown' }}
+ {{ row.device_type || 'unknown' }} — {{ row.ip_address || '—' }}
+
+
- {{ row.status }}
+ {{ row.status || 'unknown' }}
+
+
+
+ {{ row.firmware_version || '—' }}
@@ -66,10 +80,12 @@
v-if="row.status === 'setup'"
variant="primary"
icon="ph-plus"
+ size="sm"
@click="openSetup(row)"
>
Add
+ —
@@ -102,12 +118,13 @@
diff --git a/webclient-vue/src/features/scripts/pages/ScriptsActionsPage.vue b/webclient-vue/src/features/scripts/pages/ScriptsActionsPage.vue
index 4c9dd0d..40e97af 100644
--- a/webclient-vue/src/features/scripts/pages/ScriptsActionsPage.vue
+++ b/webclient-vue/src/features/scripts/pages/ScriptsActionsPage.vue
@@ -34,7 +34,7 @@
{{ script.state }}
- {{ script.alias }}
+ {{ areaFor(script).display_name }}
{{ script.author }}
@@ -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 @@
+
+
+ Scope
+ {{ row.scope }}
+
+
+ —
+
+
+
+ {{ areaNameFor(row) }}
+ —
+
+
{{ row.state }}
@@ -65,9 +83,10 @@
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("