Newer
Older
smart-home-server / webclient-vue / src / features / devices / pages / DeviceDetailPage.vue
<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>