<template>
<section class="page">
<AppLoadingState v-if="isLoadingList" :text="loadingText" />
<AppErrorState
v-else-if="listError && !hasList"
:title="`${kicker} loading failed`"
:message="listError.message"
: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)"
/>
<GnButton
v-if="!isScope"
variant="primary"
icon="ph-map-pin"
@click="openAssign"
>
{{ script.area_id ? 'Change area' : 'Assign to area' }}
</GnButton>
<GnButton
v-if="isAction"
variant="primary"
icon="ph-play"
:loading="scriptsStore.isRunning(script.alias)"
:disabled="script.state !== 'enabled'"
@click="run"
>
Run
</GnButton>
</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"
:message="codeError.message"
: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>
<GnAlert v-if="resultAlert" :variant="resultAlert.variant" class="result-alert">
<strong>{{ resultAlert.title }}</strong>
<p v-if="resultAlert.message">{{ resultAlert.message }}</p>
</GnAlert>
</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,
} 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";
const route = useRoute();
const scriptsStore = useScriptsStore();
const areasStore = useAreasStore();
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 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");
});
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() {
if (!script.value?.alias) return;
resultAlert.value = null;
const result = await scriptsStore.runScript(script.value.alias);
resultAlert.value = resultAlertComputed.value;
}
async function toggleState(enabled) {
const id = scriptId.value;
if (!id) return;
if (isAction.value) {
await scriptsStore.setActionState(id, enabled);
} else if (isRegular.value) {
await scriptsStore.setRegularState(id, enabled);
} else if (isScope.value) {
await scriptsStore.setScopeState(id, enabled);
}
}
function openAssign() {
openAssignModal(script.value?.area_id);
}
async function submitAssign() {
const id = script.value?.id;
await submitAssignCore(id, scriptsStore.assignToArea.bind(scriptsStore));
}
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>