diff --git a/CLAUDE.md b/CLAUDE.md index d150201..7af1001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,16 +7,16 @@ A distributed smart home system with three layers: - **ESP8266/ESP32 firmware** (`devices/`) — IoT devices exposing a REST API - **PHP server** (`server/`) — central backend that manages devices, events, and automation scripts -- **Vue web client** (`webclient-vue/`) — Vue 3 + Pinia + Vite frontend using `gnexus-ui-kit` +- **Vue web client** (`webclient/`) — Vue 3 + Pinia + Vite frontend using `gnexus-ui-kit` - **Legacy web client** (`webclient_legacy/`) — old vanilla JS frontend (archived, no longer served) --- ## Build & Dev Commands -### Vue Web Client (webclient-vue/) +### Vue Web Client (webclient/) ```bash -cd webclient-vue +cd webclient npm install # install dependencies (Vue 3, Pinia, Vite, gnexus-ui-kit, Phosphor icons) npm run dev # starts Vite dev server with proxy to PHP backend npm run build # production build → dist/ @@ -71,7 +71,7 @@ ### Automation Scripts PHP classes in `server/ControlScripts/Scopes/`, extending `ControlScripts` base class. Each Scope class implements four methods: `register_sync_map`, `register_events_handlers`, `register_actions_scripts`, `register_regular_scripts`. All Scopes are auto-loaded at startup. See `docs/control-scripts-guide.md`. -### Vue Web Client Structure (webclient-vue/) +### Vue Web Client Structure (webclient/) - `src/app/main.js` — Vue app entry (createApp + Pinia + router) - `src/router/routes.js` — hash-router routes - `src/api/` — HTTP client: `client.js`, `http.js`, `mappers.js`, `modules/{areas,devices,scripts,scanning}.js` @@ -112,13 +112,13 @@ | `server/SHServ/Models/` | DB query layer | | `server/console.php` | CLI entry point for server-side scripts | | `webclient_legacy/src/js/sh/SmartHomeApi.js` | Legacy JS API client | -| `webclient-vue/src/api/client.js` | Vue API client wrapper | -| `webclient-vue/src/app/main.js` | Vue app entry point | +| `webclient/src/api/client.js` | Vue API client wrapper | +| `webclient/src/app/main.js` | Vue app entry point | | `devices/sh_core_esp8266/src/sh_core.h` | EEPROM layout and all device-side constants | | `docs/device-spec.md` | Device REST API contract (endpoints on the device itself) | | `docs/server-api.md` | **Server REST API** — full reference of all implemented endpoints | | `docs/architecture.md` | Full architecture: firmware contract, events routing, sync map, Fury framework | | `docs/firmware-dev-guide.md` | How to write firmware for a new device type | | `docs/control-scripts-guide.md` | How to write automation scripts (Scope classes) | -| `webclient-vue/docs/migration-plan.md` | Vue client migration plan (Phases 1–6) | -| `webclient-vue/docs/smoke-checklist.md` | UI smoke checklist for releases | +| `webclient/docs/migration-plan.md` | Vue client migration plan (Phases 1–6) | +| `webclient/docs/smoke-checklist.md` | UI smoke checklist for releases | diff --git a/webclient-vue/.env b/webclient-vue/.env deleted file mode 100644 index a469b08..0000000 --- a/webclient-vue/.env +++ /dev/null @@ -1,3 +0,0 @@ -VITE_API_BASE_URL= -VITE_API_PROXY_PATH=/proxy.php -VITE_API_TIMEOUT_MS=10000 diff --git a/webclient-vue/.env.example b/webclient-vue/.env.example deleted file mode 100644 index d51c7aa..0000000 --- a/webclient-vue/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -VITE_API_BASE_URL=http://smart-home-serv.local -VITE_API_PROXY_PATH=/proxy.php -VITE_API_TIMEOUT_MS=10000 diff --git a/webclient-vue/.gitignore b/webclient-vue/.gitignore deleted file mode 100644 index f4650bb..0000000 --- a/webclient-vue/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -.env.local -.env.*.local diff --git a/webclient-vue/coverage/api/client.js.html b/webclient-vue/coverage/api/client.js.html deleted file mode 100644 index ef59379..0000000 --- a/webclient-vue/coverage/api/client.js.html +++ /dev/null @@ -1,283 +0,0 @@ - - - - -
-- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 | - - -4x - - - - - - - -20x -20x - -18x -1x - - - - - - - - - -17x -1x - - - - - - - - - - -16x - - - - - -2x -2x - - - - - - - - - - - - - - - -10x - - - -5x - - | import { requestHttp } from "./http";
-
-function makeError(type, message, extra = {}) {
- return {
- type,
- message,
- ...extra,
- };
-}
-
-export async function apiRequest(method, path, body, options) {
- try {
- const { response, data, meta } = await requestHttp(method, path, body, options);
-
- if (!response.ok) {
- return {
- ok: false,
- error: makeError("http_error", `HTTP ${response.status}`, {
- statusCode: response.status,
- raw: data,
- }),
- meta,
- };
- }
-
- if (data && typeof data === "object" && (data.status === false || data.status === "error")) {
- return {
- ok: false,
- error: makeError("api_error", data.msg || data.message || "API error", {
- errorAlias: data.error_alias,
- failedFields: data.failed_fields || [],
- raw: data,
- }),
- meta,
- };
- }
-
- return {
- ok: true,
- data,
- meta,
- };
- } catch (error) {
- const isAbort = error?.name === "AbortError";
- return {
- ok: false,
- error: makeError(isAbort ? "timeout" : "network_error", error?.message || "Network error", {
- details: error,
- }),
- meta: {
- url: path,
- method,
- statusCode: 0,
- headers: null,
- },
- };
- }
-}
-
-export function apiGet(path, options) {
- return apiRequest("GET", path, null, options);
-}
-
-export function apiPost(path, body, options) {
- return apiRequest("POST", path, body, options);
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 | 4x - - -23x - -23x -23x - - - -23x - - -23x -23x - - - -23x -23x - -23x -22x - - -1x - - - -23x - -23x -1x - - -22x - - - -23x -23x -23x -23x -23x - -23x - - - - -23x - - - - - -23x -8x - - -23x -6x -6x - - -23x -23x -23x -23x - -23x -23x -23x - -1x - - - -23x - - - - - - - - - - -23x - - - | const DEFAULT_TIMEOUT_MS = Number(import.meta.env.VITE_API_TIMEOUT_MS || 10000); - -function buildQuery(params) { - const query = new URLSearchParams(); - - for (const [key, value] of Object.entries(params || {})) { - Iif (value === undefined || value === null) { - continue; - } - - query.append(key, String(value)); - } - - const serialized = query.toString(); - return serialized ? `?${serialized}` : ""; -} - -function joinUrl(baseUrl, path) { - const base = String(baseUrl || "").replace(/\/+$/, ""); - const nextPath = String(path || "").replace(/^\/+/, ""); - - if (!base) { - return `/${nextPath}`; - } - - return `${base}/${nextPath}`; -} - -function wrapProxyPath(path, query) { - const proxyPath = import.meta.env.VITE_API_PROXY_PATH || ""; - - if (!proxyPath) { - return `${path}${buildQuery(query)}`; - } - - return `${proxyPath}${buildQuery({ path, ...(query || {}) })}`; -} - -export async function requestHttp(method, path, body, options = {}) { - const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const baseUrl = import.meta.env.VITE_API_BASE_URL || ""; - const url = joinUrl(baseUrl, wrapProxyPath(path, options.query)); - - const headers = { - Accept: "application/json", - ...(options.headers || {}), - }; - - const init = { - method, - headers, - signal: controller.signal, - }; - - if (options.signal) { - options.signal.addEventListener("abort", () => controller.abort(), { once: true }); - } - - if (body !== undefined && body !== null) { - headers["Content-Type"] = "application/json"; - init.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, init); - const text = await response.text(); - let data = text; - - Eif (text) { - try { - data = JSON.parse(text); - } catch (_) { - data = text; - } - } - - return { - response, - data, - meta: { - url, - method, - statusCode: response.status, - headers: response.headers, - }, - }; - } finally { - clearTimeout(timeout); - } -} - |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| client.js | -
-
- |
- 100% | -12/12 | -83.33% | -15/18 | -100% | -4/4 | -100% | -12/12 | -
| http.js | -
-
- |
- 92.68% | -38/41 | -89.18% | -33/37 | -66.66% | -4/6 | -97.43% | -38/39 | -
| mappers.js | -
-
- |
- 100% | -5/5 | -100% | -4/4 | -100% | -1/1 | -100% | -5/5 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 | 3x - - - - - - - - - - - -5x - -5x -13x - - -5x - - | const deviceFieldMap = {
- device_name: "name",
- device_hard_id: "device_id",
- device_ip: "ip",
- device_type: "type",
- ip_address: "ip",
- mac_address: "mac",
- device_mac: "mac",
- core_version: "firmware_core_version",
-};
-
-export function unifyDeviceFields(device) {
- const normalized = {};
-
- for (const [field, value] of Object.entries(device || {})) {
- normalized[deviceFieldMap[field] || field] = value;
- }
-
- return normalized;
-}
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 | - -2x - -5x - - - -1x - - - -2x - - - -2x - - - -1x - - - -1x - - - -2x - - - -1x - - - -1x - - - -1x - - - | import { apiGet, apiPost } from "../client";
-
-export const areasApi = {
- list(options) {
- return apiGet("/api/v1/areas/list", options);
- },
-
- innerList(areaId, options) {
- return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/list`, options);
- },
-
- newArea(payload) {
- return apiPost("/api/v1/areas/new-area", payload);
- },
-
- remove(areaId) {
- return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/remove`);
- },
-
- devices(areaId) {
- return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/devices`);
- },
-
- scripts(areaId) {
- return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/scripts`);
- },
-
- updateDisplayName(payload) {
- return apiPost("/api/v1/areas/update-display-name", payload);
- },
-
- updateAlias(payload) {
- return apiPost("/api/v1/areas/update-alias", payload);
- },
-
- placeInArea(payload) {
- return apiPost("/api/v1/areas/place-in-area", payload);
- },
-
- unassign(areaId) {
- return apiGet(`/api/v1/areas/id/${encodeURIComponent(String(areaId))}/unassign-from-area`);
- },
-};
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 | - - - -2x - - - -1x - - - -1x - -1x - - - - - - - - - - - -2x - -1x - - - -1x - - - -1x - - - -1x - - - -3x - - - -1x - - - -2x - - - | import { apiGet, apiPost } from "../client";
-import { unifyDeviceFields } from "../mappers";
-
-function safeId(id) {
- return encodeURIComponent(String(id));
-}
-
-function mapDevicesResponse(result) {
- Iif (!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);
- },
-
- scanningSetup(options) {
- return apiGet("/api/v1/devices/scanning/setup", options);
- },
-
- scanningAll(options) {
- return apiGet("/api/v1/devices/scanning/all", options);
- },
-
- setupNewDevice(payload) {
- return apiPost("/api/v1/devices/setup/new-device", payload);
- },
-};
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| areas.js | -
-
- |
- 100% | -11/11 | -100% | -0/0 | -100% | -10/10 | -100% | -11/11 | -
| devices.js | -
-
- |
- 92.3% | -12/13 | -75% | -3/4 | -100% | -9/9 | -92.3% | -12/13 | -
| scripts.js | -
-
- |
- 100% | -7/7 | -80% | -4/5 | -100% | -6/6 | -100% | -7/7 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 | - -2x - -3x - - - -1x - - - -1x - - - -2x - - - -2x - - - -1x - - - | import { apiGet, apiPost } from "../client";
-
-export const scriptsApi = {
- actionsList(options) {
- return apiGet("/api/v1/scripts/actions/list", options);
- },
-
- regularList(options) {
- return apiGet("/api/v1/scripts/regular/list", options);
- },
-
- scopesList(options) {
- return apiGet("/api/v1/scripts/scopes/list", options);
- },
-
- runAction(alias, params = {}) {
- return apiPost("/api/v1/scripts/actions/run", { alias, params });
- },
-
- setRegularState(alias, enabled) {
- return apiGet(`/api/v1/scripts/regular/alias/${encodeURIComponent(alias)}/${enabled ? "enable" : "disable"}`);
- },
-
- setScopeState(name, enabled) {
- return apiGet(`/api/v1/scripts/actions/scope/${encodeURIComponent(name)}/${enabled ? "enable" : "disable"}`);
- },
-};
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 | -9x - - - - - - - - - - - - - - - - - | <template>
- <GnEmptyState :title="title" :text="message" icon="ph-package" />
-</template>
-
-<script setup>
-import { GnEmptyState } from "gnexus-ui-kit/vue";
-
-defineProps({
- title: {
- type: String,
- default: "Nothing here",
- },
- message: {
- type: String,
- default: "",
- },
-});
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 | -4x -4x - -2x - - - - - - - - - - - - - - - - - - - - - - | <template>
- <GnAlert variant="danger" role="alert">
- <strong>{{ title }}</strong>
- <p v-if="message">{{ message }}</p>
- <GnButton v-if="retry" variant="danger" @click="retry">Retry</GnButton>
- </GnAlert>
-</template>
-
-<script setup>
-import { GnAlert, GnButton } from "gnexus-ui-kit/vue";
-
-defineProps({
- title: {
- type: String,
- default: "Request failed",
- },
- message: {
- type: String,
- default: "",
- },
- retry: {
- type: Function,
- default: null,
- },
-});
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 | -8x - - - - - - - - - - - - - | <template>
- <GnLoader circle :label="text" />
-</template>
-
-<script setup>
-import { GnLoader } from "gnexus-ui-kit/vue";
-
-defineProps({
- text: {
- type: String,
- default: "Loading",
- },
-});
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| AppEmptyState.vue | -
-
- |
- 100% | -1/1 | -100% | -0/0 | -100% | -0/0 | -100% | -1/1 | -
| AppErrorState.vue | -
-
- |
- 100% | -3/3 | -100% | -4/4 | -100% | -2/2 | -100% | -3/3 | -
| AppLoadingState.vue | -
-
- |
- 100% | -1/1 | -100% | -0/0 | -100% | -0/0 | -100% | -1/1 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 | -3x - - - - - - - - -3x - - - - - - - -3x - - - - - - - - - - | <template>
- <GnNavigationShell
- brand="SHSERV WEB CLIENT"
- logo-src=""
- title="Navigation"
- subtitle="Smart Home"
- :items="navItems"
- active-match="prefix"
- >
- <template #content>
- <slot />
- </template>
- </GnNavigationShell>
-</template>
-
-<script setup>
-import { GnNavigationShell } from "gnexus-ui-kit/vue";
-
-const navItems = [
- { label: "Favorites", to: "/areas/favorites", icon: "ph-star" },
- { label: "Areas", to: "/areas/tree", icon: "ph-map-trifold" },
- { label: "Devices", to: "/devices", icon: "ph-cpu" },
- { label: "Scanning", to: "/devices/scanning", icon: "ph-magnifying-glass" },
- { label: "Actions", to: "/scripts/actions", icon: "ph-play" },
- { label: "Regular", to: "/scripts/regular", icon: "ph-clock" },
- { label: "Scopes", to: "/scripts/scopes", icon: "ph-brackets-curly" },
-];
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| AppShell.vue | -
-
- |
- 100% | -3/3 | -100% | -0/0 | -100% | -1/1 | -100% | -3/3 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 | -26x -2x - - - - - -1x - - - - -2x - - -23x - - - - -2x - - - -1x - - -18x - - - - -1x - - -18x - - - -1x - - -18x - - - -1x - -19x - - - - - -1x - - - - - - - - - - - -2x - - - - - - - - - - -19x - - - - - - - - - - -19x - -19x -19x -23x -24x -24x -19x - - | <template>
- <li v-if="!isCycle" class="area-tree-node" :class="{ 'is-open': isOpen, 'is-leaf': isLeaf }">
- <article class="area-tree-card">
- <button
- class="tree-toggle"
- type="button"
- :disabled="isLeaf"
- :aria-expanded="isOpen"
- @click="isOpen = !isOpen"
- >
- {{ isLeaf ? "·" : isOpen ? "−" : "+" }}
- </button>
-
- <div class="area-tree-info">
- <h2>{{ area.display_name }}</h2>
- <p>
- <GnBadge variant="secondary">{{ area.type }}</GnBadge>
- <code>{{ area.alias }}</code>
- </p>
- </div>
-
- <div class="area-tree-actions">
- <GnButton
- variant="secondary"
- icon="ph-pencil"
- @click="emit('rename', area)"
- >
- Rename
- </GnButton>
- <GnButton
- variant="secondary"
- icon="ph-arrow-up"
- :disabled="area.parent_id == null || area.parent_id === 0"
- @click="emit('unassign', area)"
- >
- Unassign
- </GnButton>
- <GnButton
- variant="danger"
- icon="ph-trash"
- @click="emit('remove', area)"
- >
- Remove
- </GnButton>
- <GnButton
- :variant="isFavorite ? 'warning' : 'secondary'"
- :icon="isFavorite ? 'ph-star' : 'ph-star'"
- @click="favoritesStore.toggle(area.id)"
- >
- {{ isFavorite ? "Unstar" : "Star" }}
- </GnButton>
- </div>
- </article>
-
- <ul v-if="area.children?.length && isOpen" class="area-tree-children">
- <AreaTreeNode
- v-for="child in area.children"
- :key="child.id"
- :area="child"
- :ancestors="nextAncestors"
- @rename="(a) => emit('rename', a)"
- @remove="(a) => emit('remove', a)"
- @unassign="(a) => emit('unassign', a)"
- />
- </ul>
- </li>
- <li v-else class="area-tree-node">
- <article class="area-tree-card area-tree-cycle">
- Cycle skipped for area ID {{ area.id }}
- </article>
- </li>
-</template>
-
-<script setup>
-import { computed, ref } from "vue";
-import { useFavoritesStore } from "../../../stores/favorites";
-import { GnBadge, GnButton } from "gnexus-ui-kit/vue";
-
-const props = defineProps({
- area: {
- type: Object,
- required: true,
- },
- ancestors: {
- type: Array,
- default: () => [],
- },
-});
-
-const emit = defineEmits(["rename", "remove", "unassign"]);
-
-const favoritesStore = useFavoritesStore();
-const isOpen = ref(false);
-const isLeaf = computed(() => !props.area.children?.length);
-const isFavorite = computed(() => favoritesStore.has(props.area.id));
-const isCycle = computed(() => props.ancestors.includes(String(props.area.id)));
-const nextAncestors = computed(() => [...props.ancestors, String(props.area.id)]);
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| AreaTreeNode.vue | -
-
- |
- 90.32% | -28/31 | -100% | -39/39 | -83.33% | -15/18 | -88.88% | -24/27 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 | -15x - - -4x - - - - - - - - - - - - - - - - - - - -14x - - - - - - - - - - -1x - - -1x - - -1x - - - - - - - - - - - - -1x - - - - - - - - - - - - - - - - - - - - - - - - - - - - -4x - -4x -4x -4x -4x - -4x -4x -4x -4x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -4x -4x - - - - - - - - - | <template> - <section class="page"> - <GnPageHeader title="Tree" kicker="Areas"> - <template #actions> - <GnButton variant="primary" icon="ph-plus" @click="openCreate">Create area</GnButton> - </template> - </GnPageHeader> - - <AppLoadingState v-if="areasStore.isLoading" text="Loading areas tree" /> - - <AppErrorState - v-else-if="areasStore.error" - title="Areas loading failed" - :message="areasStore.error.message" - :retry="areasStore.loadAreas" - /> - - <AppEmptyState - v-else-if="areasStore.areaTree.length === 0" - title="No areas" - message="No areas found. Create one to get started." - /> - - <ul v-else class="area-tree"> - <AreaTreeNode - v-for="area in areasStore.areaTree" - :key="area.id" - :area="area" - @rename="openRename" - @remove="remove" - @unassign="unassign" - /> - </ul> - - <GnModal :open="showCreateModal" title="Create area" @update:open="showCreateModal = $event"> - <div class="form-group"> - <GnInput v-model="createForm.type" label="Type" placeholder="room" /> - </div> - <div class="form-group"> - <GnInput v-model="createForm.alias" label="Alias" placeholder="kitchen" /> - </div> - <div class="form-group"> - <GnInput v-model="createForm.display_name" label="Display name" placeholder="Kitchen" /> - </div> - <div v-if="createError" class="form-group"> - <GnAlert variant="danger">{{ createError }}</GnAlert> - </div> - <template #footer> - <GnButton variant="secondary" @click="showCreateModal = false">Cancel</GnButton> - <GnButton variant="primary" icon="ph-plus" :loading="createLoading" @click="submitCreate">Create</GnButton> - </template> - </GnModal> - - <GnModal :open="showRenameModal" title="Rename area" @update:open="showRenameModal = $event"> - <div class="form-group"> - <GnInput v-model="renameForm.display_name" label="Display name" /> - </div> - <div v-if="renameError" class="form-group"> - <GnAlert variant="danger">{{ renameError }}</GnAlert> - </div> - <template #footer> - <GnButton variant="secondary" @click="showRenameModal = false">Cancel</GnButton> - <GnButton variant="primary" icon="ph-check" :loading="renameLoading" @click="submitRename">Rename</GnButton> - </template> - </GnModal> - </section> -</template> - -<script setup> -import { ref, reactive, onMounted } from "vue"; -import { useAreasStore } from "../../../stores/areas"; -import { - GnPageHeader, - GnButton, - GnModal, - GnInput, - GnAlert, -} from "gnexus-ui-kit/vue"; -import AreaTreeNode from "../components/AreaTreeNode.vue"; -import AppLoadingState from "../../../components/feedback/AppLoadingState.vue"; -import AppErrorState from "../../../components/feedback/AppErrorState.vue"; -import AppEmptyState from "../../../components/feedback/AppEmptyState.vue"; - -const areasStore = useAreasStore(); - -const showCreateModal = ref(false); -const createLoading = ref(false); -const createError = ref(""); -const createForm = reactive({ type: "", alias: "", display_name: "" }); - -const showRenameModal = ref(false); -const renameLoading = ref(false); -const renameError = ref(""); -const renameForm = reactive({ areaId: null, display_name: "" }); - -function openCreate() { - createForm.type = ""; - createForm.alias = ""; - createForm.display_name = ""; - createError.value = ""; - showCreateModal.value = true; -} - -async function submitCreate() { - createLoading.value = true; - createError.value = ""; - - const result = await areasStore.createArea({ ...createForm }); - createLoading.value = false; - - if (!result.ok) { - createError.value = result.error?.message || "Failed to create area"; - return; - } - - showCreateModal.value = false; -} - -function openRename(area) { - renameForm.areaId = area.id; - renameForm.display_name = area.display_name; - renameError.value = ""; - showRenameModal.value = true; -} - -async function submitRename() { - renameLoading.value = true; - renameError.value = ""; - - const result = await areasStore.renameArea(renameForm.areaId, renameForm.display_name); - renameLoading.value = false; - - if (!result.ok) { - renameError.value = result.error?.message || "Failed to rename area"; - return; - } - - showRenameModal.value = false; -} - -async function remove(area) { - if (!window.confirm(`Remove area "${area.display_name}"?`)) { - return; - } - await areasStore.removeArea(area.id); -} - -async function unassign(area) { - await areasStore.unassignArea(area.id); -} - -onMounted(() => { - areasStore.loadAreas(); -}); -</script> - -<style scoped> -.form-group { - margin-bottom: 16px; -} -</style> - |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| AreaTreePage.vue | -
-
- |
- 29.85% | -20/67 | -25% | -11/44 | -14.28% | -4/28 | -29.5% | -18/61 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 | -4x - - - - - - -4x - - - - - - -4x -4x -4x -1x - - -3x -1x - - -2x - - - | <template>
- <GnBadge :variant="variant">{{ label }}</GnBadge>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { GnBadge } from "gnexus-ui-kit/vue";
-
-const props = defineProps({
- status: {
- type: String,
- default: "unknown",
- },
-});
-
-const label = computed(() => props.status || "unknown");
-const variant = computed(() => {
- if (props.status === "active") {
- return "success";
- }
-
- if (props.status === "lost") {
- return "danger";
- }
-
- return "secondary";
-});
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 | -5x -4x - - - - - - -5x - - - - - - - - - -5x -5x -1x - - -4x -1x - - -3x -1x - - -2x - - -5x -4x - -1x - -1x - - - -2x - - - - | <template>
- <GnLoader v-if="state.status === 'loading'" circle :label="label" />
- <GnBadge v-else :variant="variant">{{ label }}</GnBadge>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { GnLoader, GnBadge } from "gnexus-ui-kit/vue";
-
-const props = defineProps({
- state: {
- type: Object,
- default: () => ({
- status: "idle",
- message: "Not loaded",
- }),
- },
-});
-
-const label = computed(() => {
- if (props.state.status === "ready") {
- return props.state.message || "ok";
- }
-
- if (props.state.status === "error") {
- return props.state.message || "Loading error";
- }
-
- if (props.state.status === "skipped") {
- return props.state.message || "Skipped";
- }
-
- return props.state.message || "Not loaded";
-});
-
-const variant = computed(() => {
- switch (props.state.status) {
- case "ready":
- return "success";
- case "error":
- return "danger";
- case "skipped":
- case "idle":
- default:
- return "secondary";
- }
-});
-</script>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| DeviceConnectionBadge.vue | -
-
- |
- 100% | -11/11 | -100% | -6/6 | -100% | -3/3 | -100% | -9/9 | -
| DeviceStateCell.vue | -
-
- |
- 100% | -16/16 | -85.71% | -18/21 | -100% | -3/3 | -100% | -16/16 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 -119 -120 -121 -122 -123 -124 -125 -126 -127 -128 -129 -130 -131 -132 -133 -134 -135 -136 -137 -138 -139 -140 -141 -142 -143 -144 -145 -146 -147 -148 -149 -150 -151 -152 -153 -154 -155 -156 -157 -158 -159 -160 -161 -162 -163 -164 -165 -166 -167 -168 -169 -170 -171 -172 -173 -174 -175 -176 -177 -178 -179 -180 -181 -182 -183 -184 -185 -186 -187 -188 -189 -190 -191 | -3x - - -1x - - - - - -3x - - - - - -3x - - - - - - -3x -3x - - - - - - - - - - - - - - - - - - - -1x -1x -1x - - - - - - - -1x - - - - -1x -1x - - -1x - - - - - - - -1x - - - - - - - - - -1x - - -1x - - -1x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -1x - -1x -1x -1x -1x - - - - - - -1x - - - - - - - - -1x -1x - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | <template> - <section class="page"> - <GnPageHeader title="Scanning" kicker="Devices"> - <template #actions> - <div class="devices-actions"> - <GnButton - :variant="scanningStore.mode === 'setup' ? 'primary' : 'secondary'" - @click="setMode('setup')" - > - Setup - </GnButton> - <GnButton - :variant="scanningStore.mode === 'all' ? 'primary' : 'secondary'" - @click="setMode('all')" - > - All - </GnButton> - <GnButton - :loading="scanningStore.isLoading" - icon="ph-magnifying-glass" - @click="scan" - > - Scan - </GnButton> - </div> - </template> - </GnPageHeader> - - <AppLoadingState v-if="scanningStore.isLoading" text="Scanning network" /> - - <AppErrorState - v-else-if="scanningStore.error" - title="Scan failed" - :message="scanningStore.error.message" - :retry="scan" - /> - - <AppEmptyState - v-else-if="scanningStore.devices.length === 0" - title="No devices found" - message="Choose scan mode and click Scan to discover devices." - /> - - <div v-else class="devices-panel"> - <div class="devices-summary"> - <GnBadge variant="primary">{{ scanningStore.total }} found</GnBadge> - <GnBadge variant="secondary">Mode: {{ scanningStore.mode }}</GnBadge> - </div> - - <GnTable - :columns="tableColumns" - :rows="tableRows" - caption="Discovered devices" - > - <template #cell-device_name="{ row }"> - <strong>{{ row.device_name }}</strong> - <small>{{ row.device_type }}</small> - </template> - - <template #cell-status="{ row }"> - <GnBadge :variant="row.status === 'setup' ? 'warning' : 'success'">{{ row.status }}</GnBadge> - </template> - - <template #cell-actions="{ row }"> - <GnButton - v-if="row.status === 'setup'" - variant="primary" - icon="ph-plus" - @click="openSetup(row)" - > - Add - </GnButton> - </template> - </GnTable> - </div> - - <GnModal - :open="showSetupModal" - title="Setup new device" - @update:open="showSetupModal = $event" - > - <div class="form-group"> - <GnInput v-model="setupForm.alias" label="Alias" placeholder="kitchen_relay" /> - </div> - <div class="form-group"> - <GnInput v-model="setupForm.name" label="Name" placeholder="Kitchen Relay" /> - </div> - <div class="form-group"> - <GnInput v-model="setupForm.description" label="Description" /> - </div> - <div v-if="setupError" class="form-group"> - <GnAlert variant="danger">{{ setupError }}</GnAlert> - </div> - <template #footer> - <GnButton variant="secondary" @click="showSetupModal = false">Cancel</GnButton> - <GnButton variant="primary" icon="ph-plus" :loading="setupLoading" @click="submitSetup"> - Add device - </GnButton> - </template> - </GnModal> - </section> -</template> - -<script setup> -import { ref, reactive, computed } from "vue"; -import { useScanningStore } from "../../../stores/scanning"; -import { - GnPageHeader, - GnButton, - GnBadge, - GnTable, - GnModal, - GnInput, - GnAlert, -} from "gnexus-ui-kit/vue"; -import AppEmptyState from "../../../components/feedback/AppEmptyState.vue"; -import AppErrorState from "../../../components/feedback/AppErrorState.vue"; -import AppLoadingState from "../../../components/feedback/AppLoadingState.vue"; - -const scanningStore = useScanningStore(); - -const showSetupModal = ref(false); -const setupLoading = ref(false); -const setupError = ref(""); -const setupForm = reactive({ - device_ip: "", - alias: "", - name: "", - description: "", -}); - -const tableColumns = [ - { key: "device_name", label: "Device" }, - { key: "ip_address", label: "IP" }, - { key: "mac_address", label: "MAC" }, - { key: "firmware_version", label: "Firmware" }, - { key: "status", label: "Status" }, - { key: "actions", label: "Actions" }, -]; - -const tableRows = computed(() => - scanningStore.devices.map((device) => ({ - id: device.device_id || device.ip_address, - device_name: device.device_name || "Unknown", - device_type: device.device_type || "unknown", - ip_address: device.ip_address || "unknown", - mac_address: device.mac_address || "unknown", - firmware_version: device.firmware_version || "unknown", - status: device.status || "unknown", - })) -); - -function setMode(mode) { - scanningStore.setMode(mode); -} - -function scan() { - scanningStore.scan(); -} - -function openSetup(row) { - setupForm.device_ip = row.ip_address; - setupForm.alias = ""; - setupForm.name = ""; - setupForm.description = ""; - setupError.value = ""; - showSetupModal.value = true; -} - -async function submitSetup() { - setupLoading.value = true; - setupError.value = ""; - - const result = await scanningStore.setupDevice({ ...setupForm }); - setupLoading.value = false; - - if (!result.ok) { - setupError.value = result.error?.message || "Failed to setup device"; - return; - } - - showSetupModal.value = false; -} -</script> - -<style scoped> -.form-group { - margin-bottom: 16px; -} -</style> - |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| DevicesScanningPage.vue | -
-
- |
- 48.21% | -27/56 | -53.44% | -31/58 | -43.33% | -13/30 | -48.07% | -25/52 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| 1 -2 -3 -4 -5 -6 -7 -8 -9 -10 -11 -12 -13 -14 -15 -16 -17 -18 -19 -20 -21 -22 -23 -24 -25 -26 -27 -28 -29 -30 -31 -32 -33 -34 -35 -36 -37 -38 -39 -40 -41 -42 -43 -44 -45 -46 -47 -48 -49 -50 -51 -52 -53 -54 -55 -56 -57 -58 -59 -60 -61 -62 -63 -64 -65 -66 -67 -68 -69 -70 -71 -72 -73 -74 -75 -76 -77 -78 -79 -80 -81 -82 -83 -84 -85 -86 -87 -88 -89 -90 -91 -92 -93 -94 -95 -96 -97 -98 -99 -100 -101 -102 -103 -104 -105 -106 -107 -108 -109 -110 -111 -112 -113 -114 -115 -116 -117 -118 | -3x - - -2x - - - - - - - - - - - - - - - - - - - -1x - - - - - -1x - - - -1x - - - - - - - - - - - - - -1x - - - - - - - - - - - - - - - - - - - - - - - - - -1x -1x - -1x - - - - - - - - - - - - - - - - - - - - - - - - -1x -1x - - - - - - - - - - - - - - | <template>
- <section class="page">
- <GnPageHeader title="Actions" kicker="Scripts">
- <template #actions>
- <GnBadge variant="primary">{{ scriptsStore.totalActions }} scripts</GnBadge>
- </template>
- </GnPageHeader>
-
- <AppLoadingState v-if="scriptsStore.isLoadingActions" text="Loading actions" />
-
- <AppErrorState
- v-else-if="scriptsStore.errorActions"
- title="Actions loading failed"
- :message="scriptsStore.errorActions.message"
- :retry="scriptsStore.loadActions"
- />
-
- <AppEmptyState
- v-else-if="scriptsStore.actions.length === 0"
- title="No action scripts"
- message="No action scripts registered."
- />
-
- <div v-else class="area-grid">
- <GnCard
- v-for="script in scriptsStore.actions"
- :key="script.alias"
- :title="script.name"
- >
- <template #default>
- <div v-html="script.icon" class="script-icon" />
- <p v-if="script.description">{{ script.description }}</p>
- <p>
- <GnBadge :variant="script.state === 'enabled' ? 'success' : 'secondary'"
- >{{ script.state }}</GnBadge>
- <code>{{ script.alias }}</code>
- </p>
- <small>{{ script.author }}</small>
- </template>
- <template #footer>
- <GnButton
- variant="primary"
- icon="ph-play"
- :loading="scriptsStore.isRunning(script.alias)"
- :disabled="script.state !== 'enabled'"
- @click="run(script.alias)"
- >
- Run
- </GnButton>
- </template>
- </GnCard>
- </div>
-
- <GnAlert v-if="resultAlert" :variant="resultAlert.variant" class="result-alert">
- <strong>{{ resultAlert.title }}</strong>
- <p v-if="resultAlert.message">{{ resultAlert.message }}</p>
- </GnAlert>
- </section>
-</template>
-
-<script setup>
-import { ref, onMounted, computed } from "vue";
-import { useScriptsStore } from "../../../stores/scripts";
-import {
- GnPageHeader,
- GnBadge,
- GnCard,
- GnButton,
- GnAlert,
-} from "gnexus-ui-kit/vue";
-import AppEmptyState from "../../../components/feedback/AppEmptyState.vue";
-import AppErrorState from "../../../components/feedback/AppErrorState.vue";
-import AppLoadingState from "../../../components/feedback/AppLoadingState.vue";
-
-const scriptsStore = useScriptsStore();
-const resultAlert = ref(null);
-
-const resultAlertComputed = computed(() => {
- const r = scriptsStore.lastRunResult;
- if (!r) return null;
-
- if (r.ok) {
- return {
- variant: "success",
- title: `Ran ${r.alias}`,
- message: r.execTime ? `Exec time: ${r.execTime}` : undefined,
- };
- }
-
- return {
- variant: "danger",
- title: `Failed ${r.alias}`,
- message: r.error?.message || "Unknown error",
- };
-});
-
-async function run(alias) {
- resultAlert.value = null;
- const result = await scriptsStore.runScript(alias);
- resultAlert.value = resultAlertComputed.value;
-}
-
-onMounted(() => {
- scriptsStore.loadActions();
-});
-</script>
-
-<style scoped>
-.script-icon {
- font-size: 32px;
- margin-bottom: 12px;
-}
-
-.result-alert {
- margin-top: 24px;
-}
-</style>
- |
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| ScriptsActionsPage.vue | -
-
- |
- 56% | -14/25 | -38.09% | -8/21 | -66.66% | -8/12 | -52.38% | -11/21 | -
- Press n or j to go to the next uncovered block, b, p or k for the previous block. -
- -| File | -- | Statements | -- | Branches | -- | Functions | -- | Lines | -- |
|---|---|---|---|---|---|---|---|---|---|
| api | -
-
- |
- 94.82% | -55/58 | -88.13% | -52/59 | -81.81% | -9/11 | -98.21% | -55/56 | -
| api/modules | -
-
- |
- 96.77% | -30/31 | -77.77% | -7/9 | -100% | -25/25 | -96.77% | -30/31 | -
| components/feedback | -
-
- |
- 100% | -5/5 | -100% | -4/4 | -100% | -2/2 | -100% | -5/5 | -
| components/layout | -
-
- |
- 100% | -3/3 | -100% | -0/0 | -100% | -1/1 | -100% | -3/3 | -
| features/areas/components | -
-
- |
- 90.32% | -28/31 | -100% | -39/39 | -83.33% | -15/18 | -88.88% | -24/27 | -
| features/areas/pages | -
-
- |
- 29.85% | -20/67 | -25% | -11/44 | -14.28% | -4/28 | -29.5% | -18/61 | -
| features/devices/components | -
-
- |
- 100% | -27/27 | -88.88% | -24/27 | -100% | -6/6 | -100% | -25/25 | -
| features/devices/pages | -
-
- |
- 48.21% | -27/56 | -53.44% | -31/58 | -43.33% | -13/30 | -48.07% | -25/52 | -
| features/scripts/pages | -
-
- |
- 56% | -14/25 | -38.09% | -8/21 | -66.66% | -8/12 | -52.38% | -11/21 | -
| stores | -
-
- |
- 93.03% | -227/244 | -66.38% | -79/119 | -90.9% | -50/55 | -93.19% | -219/235 | -
| test/mocks | -
-
- |
- 85.18% | -23/27 | -85% | -17/20 | -66.66% | -2/3 | -88.46% | -23/26 | -