Newer
Older
smart-home-server / webclient-vue / src / features / scripts / pages / ScriptDetailPage.vue
<template>
  <section class="page">
    <AppLoadingState v-if="isLoadingList" :text="loadingText" />

    <AppErrorState
      v-else-if="listError && !hasList"
      :title="`${kicker} loading failed`"
      :error="listError"
      :retry="init"
    />

    <div v-else-if="script">
      <GnPageHeader :title="script.name || script.alias || script.name" :kicker="kicker">
        <template #actions>
          <GnSwitch
            :model-value="script.state === 'enabled'"
            label="Enabled"
            @update:model-value="toggleState($event)"
          />
          <PageActionsDropdown :items="scriptActions" />
        </template>
      </GnPageHeader>

      <div class="script-detail-meta">
        <div v-if="script.icon" v-html="script.icon" class="script-icon" />
        <p v-if="script.description">{{ script.description }}</p>
        <div class="script-meta">
          <GnBadge :variant="script.state === 'enabled' ? 'success' : 'secondary'"
          >{{ script.state }}</GnBadge>
          <code>{{ script.alias || script.name }}</code>
          <small v-if="script.author">{{ script.author }}</small>
          <AreaBadgeLink :area="area" :areaId="script.area_id" />
          <router-link
            v-if="script.scope"
            :to="{ name: 'script-detail', params: { type: 'scopes', id: script.scope } }"
            class="scope-link"
          >
            <span class="scope-label">Scope</span>
            <span class="scope-name">{{ script.scope }}</span>
            <i class="ph ph-arrow-right"></i>
          </router-link>
        </div>

        <div class="script-info-panel">
          <div v-if="script.filename" class="info-row">
            <span class="info-label">File:</span>
            <span class="info-value">{{ script.filename }}</span>
          </div>
          <div v-if="script.path" class="info-row">
            <span class="info-label">Path:</span>
            <span class="info-value">{{ script.path }}</span>
          </div>
          <div v-if="script.created_by" class="info-row">
            <span class="info-label">Author:</span>
            <span class="info-value">{{ script.created_by }}</span>
          </div>
        </div>
      </div>

      <AreaAssignSection
        v-if="!isScope"
        :item="script"
        emptyMessage="This script is not assigned to any area."
        @assign="openAssign"
      />

      <div v-if="isScope && scopeActions.length > 0" class="devices-panel">
        <div class="block-title">Action scripts ({{ scopeActions.length }})</div>
        <GnTable :rows="scopeActions" :columns="scriptColumns">
          <template #cell-state="{ row }">
            <GnBadge :variant="row.state === 'enabled' ? 'success' : 'secondary'">{{ row.state }}</GnBadge>
          </template>
          <template #cell-alias="{ row }">
            <router-link
              :to="{ name: 'script-detail', params: { type: 'actions', id: row.alias } }"
              class="script-link"
            >
              {{ row.alias }}
            </router-link>
          </template>
        </GnTable>
      </div>

      <div v-if="isScope && scopeRegular.length > 0" class="devices-panel">
        <div class="block-title">Regular scripts ({{ scopeRegular.length }})</div>
        <GnTable :rows="scopeRegular" :columns="scriptColumns">
          <template #cell-state="{ row }">
            <GnBadge :variant="row.state === 'enabled' ? 'success' : 'secondary'">{{ row.state }}</GnBadge>
          </template>
          <template #cell-alias="{ row }">
            <router-link
              :to="{ name: 'script-detail', params: { type: 'regular', id: row.alias } }"
              class="script-link"
            >
              {{ row.alias }}
            </router-link>
          </template>
        </GnTable>
      </div>

      <div v-if="script.code || isScope" class="devices-panel">
        <div class="block-title">Source code</div>
        <AppLoadingState v-if="isLoadingCode" text="Loading source code" />
        <AppErrorState
          v-else-if="codeError"
          title="Code loading failed"
          :error="codeError"
          :retry="loadCode"
        />
        <pre v-else-if="currentCode" class="code-block"><code class="language-php" v-html="highlightedCode"></code></pre>
        <AppEmptyState
          v-else
          title="No code"
          message="Source code is not available."
        />
      </div>

    </div>

    <AppEmptyState
      v-else
      title="Not found"
      message="The requested script does not exist."
    />

    <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>
  </section>
