diff --git a/webclient-vue/.env b/webclient-vue/.env new file mode 100644 index 0000000..a469b08 --- /dev/null +++ b/webclient-vue/.env @@ -0,0 +1,3 @@ +VITE_API_BASE_URL= +VITE_API_PROXY_PATH=/proxy.php +VITE_API_TIMEOUT_MS=10000 diff --git a/webclient-vue/coverage/api/client.js.html b/webclient-vue/coverage/api/client.js.html new file mode 100644 index 0000000..ef59379 --- /dev/null +++ b/webclient-vue/coverage/api/client.js.html @@ -0,0 +1,283 @@ + + + + +
++ 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 | +