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"
      :error="error"
      :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 text-muted">Type:</span>
          <span class="info-value">{{ device.device_type }}</span>
        </div>
        <div class="info-row">
          <span class="info-label text-muted">State:</span>
          <span class="info-value">
            <DeviceChannelsState
              :device-type="device.device_type"
              :response="devicesStore.currentDeviceStatus?.raw"
              :loading="isLoadingStatus"
              :error="statusError?.message"
              :connection-status="device.connection_status"
            />
          </span>
        </div>
        <div class="info-row">
          <span class="info-label text-muted">IP:</span>
          <span class="info-value">{{ device.device_ip }}</span>
        </div>
        <div class="info-row">
          <span class="info-label text-muted">MAC:</span>
          <span class="info-value">{{ device.device_mac }}</span>
        </div>
        <div class="info-row">
          <span class="info-label text-muted">Hard ID:</span>
          <span class="info-value">{{ device.device_hard_id }}</span>
        </div>
        <div class="info-row">
          <span class="info-label text-muted">Firmware:</span>
          <span class="info-value">{{ device.firmware_version }}</span>
        </div>
        <div v-if="device.last_contact" class="info-row">
          <span class="info-label text-muted">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 text-muted">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>

    <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="handleAssignSubmit"
        >
          Assign
        </GnButton>
      </template>
    </GnModal>

    <GnModal
      :open="showUnassignDialog"
      title="Unassign from area"
      @update:open="showUnassignDialog = $event"
    >
      <p>{{ unassignDialogMessage }}</p>
      <div v-if="unassignError" class="form-group">
        <GnAlert variant="danger">{{ unassignError }}</GnAlert>
      </div>
      <template #footer>
        <GnButton variant="secondary" @click="showUnassignDialog = false">Cancel</GnButton>
        <GnButton variant="warning" icon="ph-x-circle" :loading="unassignLoading" @click="submitUnassign">
          Unassign
        </GnButton>
      </template>
    </GnModal>

    <GnModal
      :open="showRemoveDialog"
      title="Remove device"
      @update:open="showRemoveDialog = $event"
    >
      <p>{{ removeDialogMessage }}</p>
      <div v-if="removeError" class="form-group">
        <GnAlert variant="danger">{{ removeError }}</GnAlert>
      </div>
      <template #footer>
        <GnButton variant="secondary" @click="showRemoveDialog = false">Cancel</GnButton>
        <GnButton variant="danger" icon="ph-trash" :loading="removeLoading" @click="submitRemove">
          Remove
        </GnButton>
      </template>
    </GnModal>
  </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,
  GnSelect,
  useToast,
} 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";
import DeviceChannelsState from "../components/DeviceChannelsState.vue";

const route = useRoute();
const router = useRouter();
const devicesStore = useDevicesStore();
const areasStore = useAreasStore();
const toast = useToast();

const {
  areaOptions,
  showAssignModal,
  selectedAreaId,
  assignLoading,
  assignError,
  openAssign: openAssignModal,
  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 = ref(false);
const statusError = ref(null);

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 removeLoading = ref(false);
const removeError = ref("");

const showUnassignDialog = ref(false);
const unassignDialogMessage = ref("");
const unassignLoading = ref(false);
const unassignError = ref("");


const deviceActions = computed(() => {
  const actions = [{ label: "Edit", icon: "ph-pencil", onSelect: openEdit }];

  if (device.value?.area_id) {
    actions.push(
      { label: "Change area", icon: "ph-map-pin", onSelect: openAssign },
      { label: "Unassign from area", icon: "ph-x-circle", onSelect: openUnassign }
    );
  } else {
    actions.push({ label: "Assign to area", icon: "ph-map-pin", onSelect: openAssign });
  }

  actions.push(
    {
      label: "Reboot",
      icon: "ph-arrow-clockwise",
      disabled: devicesStore.isRebooting(device.value?.id),
      onSelect: reboot,
    },
    { label: "Remove", icon: "ph-trash", danger: true, onSelect: remove }
  );

  return actions;
});

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;
  toast.success({ title: "Updated", text: "Device updated successfully" });
}

function openAssign() {
  openAssignModal(device.value?.area_id);
}

async function handleAssignSubmit() {
  const result = await submitAssignCore(deviceId.value, devicesStore.assignToArea.bind(devicesStore));
  if (result?.ok) {
    toast.success({ title: "Assigned", text: "Device assigned to area successfully" });
  }
}

function openUnassign() {
  if (!device.value) return;
  unassignDialogMessage.value = `Are you sure you want to unassign device "${device.value.name || device.value.alias}" from its area?`;
  unassignError.value = "";
  showUnassignDialog.value = true;
}

async function submitUnassign() {
  if (!device.value) return;
  unassignLoading.value = true;
  unassignError.value = "";

  const result = await devicesStore.unassignDevice(deviceId.value);
  unassignLoading.value = false;

  if (!result.ok) {
    unassignError.value = result.error?.message || "Failed to unassign device";
    return;
  }

  showUnassignDialog.value = false;
  toast.success({ title: "Unassigned", text: "Device unassigned from area successfully" });
}

function remove() {
  if (!device.value) return;
  removeDialogMessage.value = `Are you sure you want to remove device "${device.value.name || device.value.alias}"?`;
  removeError.value = "";
  showRemoveDialog.value = true;
}

async function submitRemove() {
  const id = deviceId.value;
  removeLoading.value = true;
  removeError.value = "";

  const result = await devicesStore.removeDevice(id);
  removeLoading.value = false;

  if (!result.ok) {
    removeError.value = result.error?.message || "Failed to remove device";
    return;
  }

  showRemoveDialog.value = false;
  toast.success({ title: "Removed", text: "Device removed successfully" });
  router.push({ name: "devices" });
}

async function reboot() {
  if (!device.value) return;

  const result = await devicesStore.rebootDevice(device.value.id);

  if (!result.ok) {
    toast.error({ title: "Reboot failed", text: result.error?.message || "Failed to reboot device" });
  } else {
    toast.success({ title: "Rebooting", text: `Device ${device.value.name || device.value.alias || "#" + device.value.id} is rebooting` });
  }
}

async function loadStatus() {
  const id = deviceId.value;
  if (!id || !device.value) return;
  if (device.value.connection_status !== "active") return;

  isLoadingStatus.value = true;
  statusError.value = null;

  const result = await devicesStore.loadDeviceStatus(id);

  isLoadingStatus.value = false;

  if (!result.ok) {
    statusError.value = result.error;
  }
}

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 {
  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>