</template>

<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import Prism from "prismjs";
import "prismjs/components/prism-markup-templating";
import "prismjs/components/prism-php";
import { useScriptsStore } from "../../../stores/scripts";
import { useAreasStore } from "../../../stores/areas";
import { useAreaAssign } from "../../../composables/useAreaAssign";
import {
  GnPageHeader,
  GnBadge,
  GnButton,
  GnSwitch,
  GnAlert,
  GnTable,
  GnSelect,
  GnModal,
  useToast,
} from "gnexus-ui-kit/vue";
import AppLoadingState from "../../../components/feedback/AppLoadingState.vue";
import AppErrorState from "../../../components/feedback/AppErrorState.vue";
import AppEmptyState from "../../../components/feedback/AppEmptyState.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 scriptsStore = useScriptsStore();
const areasStore = useAreasStore();
const toast = useToast();

const {
  areaOptions,
  showAssignModal,
  selectedAreaId,
  assignLoading,
  assignError,
  openAssign: openAssignModal,
  submitAssign: submitAssignCore,
} = useAreaAssign();

const scriptType = computed(() => route.params.type);
const scriptId = computed(() => route.params.id);

const isAction = computed(() => scriptType.value === "actions");
const isRegular = computed(() => scriptType.value === "regular");
const isScope = computed(() => scriptType.value === "scopes");

const scriptActions = computed(() => {
  const actions = [];
  if (!isScope.value) {
    actions.push({
      label: script.value?.area_id ? "Change area" : "Assign to area",
      icon: "ph-map-pin",
      onSelect: openAssign,
    });
  }
  if (isAction.value) {
    actions.push({
      label: "Run",
      icon: "ph-play",
      disabled: script.value?.state !== "enabled" || scriptsStore.isRunning(script.value?.alias),
      onSelect: run,
    });
  }
  return actions;
});

const kicker = computed(() => {
  if (isAction.value) return "Actions";
  if (isRegular.value) return "Regular";
  if (isScope.value) return "Scope";
  return "Script";
});

const loadingText = computed(() => `Loading ${kicker.value.toLowerCase()} details`);

const isLoadingList = computed(() => {
  if (isAction.value) return scriptsStore.isLoadingActions;
  if (isRegular.value) return scriptsStore.isLoadingRegular;
  if (isScope.value) return scriptsStore.isLoadingScopes || scriptsStore.isLoadingActions || scriptsStore.isLoadingRegular;
  return false;
});

const listError = computed(() => {
  if (isAction.value) return scriptsStore.errorActions;
  if (isRegular.value) return scriptsStore.errorRegular;
  if (isScope.value) return scriptsStore.errorScopes;
  return null;
});

const hasList = computed(() => {
  if (isAction.value) return scriptsStore.actions.length > 0;
  if (isRegular.value) return scriptsStore.regular.length > 0;
  if (isScope.value) return scriptsStore.scopes.length > 0;
  return false;
});

const script = computed(() => {
  const id = scriptId.value;
  if (!id) return null;
  if (isAction.value) return scriptsStore.actionByAlias(id);
  if (isRegular.value) return scriptsStore.regularByAlias(id);
  if (isScope.value) return scriptsStore.scopeByName(id);
  return null;
});

const scopeActions = computed(() => {
  const id = scriptId.value;
  if (!id || !isScope.value) return [];
  return scriptsStore.actionsByScope(id);
});

const scopeRegular = computed(() => {
  const id = scriptId.value;
  if (!id || !isScope.value) return [];
  return scriptsStore.regularByScope(id);
});

const area = computed(() => {
  const areaId = script.value?.area_id;
  if (!areaId) return null;
  return areasStore.areasById[String(areaId)] || null;
});

const isLoadingCode = computed(() => {
  if (isScope.value) return scriptsStore.isLoadingScopeCode;
  return false;
});

const codeError = computed(() => {
  if (isScope.value) return scriptsStore.errorScopeCode;
  return null;
});

