<template>
<section class="page">
<AppLoadingState v-if="isLoading" text="Loading device details" />
<AppErrorState
v-else-if="error"
title="Device loading failed"
:message="error.message"
:retry="init"
/>
<div v-else-if="device">
<GnPageHeader :title="device.name || device.alias || `Device #${device.id}`" kicker="Device">
<template #actions>
<PageActionsDropdown :items="deviceActions" />
</template>
</GnPageHeader>
<div class="device-meta">
<GnBadge :variant="device.connection_status === 'active' ? 'success' : 'danger'"
>{{ device.connection_status }}</GnBadge>
<code>{{ device.alias }}</code>
<AreaBadgeLink :area="area" :areaId="device.area_id" />
</div>
<div class="script-info-panel">
<div class="info-row">
<span class="info-label">Type:</span>
<span class="info-value">{{ device.device_type }}</span>
</div>
<div class="info-row">
<span class="info-label">IP:</span>
<span class="info-value">{{ device.device_ip }}</span>
</div>
<div class="info-row">
<span class="info-label">MAC:</span>
<span class="info-value">{{ device.device_mac }}</span>
</div>
<div class="info-row">
<span class="info-label">Hard ID:</span>
<span class="info-value">{{ device.device_hard_id }}</span>
</div>
<div class="info-row">
<span class="info-label">Firmware:</span>
<span class="info-value">{{ device.firmware_version }}</span>
</div>
<div v-if="device.last_contact" class="info-row">
<span class="info-label">Last contact:</span>
<span class="info-value">{{ device.last_contact }}</span>
</div>
<div v-if="device.create_at" class="info-row">
<span class="info-label">Created:</span>
<span class="info-value">{{ device.create_at }}</span>
</div>
</div>
<AreaAssignSection
:item="device"
emptyMessage="This device is not assigned to any area."
@assign="openAssign"
/>
<div v-if="device.description" class="devices-panel">
<div class="block-title">Description</div>
<p>{{ device.description }}</p>
</div>
<div v-if="device.connection_status === 'active'" class="devices-panel">
<div class="block-title">Channel status</div>
<AppLoadingState v-if="isLoadingStatus" text="Loading channel status" />
<AppErrorState
v-else-if="statusError"
title="Status loading failed"
:message="statusError.message"
:retry="loadStatus"
/>
<GnTable
v-else-if="channels.length > 0"
:columns="channelColumns"
:rows="channels"
caption="Channels"
>
<template #cell-state="{ row }">
<GnBadge :variant="row.state === 'on' || row.state === true ? 'success' : 'secondary'"
>{{ row.state }}</GnBadge>
</template>
</GnTable>
<AppEmptyState
v-else
title="No channels"
message="No channel data available."
/>
</div>
</div>
<AppEmptyState
v-else
title="Device not found"
message="The requested device does not exist."
/>
<GnModal :open="showEditModal" title="Edit device" @update:open="showEditModal = $event">
<div class="form-group">
<GnInput v-model="editForm.name" label="Name" />
</div>
<div class="form-group">
<GnInput v-model="editForm.description" label="Description" />
</div>
<div class="form-group">
<GnInput v-model="editForm.alias" label="Alias" />
</div>
<div v-if="editError" class="form-group">
<GnAlert variant="danger">{{ editError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showEditModal = false">Cancel</GnButton>
<GnButton variant="primary" icon="ph-check" :loading="editLoading" @click="submitEdit">
Save
</GnButton>
</template>
</GnModal>
<GnModal
:open="showAssignModal"
title="Assign to area"
@update:open="showAssignModal = $event"
>
<GnSelect
v-model="selectedAreaId"
label="Area"
:options="areaOptions"
icon="ph-map-trifold"
/>
<div v-if="assignError" class="form-group">
<GnAlert variant="danger">{{ assignError }}</GnAlert>
</div>
<template #footer>
<GnButton variant="secondary" @click="showAssignModal = false">Cancel</GnButton>
<GnButton
variant="primary"
icon="ph-check"
:loading="assignLoading"
@click="submitAssign"
>
Assign
</GnButton>
</template>
</GnModal>
<GnConfirmDialog
:open="showRemoveDialog"
title="Remove device"
:message="removeDialogMessage"
confirm-text="Remove"
cancel-text="Cancel"
confirm-variant="danger"
@update:open="showRemoveDialog = $event"
@confirm="submitRemove"
/>
</section>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDevicesStore } from "../../../stores/devices";
import { useAreasStore } from "../../../stores/areas";
import { useAreaAssign } from "../../../composables/useAreaAssign";
import {
GnPageHeader,
GnButton,
GnBadge,
GnModal,
GnInput,
GnAlert,
GnTable,
GnSelect,
GnConfirmDialog,
} 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";
import AreaBadgeLink from "../../../components/area/AreaBadgeLink.vue";
import AreaAssignSection from "../../../components/area/AreaAssignSection.vue";
import PageActionsDropdown from "../../../components/layout/PageActionsDropdown.vue";
const route = useRoute();
const router = useRouter();
const devicesStore = useDevicesStore();
const areasStore = useAreasStore();
const {
areaOptions,
showAssignModal,
selectedAreaId,
assignLoading,
assignError,
openAssign: openAssignModal,
submitAssign: submitAssignCore,
} = useAreaAssign();
const deviceId = computed(() => route.params.id);
const device = computed(() => devicesStore.currentDevice);
const isLoading = computed(() => devicesStore.isLoadingDetail);
const error = computed(() => devicesStore.errorDetail);
const isLoadingStatus = computed(() => devicesStore.isLoadingStates);
const statusError = computed(() => devicesStore.stateError);
const channels = computed(() => devicesStore.currentDeviceStatus?.channels || []);
const area = computed(() => {
const areaId = device.value?.area_id;
if (!areaId) return null;
return areasStore.areasById[String(areaId)] || null;
});
const showEditModal = ref(false);
const editLoading = ref(false);
const editError = ref("");
const editForm = reactive({ name: "", description: "", alias: "" });
const showRemoveDialog = ref(false);
const removeDialogMessage = ref("");
const channelColumns = [
{ key: "channel", label: "Channel" },
{ key: "state", label: "State" },
{ key: "type", label: "Type" },
];
const deviceActions = computed(() => [
{ label: "Edit", icon: "ph-pencil", onSelect: openEdit },
{
label: device.value?.area_id ? "Change area" : "Assign to area",
icon: "ph-map-pin",
onSelect: openAssign,
},
{
label: "Reboot",
icon: "ph-arrow-clockwise",
disabled: devicesStore.isRebooting(device.value?.id),
onSelect: reboot,
},
{ label: "Remove", icon: "ph-trash", danger: true, onSelect: remove },
]);
function openEdit() {
if (!device.value) return;
editForm.name = device.value.name || "";
editForm.description = device.value.description || "";
editForm.alias = device.value.alias || "";
editError.value = "";
showEditModal.value = true;
}
async function submitEdit() {
editLoading.value = true;
editError.value = "";
const id = deviceId.value;
const results = await Promise.all([
editForm.name !== device.value?.name ? devicesStore.updateDeviceName(id, editForm.name) : { ok: true },
editForm.description !== device.value?.description ? devicesStore.updateDeviceDescription(id, editForm.description) : { ok: true },
editForm.alias !== device.value?.alias ? devicesStore.updateDeviceAlias(id, editForm.alias) : { ok: true },
]);
editLoading.value = false;
const failed = results.find((r) => !r.ok);
if (failed) {
editError.value = failed.error?.message || "Failed to update device";
return;
}
showEditModal.value = false;
}
function openAssign() {
openAssignModal(device.value?.area_id);
}
async function submitAssign() {
await submitAssignCore(deviceId.value, devicesStore.assignToArea.bind(devicesStore));
}
async function unassign() {
if (!device.value) return;
await devicesStore.unassignDevice(deviceId.value);
}
function remove() {
if (!device.value) return;
removeDialogMessage.value = `Are you sure you want to remove device "${device.value.name || device.value.alias}"?`;
showRemoveDialog.value = true;
}
async function submitRemove() {
const id = deviceId.value;
await devicesStore.removeDevice(id);
router.push({ name: "devices" });
}
function reboot() {
if (!device.value) return;
devicesStore.rebootDevice(device.value.id);
}
async function loadStatus() {
const id = deviceId.value;
if (!id || !device.value) return;
if (device.value.connection_status !== "active") return;
await devicesStore.loadDeviceStatus(id);
}
async function init() {
const id = deviceId.value;
if (!id) return;
if (areasStore.areas.length === 0) {
await areasStore.loadAreas();
}
await devicesStore.loadDeviceDetail(id);
if (device.value?.connection_status === "active") {
await loadStatus();
}
}
onMounted(() => {
init();
});
onUnmounted(() => {
devicesStore.clearDeviceDetail();
});
watch(() => route.params.id, (newId, oldId) => {
if (newId !== oldId) {
devicesStore.clearDeviceDetail();
init();
}
});
</script>
<style scoped>
.device-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 24px;
}
.script-info-panel {
display: grid;
gap: 8px;
padding: 12px;
background: var(--color-panel);
border: 1px solid rgba(192, 202, 245, 0.12);
margin-bottom: 24px;
}
.info-row {
display: flex;
gap: 8px;
align-items: baseline;
}
.info-label {
color: var(--color-muted);
font-size: 12px;
text-transform: uppercase;
min-width: 48px;
}
.info-value {
font-size: 13px;
word-break: break-all;
}
.devices-panel {
margin-bottom: 24px;
}
.block-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 12px;
color: var(--color-primary);
}
.form-group {
margin-bottom: 16px;
}
</style>