const currentCode = computed(() => {
  if (isAction.value || isRegular.value) return script.value?.code || "";
  if (isScope.value) return scriptsStore.currentScopeCode;
  return "";
});

const highlightedCode = computed(() => {
  const code = currentCode.value;
  if (!code) return "";
  return Prism.highlight(code, Prism.languages.php, "php");
});

async function run() {
  if (!script.value?.alias) return;
  const result = await scriptsStore.runScript(script.value.alias);
  if (result?.ok) {
    toast.success({ title: `Ran ${script.value.alias}`, text: result.execTime ? `Exec time: ${result.execTime}` : undefined });
  } else {
    toast.error({ title: `Failed ${script.value.alias}`, text: result?.error?.message || "Unknown error" });
  }
}

async function toggleState(enabled) {
  const id = scriptId.value;
  if (!id) return;

  let result;
  if (isAction.value) {
    result = await scriptsStore.setActionState(id, enabled);
  } else if (isRegular.value) {
    result = await scriptsStore.setRegularState(id, enabled);
  } else if (isScope.value) {
    result = await scriptsStore.setScopeState(id, enabled);
  }

  const alias = script.value?.alias || id;
  if (result && !result.ok) {
    toast.error({ title: `Failed to ${enabled ? "enable" : "disable"} ${alias}`, text: result.error?.message || "Unknown error" });
  } else if (result) {
    toast.success({ title: `${enabled ? "Enabled" : "Disabled"} ${alias}` });
  }
}

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

async function submitAssign() {
  const id = script.value?.id;
  const result = await submitAssignCore(id, scriptsStore.assignToArea.bind(scriptsStore));
  if (result?.ok) {
    toast.success({ title: "Assigned", text: "Script assigned to area successfully" });
  }
}

async function loadCode() {
  const id = scriptId.value;
  if (!id || !isScope.value) return;
  await scriptsStore.loadScopeCode(id);
}

async function init() {
  const id = scriptId.value;
  if (!id) return;

  if (isAction.value && scriptsStore.actions.length === 0) {
    await scriptsStore.loadActions();
  } else if (isRegular.value && scriptsStore.regular.length === 0) {
    await scriptsStore.loadRegular();
  } else if (isScope.value && scriptsStore.scopes.length === 0) {
    await scriptsStore.loadScopes();
  }

  if (isScope.value) {
    if (scriptsStore.actions.length === 0) {
      await scriptsStore.loadActions();
    }
    if (scriptsStore.regular.length === 0) {
      await scriptsStore.loadRegular();
    }
    await loadCode();
  }

  if (areasStore.areas.length === 0) {
    await areasStore.loadAreas();
  }
}

const scriptColumns = [
  { key: "alias", label: "Alias" },
  { key: "name", label: "Name" },
  { key: "state", label: "State" },
];

onMounted(() => {
  init();
});

watch(() => [route.params.type, route.params.id], ([newType, newId], [oldType, oldId]) => {
  if (newType !== oldType || newId !== oldId) {
    scriptsStore.clearScopeCode();
    init();
  }
});
</script>

<style scoped>
.script-detail-meta {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin-bottom: 24px;
}

.script-icon {
  font-size: 32px;
}

.script-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.result-alert {
  margin-top: 24px;
}

.devices-panel {
  margin-bottom: 24px;
}

.block-title {
  font-weight: 700;
  text-transform: uppercase;
  margin-bottom: 12px;
  color: var(--color-primary);
}

.script-link {
  color: var(--color-primary);
}

.scope-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border: 1px solid var(--color-primary);
  color: var(--color-primary);
  text-decoration: none;
  font-size: 13px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}

.scope-link:hover {
  background: var(--color-primary);
  color: var(--color-bg);
}

.scope-label {
  text-transform: uppercase;
  font-size: 11px;
  font-weight: 700;
  opacity: 0.8;
}

.script-info-panel {
  display: grid;
  gap: 8px;
  padding: 12px;
  background: var(--color-panel);
  border: 1px solid rgba(192, 202, 245, 0.12);
}

.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;
}

.code-block {
  margin: 0;
  padding: 14px;
  overflow-x: auto;
}
</style>