Files
poshmanager/client/src/App.vue
2026-06-25 14:43:13 -05:00

3080 lines
132 KiB
Vue

<template>
<div class="shell" :class="[`theme-${activeTheme}`, { 'nav-collapsed': navCollapsed }]">
<ParticleBackdrop />
<LoginView
v-if="!token"
:login-form="loginForm"
:error="error"
:show-password="showPassword"
:entra-enabled="entraLoginEnabled"
@login="login"
@update:show-password="showPassword = $event"
/>
<template v-else>
<AppSidebar
:nav="nav"
:user="user"
:view="view"
:collapsed="navCollapsed"
@update:view="view = $event"
@toggle-collapse="toggleNav"
/>
<main class="workspace">
<AppTopbar :active-title="activeTitle" :user="user" @refresh="refreshAll" @logout="logout" @open-profile="view = 'profile'" @open-help="openHelpCenter" />
<DashboardView
v-if="view === 'dashboard'"
:day-part="dayPart"
:first-name="firstName"
:dashboard-edit-mode="dashboardEditMode"
:dashboard-widget-library-open="dashboardWidgetLibraryOpen"
:dragged-dashboard-widget-id="draggedDashboardWidgetId"
:visible-dashboard-widgets="visibleDashboardWidgets"
:hidden-dashboard-widgets="hiddenDashboardWidgets"
:execution-trend="executionTrend"
:jobs="jobs"
:health-gauge="healthGauge"
:job-status-stats="jobStatusStats"
:dashboard-settings-preview="dashboardSettingsPreview"
:host-transport-stats="hostTransportStats"
:script-visibility-stats="scriptVisibilityStats"
:run-plan-target-stats="runPlanTargetStats"
:setting-label="settingLabel"
@toggle-edit="dashboardEditMode = !dashboardEditMode"
@open-library="dashboardWidgetLibraryOpen = true"
@close-library="dashboardWidgetLibraryOpen = false"
@reset-layout="resetDashboardLayout"
@drag-start="dragDashboardStart"
@drop-on="dropDashboardOn"
@toggle-collapse="toggleWidgetCollapse"
@resize="resizeDashboardWidget"
@set-visible="setDashboardWidgetVisible"
@open-job="openJob"
@navigate="view = $event"
@quick-new-script="view = 'scripts'; newScript(activeFolderId)"
/>
<section v-if="view === 'scripts'" class="dashboard-workspace template-grid-workspace">
<div class="page-header compact-header">
<div>
<span class="eyebrow">SCRIPT TEMPLATE WORKSPACE</span>
<h2>PowerShell scripts</h2>
<p>Versioned script blueprints for repeatable, governed remote execution.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="scriptEditMode = !scriptEditMode">
<SlidersHorizontal :size="16" />{{ scriptEditMode ? 'Done' : 'Customize' }}
</button>
<button v-if="!isScriptPanelVisible('library')" class="ghost-button compact" type="button" @click="setScriptPanelVisible('library', true)">
<Folder :size="16" />Open Script Library
</button>
<button v-if="!isScriptPanelVisible('inspector')" class="ghost-button compact" type="button" @click="setScriptPanelVisible('inspector', true)">
<ShieldCheck :size="16" />Open Properties
</button>
<button class="ghost-button compact" type="button" @click="openVariableStudio">
<Sparkles :size="16" />Variables
</button>
<button class="primary-action compact" type="button" @click="scriptPanelLibraryOpen = true">
<ListPlus :size="16" />Panel library
</button>
<button v-if="scriptEditMode" class="ghost-button compact" type="button" @click="resetScriptLayout">
<RotateCcw :size="14" />Reset
</button>
</div>
</div>
<div
:class="['script-template-workspace glass-panel', {
'without-library': !isScriptPanelVisible('library'),
'without-inspector': !isScriptPanelVisible('inspector')
}]"
>
<ScriptExplorer
v-if="isScriptPanelVisible('library')"
:folders="folders"
:scripts="scripts"
:selected-script-id="selectedScript?.id"
:active-folder-id="activeFolderId"
@select-script="selectScript"
@select-folder="activeFolderId = $event"
@create-folder="createFolder"
@close="setScriptPanelVisible('library', false)"
@context-menu="openContext($event.event, $event.item, $event.type)"
/>
<main class="script-editor-pane">
<header class="script-editor-header">
<div>
<FileCode2 :size="16" />
<strong>{{ selectedScript?.name || scriptDraft.name || 'Untitled Script.ps1' }}</strong>
<span>PowerShell</span>
<span>{{ scriptDraft.visibility }}</span>
</div>
<div>
<button class="ghost-button compact" type="button" @click="openVariableStudio"><Sparkles :size="15" />Variables</button>
<button class="ghost-button compact" type="button" @click="newScript(activeFolderId)"><FilePlus2 :size="15" />New</button>
<button class="primary-action compact" type="button" @click="saveScript"><Save :size="15" />Save script</button>
</div>
</header>
<div class="script-description-bar">
<input v-model="scriptDraft.name" placeholder="Inventory Health Check.ps1" />
<textarea v-model="scriptDraft.description" placeholder="Description"></textarea>
</div>
<div :ref="setEditorMount" class="monaco-host"></div>
<footer class="script-editor-status">
<span><i /> PowerShell IntelliSense</span>
<span>UTF-8</span>
<span>PowerShell</span>
<span>{{ scriptDraft.content?.length || 0 }} chars</span>
</footer>
</main>
<aside v-if="isScriptPanelVisible('inspector')" class="script-template-inspector">
<header>
<strong>Details</strong>
<button title="Close Properties" type="button" @click="setScriptPanelVisible('inspector', false)"><X :size="14" /></button>
</header>
<label>
<span>Name</span>
<input v-model="scriptDraft.name" />
</label>
<label>
<span>Description</span>
<textarea v-model="scriptDraft.description" rows="4" />
</label>
<label>
<span>Visibility</span>
<select v-model="scriptDraft.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="scriptDraft.visibility === 'group'">
<span>Group</span>
<select v-model="scriptDraft.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
<div class="inspector-section">
<span>LIBRARY STATS</span>
<div class="script-detail-row"><strong>{{ scripts.length }}</strong><small>Scripts</small></div>
<div class="script-detail-row"><strong>{{ folders.length }}</strong><small>Folders</small></div>
<div class="script-detail-row"><strong>{{ scriptDraft.content?.split('\n').length || 0 }}</strong><small>Lines</small></div>
</div>
<div class="inspector-section">
<span>ACTIONS</span>
<button class="primary-action compact full-width" type="button" @click="saveScript"><Save :size="15" />Save script</button>
<button class="ghost-button compact full-width" type="button" @click="view = 'runplans'"><Rocket :size="15" />Use in RunPlan</button>
<button v-if="selectedScript" class="ghost-button compact full-width" type="button" @click="openScriptTestRun(selectedScript)"><Play :size="15" />Test / Run</button>
<button v-if="selectedScript" class="ghost-button compact danger-text full-width" type="button" @click="deleteScript(selectedScript)"><Trash2 :size="15" />Delete selected</button>
</div>
</aside>
</div>
<Teleport to="body">
<div v-if="scriptPanelLibraryOpen" class="modal-backdrop" @mousedown.self="scriptPanelLibraryOpen = false">
<section class="widget-library glass-panel">
<header>
<div>
<span class="eyebrow">PANEL LIBRARY</span>
<h3>Customize script authoring</h3>
<p>Add hidden panels back to the script workspace, then drag and resize them on the grid.</p>
</div>
<button class="modal-close" type="button" @click="scriptPanelLibraryOpen = false">x</button>
</header>
<div class="widget-library-grid">
<article v-for="panel in hiddenScriptPanels" :key="panel.id">
<span><component :is="panel.icon" :size="20" /></span>
<div><strong>{{ panel.title }}</strong><small>{{ panel.category }} - {{ panel.description }}</small></div>
<button class="primary-action compact" type="button" @click="setScriptPanelVisible(panel.id, true)">Add</button>
</article>
<p v-if="!hiddenScriptPanels.length" class="widget-empty">All script panels are already visible.</p>
</div>
</section>
</div>
</Teleport>
<VariableEditorModal
:open="variableModalOpen"
:catalog="variableCatalog"
:groups="groups"
@close="variableModalOpen = false"
@insert="insertVariable"
@save-custom="saveCustomVariable"
@delete-custom="deleteCustomVariable"
/>
<ScriptTestRunModal
:open="scriptTestModalOpen"
:script="scriptTestTarget"
:hosts="hosts"
:host-groups="hostGroups"
:credentials="credentials"
:job="scriptTestJob"
:running="scriptTestRunning"
:error="scriptTestError"
@close="closeScriptTestRun"
@execute="executeScriptTestRun"
/>
</section>
<AssetsView
v-if="view === 'assets'"
:folders="assetFolders"
:assets="assets"
:groups="groups"
:scripts="scripts"
:runplans="runplans"
:psadt-profiles="psadtProfiles"
:intune-deployments="psadtIntuneDeployments"
:token="token"
@refresh="refreshAssets"
@save-folder="saveAssetFolder"
@delete-folder="deleteAssetFolder"
@upload-asset="uploadAsset"
@delete-asset="deleteAsset"
@link-asset="linkAsset"
@insert-reference="insertAssetReference"
/>
<PsadtView
v-if="view === 'psadt'"
:catalog="psadtCatalog"
:profiles="psadtProfiles"
:scripts="scripts"
:intune-deployments="psadtIntuneDeployments"
:graph-connections="graphConnections"
:graph-status-result="graphStatusResult"
:graph-audit-result="graphAuditResult"
:drift-result="driftResult"
:builder-status="builderStatus"
:installer-analysis="installerAnalysis"
:assets="assets"
:applications="catalogApplications"
:change-requests="changeRequests"
:reporting-overview="reportingOverview"
:validation-result="psadtValidationResult"
:migration-plan-result="psadtMigrationPlan"
:groups="groups"
@refresh="refreshPsadt"
@save-profile="savePsadtProfile"
@delete-profile="deletePsadtProfile"
@render-profile="previewPsadtProfile"
@create-script="createScriptFromPsadtProfile"
@insert-snippet="insertPsadtSnippet"
@validate-psadt="validatePsadtTarget"
@plan-migration="planPsadtMigration"
@apply-migration="applyPsadtMigration"
@save-intune-deployment="saveIntuneDeployment"
@delete-intune-deployment="deleteIntuneDeployment"
@save-graph-connection="saveGraphConnection"
@delete-graph-connection="deleteGraphConnection"
@test-graph-connection="testGraphConnection"
@link-graph-app="linkGraphApp"
@sync-graph-status="syncGraphStatus"
@load-graph-status="loadGraphStatus"
@load-graph-audit="loadGraphAudit"
@publish-graph-app="publishGraphApp"
@upload-graph-content="uploadGraphContent"
@assign-graph-app="assignGraphApp"
@set-graph-relationships="setGraphRelationships"
@check-drift="checkDrift"
@reconcile-graph-app="reconcileGraphApp"
@new-version="createDeploymentVersion"
@build-intunewin="buildIntunewin"
@save-application="saveApplication"
@delete-application="deleteApplication"
@check-application-update="checkApplicationUpdate"
@import-application="importApplication"
@refresh-governance="refreshGovernance"
@approve-change-request="approveChangeRequest"
@reject-change-request="rejectChangeRequest"
@analyze-installer="analyzeInstaller"
/>
<section v-if="view === 'hosts'" class="resource-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">HOST LIBRARY</span>
<h2>Managed systems</h2>
<p>Search, sort, and attach credentials to the hosts used by RunPlans.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="openVCenterImport">
<Download :size="16" />Import from vCenter
</button>
<button class="primary-action compact" type="button" @click="openHostModal()"><Plus :size="16" />Add host</button>
</div>
</div>
<ResourceTable
:columns="hostColumns"
:rows="pagedHosts"
:total="filteredHosts.length"
:search="hostFilters.search"
:page="hostFilters.page"
:page-count="hostPageCount"
:page-size="hostFilters.pageSize"
:sort="hostFilters.sort"
:direction="hostFilters.direction"
search-placeholder="Search hosts, addresses, tags..."
item-label="hosts"
empty-text="No hosts match the current search."
@update:search="hostFilters.search = $event"
@update:page="hostFilters.page = $event"
@update:page-size="hostFilters.pageSize = $event"
@sort="setSort(hostFilters, $event)"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-address="{ row }"><code>{{ row.address }}</code></template>
<template #cell-transport="{ row }"><span :class="['status-pill', row.transport]">{{ row.transport }}</span></template>
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openHostModal(row)">Edit</button></template>
</ResourceTable>
<HostGroupsPanel
:host-groups="hostGroups"
:hosts="hosts"
:groups="groups"
@save="saveHostGroup"
@delete="deleteHostGroup"
@sync="syncHostGroup"
@sync-all="syncHostGroups"
/>
</section>
<section v-if="view === 'credentials'" class="resource-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">CREDENTIAL VAULT</span>
<h2>Encrypted credentials</h2>
<p>Store reusable usernames, passwords, and API keys for host authentication.</p>
</div>
<div class="page-actions">
<button class="primary-action compact" type="button" @click="openCredentialModal()"><ShieldCheck :size="16" />Add credential</button>
</div>
</div>
<ResourceTable
:columns="credentialColumns"
:rows="pagedCredentials"
:total="filteredCredentials.length"
:search="credentialFilters.search"
:page="credentialFilters.page"
:page-count="credentialPageCount"
:page-size="credentialFilters.pageSize"
:sort="credentialFilters.sort"
:direction="credentialFilters.direction"
search-placeholder="Search credentials..."
item-label="credentials"
empty-text="No credentials match the current search."
@update:search="credentialFilters.search = $event"
@update:page="credentialFilters.page = $event"
@update:page-size="credentialFilters.pageSize = $event"
@sort="setSort(credentialFilters, $event)"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-username="{ row }"><code>{{ row.username || '-' }}</code></template>
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
<template #cell-status><span class="vault-state"><KeyRound :size="14" />Encrypted</span></template>
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openCredentialModal(row)">Edit</button></template>
</ResourceTable>
</section>
<section v-if="view === 'runplans'" class="resource-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">RUNPLAN LIBRARY</span>
<h2>Execution plans</h2>
<p>Pair scripts with deliberate host sets, then launch execution through the API.</p>
</div>
<div class="page-actions">
<button class="primary-action compact" type="button" @click="openRunPlanModal()"><Plus :size="16" />Add RunPlan</button>
</div>
</div>
<ResourceTable
:columns="runPlanColumns"
:rows="pagedRunPlans"
:total="filteredRunPlans.length"
:search="runPlanFilters.search"
:page="runPlanFilters.page"
:page-count="runPlanPageCount"
:page-size="runPlanFilters.pageSize"
:sort="runPlanFilters.sort"
:direction="runPlanFilters.direction"
search-placeholder="Search RunPlans, scripts, visibility..."
item-label="RunPlans"
empty-text="No RunPlans match the current search."
@update:search="runPlanFilters.search = $event"
@update:page="runPlanFilters.page = $event"
@update:page-size="runPlanFilters.pageSize = $event"
@sort="setSort(runPlanFilters, $event)"
>
<template #cell-name="{ row }"><strong>{{ row.name }}</strong></template>
<template #cell-scriptName="{ row }">{{ row.scriptName || 'No script selected' }}</template>
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
<template #cell-hostCount="{ row }">{{ row.hostCount || row.hostIds?.length || 0 }} host(s)<span v-if="row.hostGroupCount"> · {{ row.hostGroupCount }} group(s)</span></template>
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openRunPlanModal(row)">Edit</button>
<button class="primary-action compact" type="button" @click="executeRunPlan(row.id)"><Play :size="14" />Run</button>
</div>
</template>
</ResourceTable>
</section>
<VCenterImportModal
:open="vcenterImportOpen"
:preview-rows="vcenterPreviewRows"
:credentials="credentials"
:groups="groups"
:connections="vcenterConnections"
:loading="vcenterImportLoading"
:error="vcenterImportError"
:status="vcenterImportStatus"
:diagnostics="vcenterImportDiagnostics"
:trace-count="vcenterTraceEntries.length"
@close="vcenterImportOpen = false"
@preview="previewVCenterImport"
@import="importVCenterHosts"
@open-trace="vcenterTraceOpen = true"
/>
<VCenterTraceModal
:open="vcenterTraceOpen"
:trace="vcenterTraceEntries"
@close="vcenterTraceOpen = false"
/>
<BaseModal
:open="hostModalOpen"
:title="hostForm.id ? 'Update host' : 'Add host'"
eyebrow="HOST LIBRARY"
description="Define a target system once, attach its credential, and reuse it across RunPlans."
@close="hostModalOpen = false"
>
<form @submit.prevent="saveHost" class="form-grid">
<label><span>Display name</span><input v-model="hostForm.name" required placeholder="SQL-PROD-01" /></label>
<label><span>Address / FQDN</span><input v-model="hostForm.address" required placeholder="sql-prod-01.contoso.local" /></label>
<label><span>OS family</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="network">Network</option><option value="api">API</option></select></label>
<label><span>Transport</span><select v-model="hostForm.transport"><option value="winrm">WinRM</option><option value="ssh">SSH</option><option value="local">Local</option><option value="api">API</option></select></label>
<label class="full"><span>Credential</span><select v-model="hostForm.credentialId"><option :value="null">No credential</option><option v-for="credential in credentials" :key="credential.id" :value="credential.id">{{ credential.name }}</option></select></label>
<label class="full"><span>Tags</span><input v-model="hostForm.tagsText" placeholder="production, database, east" /></label>
<label><span>Visibility</span><select v-model="hostForm.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="hostForm.visibility === 'group'"><span>Group</span><select v-model="hostForm.groupId"><option :value="null">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="hostModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />{{ hostForm.id ? 'Update host' : 'Create host' }}</button>
</div>
</form>
</BaseModal>
<BaseModal
:open="credentialModalOpen"
:title="credentialForm.id ? 'Update credential' : 'Add credential'"
eyebrow="CREDENTIAL VAULT"
description="Secrets are encrypted at rest and never returned by the API after storage."
@close="credentialModalOpen = false"
>
<form @submit.prevent="saveCredential" class="form-grid">
<label><span>Name</span><input v-model="credentialForm.name" required placeholder="Domain automation account" /></label>
<label><span>Secret type</span><select v-model="credentialForm.kind"><option value="username_password">Username + password</option><option value="api_key">API key</option></select></label>
<label><span>Username / key label</span><input v-model="credentialForm.username" placeholder="svc-poshmanager" /></label>
<label><span>{{ credentialForm.id ? 'Replace secret' : 'Secret' }}</span><input v-model="credentialForm.secret" :required="!credentialForm.id" type="password" placeholder="Stored encrypted after save" /></label>
<label><span>Visibility</span><select v-model="credentialForm.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="credentialForm.visibility === 'group'"><span>Group</span><select v-model="credentialForm.groupId"><option :value="null">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="credentialModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><ShieldCheck :size="17" />{{ credentialForm.id ? 'Update credential' : 'Encrypt and store' }}</button>
</div>
</form>
</BaseModal>
<BaseModal
:open="runPlanModalOpen"
:title="runPlanForm.id ? 'Update RunPlan' : 'Add RunPlan'"
eyebrow="RUNPLAN COMPOSER"
description="Pair one script with one or more hosts. Execution and logging remain backend API workflows."
wide
@close="runPlanModalOpen = false"
>
<form @submit.prevent="saveRunPlan" class="form-grid">
<label><span>RunPlan name</span><input v-model="runPlanForm.name" required placeholder="Patch validation sweep" /></label>
<label><span>Script</span><select v-model="runPlanForm.scriptId" required><option disabled value="">Choose script</option><option v-for="script in scripts" :key="script.id" :value="script.id">{{ script.name }}</option></select></label>
<label><span>Visibility</span><select v-model="runPlanForm.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="runPlanForm.visibility === 'group'"><span>Group</span><select v-model="runPlanForm.groupId"><option :value="null">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="toggle modal-toggle"><input v-model="runPlanForm.parallel" type="checkbox" /><span><strong>Parallel execution</strong><small>Run against selected hosts concurrently.</small></span></label>
<label class="full"><span>Description</span><textarea v-model="runPlanForm.description" rows="3" placeholder="Purpose, operator notes, approval context"></textarea></label>
<div class="full modal-host-picker">
<div class="list-header"><span>Target host groups</span><strong>{{ runPlanForm.hostGroupIds.length }} selected</strong></div>
<div class="host-picker">
<label v-for="hostGroup in hostGroups" :key="hostGroup.id">
<input v-model="runPlanForm.hostGroupIds" type="checkbox" :value="hostGroup.id" />
<span><strong>{{ hostGroup.name }}</strong><small>{{ hostGroup.mode }} · {{ hostGroup.memberCount || 0 }} host(s)</small></span>
</label>
</div>
</div>
<div class="full modal-host-picker">
<div class="list-header"><span>Target hosts</span><strong>{{ runPlanForm.hostIds.length }} selected</strong></div>
<div class="host-picker">
<label v-for="host in hosts" :key="host.id">
<input v-model="runPlanForm.hostIds" type="checkbox" :value="host.id" />
<span><strong>{{ host.name }}</strong><small>{{ host.transport }} · {{ host.address }}</small></span>
</label>
</div>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="runPlanModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />{{ runPlanForm.id ? 'Update RunPlan' : 'Create RunPlan' }}</button>
</div>
</form>
</BaseModal>
<section v-if="view === 'logs'" class="logs-layout">
<article :class="['glass-panel job-list', { 'panel-collapsed': panelCollapsed.jobsList }]">
<div class="section-title"><h3>Jobs</h3><div class="section-actions"><ListFilter :size="18" /><button class="panel-toggle" type="button" @click="togglePanel('jobsList')"><component :is="panelCollapsed.jobsList ? ChevronDown : ChevronUp" :size="14" /></button></div></div>
<button v-for="job in jobs" :key="job.id" :class="{ active: selectedJob?.id === job.id }" @click="openJob(job.id)">
<span :class="['status-dot', job.status]"></span>
<span><strong>{{ job.runplanName || job.id }}</strong><small>{{ job.status }} · {{ shortDate(job.createdAt) }}</small></span>
</button>
</article>
<article :class="['glass-panel log-viewer', { 'panel-collapsed': panelCollapsed.logViewer }]">
<div class="section-title log-viewer-title">
<h3>Log Viewer</h3>
<div class="section-actions">
<Download :size="18" />
<button class="panel-toggle" type="button" @click="togglePanel('logViewer')"><component :is="panelCollapsed.logViewer ? ChevronDown : ChevronUp" :size="14" /></button>
</div>
</div>
<div class="tabs log-tabs">
<button :class="{ active: logTab === 'job' }" @click="logTab = 'job'"><PlayCircle :size="15" />Job Output</button>
<button :class="{ active: logTab === 'system' }" @click="loadSystemLogs"><Activity :size="15" />System Logs</button>
</div>
<div class="resource-toolbar log-toolbar">
<label class="search-control">
<Search :size="15" />
<input v-model="logFilters.search" placeholder="Search source, stream, message..." />
</label>
<select v-model="logFilters.stream">
<option value="">All streams</option>
<option value="stdout">stdout</option>
<option value="stderr">stderr</option>
<option value="system">system</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
<span class="toolbar-count">{{ activeLogRows.length }} entries</span>
</div>
<div class="data-table-wrap log-table-wrap">
<table class="data-table log-table">
<thead>
<tr><th>Time</th><th>Level</th><th>Source</th><th>Message</th></tr>
</thead>
<tbody>
<tr v-for="row in pagedLogRows" :key="row.id">
<td>{{ shortDate(row.time) || '-' }}</td>
<td><span :class="['status-pill', row.level]">{{ row.level }}</span></td>
<td>{{ row.source }}</td>
<td><code>{{ row.message }}</code></td>
</tr>
</tbody>
</table>
<div v-if="!pagedLogRows.length" class="widget-empty">No log lines match the current filters.</div>
</div>
<div class="pagination">
<button class="ghost-button compact" :disabled="logFilters.page <= 1" @click="logFilters.page--">Previous</button>
<span>Page {{ Math.min(logFilters.page, logPageCount) }} of {{ logPageCount }}</span>
<button class="ghost-button compact" :disabled="logFilters.page >= logPageCount" @click="logFilters.page++">Next</button>
</div>
</article>
</section>
<section v-if="view === 'admin'" class="admin-page">
<IntuneGraphConfig
:graph-connections="graphConnections"
:groups="groups"
:users="users"
:is-admin="user?.role === 'admin'"
@save="saveGraphConnection"
@delete="deleteGraphConnection"
@test="testGraphConnection"
/>
<VCenterConnectionsConfig
:connections="vcenterConnections"
:groups="groups"
@save="saveVCenterConnection"
@delete="deleteVCenterConnection"
@test="testVCenterConnection"
/>
<article :class="['glass-panel form-panel settings-layout', { 'panel-collapsed': panelCollapsed.applicationConfig }]">
<div class="section-title"><h3>Application Config</h3><div class="section-actions"><SlidersHorizontal :size="18" /><button class="panel-toggle" type="button" @click="togglePanel('applicationConfig')"><component :is="panelCollapsed.applicationConfig ? ChevronDown : ChevronUp" :size="14" /></button></div></div>
<p class="panel-intro">Database settings are editable here; environment-sourced values are shown so Docker configuration stays visible.</p>
<div class="settings-editor">
<ConfigSection
v-for="section in configSections"
:key="section.section"
:section="section"
:icon="configSectionMeta(section.section).icon"
:description="configSectionMeta(section.section).description"
:collapsed="collapsedConfigSections[section.section]"
:label-for="settingLabel"
:description-for="settingDescription"
:placeholder-for="settingPlaceholder"
:is-secret-setting="isSecretSetting"
:source-label="settingSourceLabel"
:is-boolean-setting="isBooleanSetting"
:normalized-boolean-setting="normalizedBooleanSetting"
@toggle="toggleConfigSection(section.section)"
@toggle-boolean="toggleBooleanSetting"
/>
</div>
<button class="primary-action" @click="saveSettings"><Save :size="17" />Save settings</button>
</article>
</section>
<section v-if="view === 'users'" class="users-page">
<article :class="['glass-panel form-panel admin-directory', { 'panel-collapsed': panelCollapsed.usersGroups }]">
<div class="section-title"><h3>Users & Groups</h3><div class="section-actions"><UsersRound :size="18" /><button class="panel-toggle" type="button" @click="togglePanel('usersGroups')"><component :is="panelCollapsed.usersGroups ? ChevronDown : ChevronUp" :size="14" /></button></div></div>
<div class="directory-command-strip">
<div>
<span class="eyebrow">IDENTITY DIRECTORY</span>
<strong>{{ users.length }} users · {{ groups.length }} groups</strong>
<small>Manage local operators and group ownership for scripts, RunPlans, and credentials.</small>
</div>
<div>
<button class="ghost-button compact" type="button" @click="groupModalOpen = true"><UsersRound :size="16" />Add group</button>
<button class="primary-action compact" type="button" @click="userModalOpen = true"><UserPlus :size="16" />Add user</button>
</div>
</div>
<div class="directory-browser-grid compact-directory">
<div class="directory-results-panel">
<header><Mail :size="16" /><div><strong>Users</strong><span>{{ users.length }} accounts</span></div></header>
<div class="directory-result-list">
<article v-for="account in users" :key="account.id" class="directory-user-result">
<span class="avatar">{{ account.displayName?.split(' ').map((part) => part[0]).join('').slice(0, 2) }}</span>
<div><strong>{{ account.displayName }}</strong><small>{{ account.email }} · {{ account.role }}</small><code>{{ account.groups?.map((group) => group.name).join(', ') || 'No groups' }}</code></div>
<span :class="['status-pill', account.authProvider]">{{ account.authProvider }}</span>
</article>
</div>
</div>
<div class="directory-results-panel">
<header><UsersRound :size="16" /><div><strong>Groups</strong><span>{{ groups.length }} groups</span></div></header>
<div class="directory-result-list">
<article v-for="group in groups" :key="group.id" class="directory-user-result">
<span class="avatar group-avatar"><UsersRound :size="15" /></span>
<div><strong>{{ group.name }}</strong><small>{{ group.description || 'No description' }}</small><code>{{ group.memberCount || 0 }} members</code></div>
<span class="status-pill">group</span>
</article>
</div>
</div>
</div>
</article>
<BaseModal
:open="userModalOpen"
title="Add user"
eyebrow="USER MANAGEMENT"
description="Create a local operator account and assign initial group memberships."
@close="userModalOpen = false"
>
<form @submit.prevent="saveUser" class="form-grid">
<label><span>Display name</span><input v-model="userForm.displayName" required placeholder="Automation Operator" /></label>
<label><span>Email</span><input v-model="userForm.email" required type="email" placeholder="operator@example.com" /></label>
<label><span>Role</span><select v-model="userForm.role"><option value="user">User</option><option value="admin">Admin</option></select></label>
<label><span>Temporary password</span><input v-model="userForm.password" type="password" placeholder="Minimum 8 characters" /></label>
<label class="full"><span>Groups</span><select v-model="userForm.groupIds" multiple class="select-tall"><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="userModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><UserPlus :size="17" />Create user</button>
</div>
</form>
</BaseModal>
<BaseModal
:open="groupModalOpen"
title="Add group"
eyebrow="GROUP MANAGEMENT"
description="Create a group for shared script, RunPlan, and credential access."
compact
@close="groupModalOpen = false"
>
<form @submit.prevent="saveGroup" class="form-grid">
<label><span>Group name</span><input v-model="groupForm.name" placeholder="Operations" /></label>
<label><span>Description</span><input v-model="groupForm.description" placeholder="Shared scripts and RunPlans" /></label>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="groupModalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Plus :size="17" />Create group</button>
</div>
</form>
</BaseModal>
</section>
<section v-if="view === 'profile'" class="profile-layout">
<article class="glass-panel profile-identity-card">
<div class="profile-glow"></div>
<div class="profile-avatar">
<img v-if="profileForm.avatarUrl" :src="profileForm.avatarUrl" alt="" />
<span v-else>{{ userInitials }}</span>
</div>
<div>
<span class="eyebrow">Operator Profile</span>
<h3>{{ profileForm.displayName || user?.displayName }}</h3>
<p>{{ profileForm.jobTitle || user?.role || 'PowerShell operator' }}</p>
</div>
<div class="identity-meta">
<span><strong>{{ user?.authProvider || 'local' }}</strong> Authentication</span>
<span><strong>{{ activeTheme }}</strong> Active theme</span>
<span><strong>{{ profileForm.timezone || 'local' }}</strong> Time zone</span>
</div>
</article>
<article class="glass-panel form-panel profile-editor-card">
<div class="section-title"><h3>Profile Details</h3><UserCircle :size="18" /></div>
<p class="panel-intro">Keep identity details attached to the session so shared scripts, RunPlans, and logs read cleanly across teams.</p>
<form class="grid-form" @submit.prevent="saveProfile">
<label class="field">
<span>Display name</span>
<input v-model="profileForm.displayName" required />
</label>
<label class="field">
<span>Email address</span>
<input v-model="profileForm.email" type="email" :readonly="isExternalAuth" />
<small v-if="isExternalAuth">Managed by {{ user?.authProvider }} identity.</small>
</label>
<label class="field">
<span>Job title</span>
<input v-model="profileForm.jobTitle" placeholder="Automation lead" />
</label>
<label class="field">
<span>Phone</span>
<input v-model="profileForm.phone" placeholder="+1 555 0100" />
</label>
<label class="field">
<span>Time zone</span>
<input v-model="profileForm.timezone" placeholder="America/Chicago" />
</label>
<label class="field">
<span>Avatar URL</span>
<input v-model="profileForm.avatarUrl" placeholder="https://..." />
</label>
<div class="form-actions">
<button class="primary-action"><Save :size="17" />Save profile</button>
</div>
</form>
</article>
<article class="glass-panel form-panel">
<div class="section-title"><h3>Theme</h3><Palette :size="18" /></div>
<p class="panel-intro">Theme preference is stored on your account. The current choices are placeholders for the deeper theme library we can expand later.</p>
<div class="theme-picker">
<button
v-for="theme in availableThemes"
:key="theme.id"
type="button"
:class="['theme-card', `theme-card-${theme.id}`, { active: activeTheme === theme.id }]"
@click="changeTheme(theme.id)"
>
<span class="theme-swatch">
<i v-for="color in theme.colors" :key="color" :style="{ background: color }"></i>
</span>
<span>
<strong>{{ theme.name }}</strong>
<small>{{ theme.description }}</small>
</span>
<CheckCircle2 v-if="activeTheme === theme.id" :size="18" />
</button>
</div>
</article>
<article class="glass-panel form-panel">
<div class="section-title"><h3>Security</h3><KeyRound :size="18" /></div>
<p class="panel-intro">Local accounts can change passwords here. Entra-connected accounts are handed to the Microsoft password flow.</p>
<form v-if="!isExternalAuth" class="grid-form" @submit.prevent="changePassword">
<label class="field">
<span>Current password</span>
<input v-model="passwordForm.currentPassword" type="password" autocomplete="current-password" />
</label>
<label class="field">
<span>New password</span>
<input v-model="passwordForm.newPassword" type="password" autocomplete="new-password" />
</label>
<label class="field">
<span>Confirm password</span>
<input v-model="passwordForm.confirmPassword" type="password" autocomplete="new-password" />
</label>
<div class="form-actions">
<button class="primary-action"><LockKeyhole :size="17" />Change password</button>
</div>
</form>
<div v-else class="external-auth-card">
<div>
<strong>{{ user?.authProvider }} password management</strong>
<span>Password changes are handled by Microsoft Entra so account policy, MFA, and conditional access stay authoritative.</span>
</div>
<button class="primary-action" type="button" @click="changePassword">
<ExternalLink :size="17" />Open Entra flow
</button>
</div>
</article>
</section>
<ErrorView
v-if="isUnknownView"
:title="`Unknown view: ${view}`"
message="The requested workspace is not registered in the POSHManager navigation map."
@go-dashboard="view = 'dashboard'"
/>
</main>
<div v-if="contextMenu.open" class="context-menu glass-panel" :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }">
<button @click="newScript(contextTargetFolderId())"><FilePlus2 :size="15" />New script</button>
<button v-if="contextMenu.type !== 'script'" @click="createFolder(contextTargetFolderId())"><FolderPlus :size="15" />New folder</button>
<template v-if="contextMenu.type === 'script'">
<button @click="openScriptTestRun(contextMenu.item)"><Play :size="15" />Test / Run</button>
<button @click="openRenameScript(contextMenu.item)"><Pencil :size="15" />Rename script</button>
<button @click="openCloneScript(contextMenu.item)"><Copy :size="15" />Clone script</button>
<button class="danger-text" @click="deleteScript(contextMenu.item)"><Trash2 :size="15" />Delete script</button>
</template>
<button v-if="contextMenu.type === 'folder'" @click="deleteFolder(contextMenu.item)"><Trash2 :size="15" />Delete folder</button>
</div>
<BaseModal
:open="scriptRenameModalOpen"
title="Rename script"
eyebrow="SCRIPT EXPLORER"
description="Update the script library display name while preserving the script id and version history."
compact
@close="closeScriptActionModals"
>
<form class="modal-form script-action-form" @submit.prevent="renameScript">
<label>
<span>Script name</span>
<input v-model.trim="scriptRenameForm.name" required placeholder="Inventory Health Check.ps1" />
</label>
<div v-if="scriptActionUsage?.total" class="script-impact-warning">
<AlertTriangle :size="18" />
<div>
<strong>{{ scriptActionUsage.total }} linked item{{ scriptActionUsage.total === 1 ? '' : 's' }} need review</strong>
<p>RunPlans and PSADT Intune deployments continue to reference this script id, but operator-facing names, docs, approval notes, or deployment metadata may need to be updated after rename.</p>
<ul>
<li v-for="item in scriptImpactItems" :key="`${item.type}-${item.id}`"><span>{{ item.type }}</span>{{ item.name }}</li>
</ul>
</div>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="closeScriptActionModals">Cancel</button>
<button class="primary-action" type="submit"><Save :size="16" />Rename script</button>
</div>
</form>
</BaseModal>
<BaseModal
:open="scriptCloneModalOpen"
title="Clone script"
eyebrow="SCRIPT EXPLORER"
description="Create a new independent script copy in the same folder. RunPlans and deployments are not copied."
compact
@close="closeScriptActionModals"
>
<form class="modal-form script-action-form" @submit.prevent="cloneScript">
<label>
<span>Clone name</span>
<input v-model.trim="scriptCloneForm.name" required placeholder="Inventory Health Check copy.ps1" />
</label>
<div class="script-action-note">
<Copy :size="17" />
<span>The clone starts unassigned so it can be safely edited before being attached to RunPlans or Intune publishing plans.</span>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="closeScriptActionModals">Cancel</button>
<button class="primary-action" type="submit"><Copy :size="16" />Create clone</button>
</div>
</form>
</BaseModal>
<BaseModal
:open="scriptDeleteModalOpen"
title="Delete script"
eyebrow="SCRIPT EXPLORER"
description="This action is permanent and can affect execution plans."
compact
@close="closeScriptActionModals"
>
<form class="modal-form script-action-form" @submit.prevent="confirmDeleteScript">
<div class="script-impact-warning danger">
<AlertTriangle :size="18" />
<div>
<strong>Delete {{ scriptActionTarget?.name }}?</strong>
<p v-if="scriptActionUsage?.total">This script is assigned to {{ scriptActionUsage.total }} item{{ scriptActionUsage.total === 1 ? '' : 's' }}. Linked RunPlans will be removed by the backend relationship, and PSADT Intune deployments will lose their saved script link.</p>
<p v-else>No linked RunPlans or PSADT Intune deployments were found, but the script and version history will still be removed.</p>
<ul v-if="scriptActionUsage?.total">
<li v-for="item in scriptImpactItems" :key="`${item.type}-${item.id}`"><span>{{ item.type }}</span>{{ item.name }}</li>
</ul>
</div>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="closeScriptActionModals">Cancel</button>
<button class="primary-action danger-action" type="submit"><Trash2 :size="16" />Delete permanently</button>
</div>
</form>
</BaseModal>
<HelpCenter
:open="helpOpen"
:articles="helpArticles"
:categories="helpCategories"
:selected-article="selectedHelpArticle"
:query="helpQuery"
:loading="helpLoading"
@close="helpOpen = false"
@search="searchHelp"
@select="selectHelpArticle"
/>
</template>
<div v-if="toast" class="toast glass-panel">{{ toast }}</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js';
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js';
import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js';
import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js';
import 'monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints.js';
import 'monaco-editor/esm/vs/editor/contrib/snippet/browser/snippetController2.js';
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import {
Activity, AlertTriangle, BarChart3, BookOpenText, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink,
FileCode2, FilePlus2, Folder, FolderPlus, Gauge, KeyRound, ListFilter, ListPlus, LockKeyhole, Mail,
PackageOpen, Pencil, PieChart, Play, PlayCircle, Plus, Palette, Rocket, RotateCcw, Save, Search, Server, Settings, ShieldCheck, SlidersHorizontal, Sparkles, Trash2, UserPlus,
UserCircle, UsersRound, X
} from '@lucide/vue';
import { createApi } from './api';
import { configurePowerShellMonaco } from './editor/powershellMonaco';
import AppSidebar from './components/AppSidebar.vue';
import AppTopbar from './components/AppTopbar.vue';
import ConfigSection from './components/settings/ConfigSection.vue';
import IntuneGraphConfig from './components/settings/IntuneGraphConfig.vue';
import VCenterConnectionsConfig from './components/settings/VCenterConnectionsConfig.vue';
import HelpCenter from './components/help/HelpCenter.vue';
import ParticleBackdrop from './components/ParticleBackdrop.vue';
import BaseModal from './components/ui/BaseModal.vue';
import ResourceTable from './components/ui/ResourceTable.vue';
import ScriptExplorer from './components/scripts/ScriptExplorer.vue';
import ScriptTestRunModal from './components/scripts/ScriptTestRunModal.vue';
import VariableEditorModal from './components/scripts/VariableEditorModal.vue';
import HostGroupsPanel from './components/hosts/HostGroupsPanel.vue';
import VCenterImportModal from './components/hosts/VCenterImportModal.vue';
import VCenterTraceModal from './components/hosts/VCenterTraceModal.vue';
import DashboardView from './views/DashboardView.vue';
import ErrorView from './views/ErrorView.vue';
import LoginView from './views/LoginView.vue';
import PsadtView from './views/PsadtView.vue';
import AssetsView from './views/AssetsView.vue';
self.MonacoEnvironment = {
getWorker() {
return new editorWorker();
}
};
const token = ref(localStorage.getItem('poshmanager.token') || '');
const user = ref(JSON.parse(localStorage.getItem('poshmanager.user') || 'null'));
const api = createApi(() => token.value, logout);
const view = ref('dashboard');
const error = ref('');
const toast = ref('');
const showPassword = ref(false);
const entraLoginEnabled = ref(false);
const navCollapsed = ref(localStorage.getItem('poshmanager.navCollapsed') === 'true');
const dashboardEditMode = ref(false);
const dashboardWidgetLibraryOpen = ref(false);
const draggedDashboardWidgetId = ref('');
const scriptEditMode = ref(false);
const scriptPanelLibraryOpen = ref(false);
const scriptRenameModalOpen = ref(false);
const scriptCloneModalOpen = ref(false);
const scriptDeleteModalOpen = ref(false);
const scriptActionTarget = ref(null);
const scriptActionUsage = ref(null);
const scriptTestModalOpen = ref(false);
const scriptTestTarget = ref(null);
const scriptTestJob = ref(null);
const scriptTestRunning = ref(false);
const scriptTestError = ref('');
const userModalOpen = ref(false);
const groupModalOpen = ref(false);
const hostModalOpen = ref(false);
const vcenterImportOpen = ref(false);
const vcenterImportLoading = ref(false);
const vcenterImportError = ref('');
const vcenterPreviewRows = ref([]);
const vcenterImportStatus = ref(null);
const vcenterImportDiagnostics = ref(null);
const vcenterTraceEntries = ref([]);
const vcenterTraceOpen = ref(false);
const credentialModalOpen = ref(false);
const runPlanModalOpen = ref(false);
const variableModalOpen = ref(false);
const helpOpen = ref(false);
const helpLoading = ref(false);
const helpQuery = ref('');
const helpArticles = ref([]);
const helpCategories = ref([]);
const selectedHelpArticle = ref(null);
const summary = ref({});
const settings = ref({});
const settingsDraft = reactive({});
const settingMetadata = {
server_fqdn: {
label: 'Server FQDN',
description: 'External hostname or URL used for generated links and reverse-proxy aware app behavior.',
placeholder: 'https://poshmanager.example.com',
section: 'Deployment'
},
trusted_origins: {
label: 'Trusted Origins',
description: 'Comma-separated browser origins allowed to call the API when deployed behind a proxy.',
placeholder: 'https://poshmanager.example.com,https://ops.example.com',
section: 'Deployment'
},
client_origin: {
label: 'Client Origin',
description: 'Primary UI origin used by auth callbacks and generated links.',
placeholder: 'https://poshmanager.example.com',
section: 'Deployment'
},
api_port: {
label: 'API Port',
description: 'Internal Express API port used behind the Vite/reverse-proxy surface.',
placeholder: '5174',
section: 'Runtime'
},
database_path: {
label: 'Database Path',
description: 'SQLite database location inside the container or mounted volume.',
placeholder: '/app/data/poshmanager.sqlite',
section: 'Runtime'
},
log_dir: {
label: 'Log Directory',
description: 'Directory where Winston request and system logs are written.',
placeholder: '/app/data/logs',
section: 'Runtime'
},
file_locker_dir: {
label: 'FileLocker Directory',
description: 'Filesystem root where uploaded package assets, images, scripts, and support files are stored.',
placeholder: '/app/FileLocker',
section: 'Runtime'
},
asset_dir: {
label: 'Legacy Asset Directory',
description: 'Compatibility alias for older deployments. New uploads use FileLocker.',
placeholder: '/app/FileLocker',
section: 'Runtime'
},
max_asset_bytes: {
label: 'Max Asset Bytes',
description: 'Maximum allowed size for one uploaded asset.',
placeholder: '52428800',
section: 'Runtime'
},
json_limit: {
label: 'JSON Body Limit',
description: 'Express request body limit used by API-first browser uploads.',
placeholder: '60mb',
section: 'Runtime'
},
node_env: {
label: 'Node Environment',
description: 'Runtime mode for the API process.',
placeholder: 'production',
section: 'Runtime'
},
entra_enabled: {
label: 'Microsoft Entra ID',
description: 'Turns on Entra-backed authentication while keeping local login available when disabled.',
section: 'Authentication'
},
entra_tenant_id: {
label: 'Entra Tenant ID',
description: 'Tenant identifier used for Microsoft identity flows.',
placeholder: '00000000-0000-0000-0000-000000000000',
section: 'Authentication'
},
entra_client_id: {
label: 'Entra Client ID',
description: 'Application registration client ID used by the Entra integration.',
placeholder: '00000000-0000-0000-0000-000000000000',
section: 'Authentication'
},
entra_callback_url: {
label: 'Entra Callback URL',
description: 'Redirect URL registered for the POSHManager sign-in flow.',
placeholder: 'https://poshmanager.example.com/api/auth/entra/callback',
section: 'Authentication'
},
entra_password_change_url: {
label: 'Entra Password Flow',
description: 'Microsoft URL opened when Entra-backed users change passwords.',
placeholder: 'https://mysignins.microsoft.com/security-info/password/change',
section: 'Authentication'
},
powershell_bin: {
label: 'PowerShell Binary',
description: 'Executable used by the API worker inside the Linux container when RunPlans execute.',
placeholder: 'pwsh',
section: 'Execution'
},
allow_script_execution: {
label: 'Script Execution',
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
section: 'Execution'
},
host_group_sync_interval_minutes: {
label: 'Host Group Sync Interval',
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
placeholder: '60',
section: 'Execution'
},
vcenter_enabled: {
label: 'VMware vCenter',
description: 'Enable VM discovery and import through the VMware vCenter REST API.',
section: 'Integrations'
},
vcenter_base_url: {
label: 'vCenter Base URL',
description: 'Root URL of the vCenter API endpoint used for VM inventory discovery.',
placeholder: 'https://vcenter.contoso.local',
section: 'Integrations'
},
vcenter_username: {
label: 'vCenter Username',
description: 'Account used to create vCenter API sessions for host import previews.',
placeholder: 'administrator@vsphere.local',
section: 'Integrations'
},
vcenter_password: {
label: 'vCenter Password',
description: 'Password used only by the backend service when authenticating to vCenter.',
placeholder: 'Stored in app settings or pinned by VCENTER_PASSWORD',
section: 'Integrations',
secret: true
},
vcenter_api_mode: {
label: 'vCenter API Mode',
description: 'Use auto for modern /api with fallback to legacy /rest. Set api or rest to force one contract.',
placeholder: 'auto',
section: 'Integrations'
},
vcenter_request_timeout_ms: {
label: 'vCenter Request Timeout',
description: 'Maximum time the backend waits for one vCenter HTTP request before returning a traceable timeout.',
placeholder: '20000',
section: 'Integrations'
},
vcenter_allow_untrusted_tls: {
label: 'Allow Untrusted vCenter TLS',
description: 'Permit self-signed or private CA vCenter certificates for lab and internal deployments.',
section: 'Integrations'
}
};
const users = ref([]);
const groups = ref([]);
const credentials = ref([]);
const hosts = ref([]);
const hostGroups = ref([]);
const folders = ref([]);
const scripts = ref([]);
const assetFolders = ref([]);
const assets = ref([]);
const runplans = ref([]);
const jobs = ref([]);
const variableCatalog = ref({ builtIns: [], psadt: [], custom: [] });
const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure: [], invokeParameters: [], functions: [], admxPolicies: [], launchCommands: [], snippets: [] });
const psadtProfiles = ref([]);
const psadtIntuneDeployments = ref([]);
const catalogApplications = ref([]);
const changeRequests = ref([]);
const reportingOverview = ref(null);
const graphConnections = ref([]);
const vcenterConnections = ref([]);
const graphStatusResult = ref(null);
const graphAuditResult = ref([]);
const driftResult = ref(null);
const builderStatus = ref(null);
const installerAnalysis = ref(null);
const psadtValidationResult = ref(null);
const psadtMigrationPlan = ref(null);
const selectedJob = ref(null);
const systemLogs = ref([]);
const logTab = ref('job');
const logFilters = reactive({ search: '', stream: '', page: 1, pageSize: 14 });
const hostFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
const credentialFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
const runPlanFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
const collapsedConfigSections = reactive(loadCollapsedConfigSections());
const activeFolderId = ref(null);
const selectedScript = ref(null);
const editorMount = ref(null);
let editor;
let scriptTestPollTimer = null;
let scriptTestPollFailures = 0;
const loginForm = reactive({ email: 'admin@posh.local', password: 'change-me-now' });
const scriptDraft = reactive({ id: null, folderId: null, name: '', description: '', content: '', visibility: 'personal', groupId: null });
const scriptRenameForm = reactive({ name: '' });
const scriptCloneForm = reactive({ name: '' });
const hostForm = reactive({ id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
const credentialForm = reactive({ id: null, name: '', kind: 'username_password', username: '', secret: '', visibility: 'personal', groupId: null });
const runPlanForm = reactive({ id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
const groupForm = reactive({ name: '', description: '' });
const userForm = reactive({ email: '', displayName: '', password: '', role: 'user', groupIds: [] });
const profileForm = reactive({ displayName: '', email: '', jobTitle: '', phone: '', timezone: '', avatarUrl: '' });
const passwordForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' });
const contextMenu = reactive({ open: false, x: 0, y: 0, item: null, type: null });
const panelCollapsed = reactive(loadPanelCollapsed());
const dashboardWidgetCatalog = [
{ id: 'scripts', title: 'Scripts', category: 'Library', icon: FileCode2, minW: 2, minH: 2, defaultW: 3, defaultH: 2, description: 'Script count and library state.' },
{ id: 'hosts', title: 'Hosts', category: 'Inventory', icon: Server, minW: 2, minH: 2, defaultW: 3, defaultH: 2, description: 'Registered target systems.' },
{ id: 'runplans', title: 'RunPlans', category: 'Execution', icon: Rocket, minW: 2, minH: 2, defaultW: 3, defaultH: 2, description: 'Saved execution plans.' },
{ id: 'jobs', title: 'Jobs', category: 'Telemetry', icon: Activity, minW: 2, minH: 2, defaultW: 3, defaultH: 2, description: 'Execution history.' },
{ id: 'execution-trend', title: 'Execution Trend', category: 'Charts', icon: BarChart3, minW: 4, minH: 3, defaultW: 6, defaultH: 3, description: 'RunPlan job activity over the latest executions.' },
{ id: 'operations-health', title: 'Operations Health', category: 'Charts', icon: PieChart, minW: 4, minH: 3, defaultW: 6, defaultH: 3, description: 'Execution status distribution and platform mix.' },
{ id: 'recent-jobs', title: 'Recent Jobs', category: 'Execution logs', icon: PlayCircle, minW: 4, minH: 3, defaultW: 6, defaultH: 3, description: 'Latest RunPlan execution state.' },
{ id: 'configuration', title: 'Configuration Snapshot', category: 'Control plane', icon: Settings, minW: 4, minH: 3, defaultW: 6, defaultH: 3, description: 'Runtime app settings.' },
{ id: 'quick-actions', title: 'Quick Actions', category: 'Operations', icon: Sparkles, minW: 3, minH: 2, defaultW: 4, defaultH: 3, description: 'Fast create and navigation controls.' },
{ id: 'host-mix', title: 'Host Transport Mix', category: 'Inventory', icon: Server, minW: 3, minH: 2, defaultW: 4, defaultH: 3, description: 'Transport breakdown for host targets.' },
{ id: 'script-visibility', title: 'Script Visibility', category: 'Governance', icon: ShieldCheck, minW: 3, minH: 2, defaultW: 4, defaultH: 3, description: 'Personal, shared, and group script spread.' },
{ id: 'runplan-targets', title: 'RunPlan Targets', category: 'Execution', icon: ListFilter, minW: 3, minH: 2, defaultW: 4, defaultH: 3, description: 'Host count distribution across RunPlans.' }
];
const defaultDashboardLayout = [
{ id: 'scripts', w: 3, h: 2, visible: true, collapsed: false },
{ id: 'hosts', w: 3, h: 2, visible: true, collapsed: false },
{ id: 'runplans', w: 3, h: 2, visible: true, collapsed: false },
{ id: 'jobs', w: 3, h: 2, visible: true, collapsed: false },
{ id: 'execution-trend', w: 6, h: 3, visible: true, collapsed: false },
{ id: 'operations-health', w: 6, h: 3, visible: true, collapsed: false },
{ id: 'recent-jobs', w: 6, h: 3, visible: true, collapsed: false },
{ id: 'configuration', w: 6, h: 3, visible: true, collapsed: false },
{ id: 'quick-actions', w: 4, h: 3, visible: false, collapsed: false },
{ id: 'host-mix', w: 4, h: 3, visible: false, collapsed: false },
{ id: 'script-visibility', w: 4, h: 3, visible: false, collapsed: false },
{ id: 'runplan-targets', w: 4, h: 3, visible: false, collapsed: false }
];
const dashboardLayout = reactive(loadDashboardLayout());
const scriptPanelCatalog = [
{ id: 'library', title: 'Script Library', category: 'Storage', icon: Folder, minW: 2, minH: 5, defaultW: 2, defaultH: 7, description: 'Folder tree and script selection.' },
{ id: 'editor', title: 'PowerShell Editor', category: 'Authoring', icon: FileCode2, minW: 6, minH: 5, defaultW: 8, defaultH: 7, description: 'Monaco terminal editor and script metadata.' },
{ id: 'inspector', title: 'Properties', category: 'Governance', icon: ShieldCheck, minW: 2, minH: 4, defaultW: 2, defaultH: 7, description: 'Selection details, visibility, and operational actions.' }
];
const defaultScriptLayout = [
{ id: 'library', w: 2, h: 7, visible: true, collapsed: false },
{ id: 'editor', w: 8, h: 7, visible: true, collapsed: false },
{ id: 'inspector', w: 2, h: 7, visible: true, collapsed: false }
];
const scriptLayout = reactive(loadScriptLayout());
const nav = [
{ id: 'dashboard', label: 'Dashboard', icon: Gauge },
{ id: 'scripts', label: 'Scripts', icon: BookOpenText },
{ id: 'assets', label: 'Assets', icon: PackageOpen },
{ id: 'psadt', label: 'PSADT', icon: Sparkles },
{ id: 'hosts', label: 'Hosts', icon: Server },
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound },
{ id: 'runplans', label: 'RunPlans', icon: Rocket },
{ id: 'logs', label: 'Logs', icon: Activity },
{ id: 'users', label: 'Users & Groups', icon: UsersRound },
{ id: 'admin', label: 'Config', icon: Settings }
];
const availableThemes = [
{
id: 'dashtreme',
name: 'Dashtreme Pulse',
description: 'Bright magenta, cyan, and amber glass controls.',
colors: ['#ffe38e', '#ff73b0', '#74ece7']
},
{
id: 'terminal',
name: 'Green Screen',
description: 'A darker operations console with phosphor edges.',
colors: ['#0b2d12', '#41ff64', '#d7ff72']
},
{
id: 'aurora',
name: 'Aurora Glass',
description: 'Cool blue and violet gradients with quiet contrast.',
colors: ['#5de7ff', '#7c5cff', '#f5f7ff']
}
];
const hostColumns = [
{ key: 'name', label: 'Name' },
{ key: 'address', label: 'Address' },
{ key: 'transport', label: 'Transport' },
{ key: 'sourceType', label: 'Source' },
{ key: 'credentialName', label: 'Credential' },
{ key: 'tags', label: 'Tags', sortable: false },
{ key: 'actions', label: 'Actions', sortable: false }
];
const credentialColumns = [
{ key: 'name', label: 'Name' },
{ key: 'kind', label: 'Type' },
{ key: 'username', label: 'Username / label' },
{ key: 'visibility', label: 'Visibility' },
{ key: 'status', label: 'Status', sortable: false },
{ key: 'actions', label: 'Actions', sortable: false }
];
const runPlanColumns = [
{ key: 'name', label: 'Name' },
{ key: 'scriptName', label: 'Script' },
{ key: 'visibility', label: 'Visibility' },
{ key: 'hostCount', label: 'Targets' },
{ key: 'mode', label: 'Mode', sortable: false },
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' }
];
const activeTitle = computed(() => (view.value === 'profile' ? 'My Profile' : nav.find((item) => item.id === view.value)?.label || 'Dashboard'));
const routedViewIds = computed(() => new Set([...nav.map((item) => item.id), 'profile']));
const isUnknownView = computed(() => token.value && !routedViewIds.value.has(view.value));
const activeTheme = computed(() => user.value?.theme || 'dashtreme');
const isExternalAuth = computed(() => Boolean(user.value?.authProvider && user.value.authProvider !== 'local'));
const userInitials = computed(() => (user.value?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
const firstName = computed(() => (user.value?.displayName || 'Operator').split(/\s+/)[0] || 'Operator');
const dayPart = computed(() => {
const hour = new Date().getHours();
if (hour < 12) return 'morning';
if (hour < 17) return 'afternoon';
return 'evening';
});
const dashboardMetricMap = computed(() => ({
scripts: { metric: true, label: 'Scripts', value: summary.value.scripts || scripts.value.length, icon: FileCode2, tone: 'violet', meta: 'library objects indexed' },
hosts: { metric: true, label: 'Hosts', value: summary.value.hosts || hosts.value.length, icon: Server, tone: 'cyan', meta: 'systems available for targeting' },
runplans: { metric: true, label: 'RunPlans', value: summary.value.runplans || runplans.value.length, icon: Rocket, tone: 'mint', meta: 'execution plans ready' },
jobs: { metric: true, label: 'Jobs', value: summary.value.jobs || jobs.value.length, icon: Activity, tone: 'orange', meta: 'execution records captured' }
}));
const dashboardCatalogById = computed(() => new Map(dashboardWidgetCatalog.map((widget) => [widget.id, widget])));
const visibleDashboardWidgets = computed(() => dashboardLayout
.filter((widget) => widget.visible !== false)
.map((widget) => ({ ...dashboardCatalogById.value.get(widget.id), ...widget, ...(dashboardMetricMap.value[widget.id] || {}) })));
const hiddenDashboardWidgets = computed(() => dashboardLayout
.filter((widget) => widget.visible === false)
.map((widget) => ({ ...dashboardCatalogById.value.get(widget.id), ...widget })));
const scriptCatalogById = computed(() => new Map(scriptPanelCatalog.map((panel) => [panel.id, panel])));
const hiddenScriptPanels = computed(() => scriptLayout
.filter((panel) => panel.visible === false)
.map((panel) => ({ ...scriptCatalogById.value.get(panel.id), ...panel })));
const hostTransportStats = computed(() => ['winrm', 'ssh', 'local', 'api'].map((transport) => ({
transport,
count: hosts.value.filter((host) => host.transport === transport).length
})));
const scriptVisibilityStats = computed(() => ['personal', 'shared', 'group'].map((visibility) => ({
visibility,
count: scripts.value.filter((script) => script.visibility === visibility).length
})));
const runPlanTargetStats = computed(() => runplans.value.slice(0, 5).map((runplan) => ({
name: runplan.name,
count: runplan.hostCount || runplan.hostIds?.length || 0
})));
const scriptImpactItems = computed(() => [
...(scriptActionUsage.value?.runplans || []).map((item) => ({ id: item.id, name: item.name, type: 'RunPlan' })),
...(scriptActionUsage.value?.intuneDeployments || []).map((item) => ({ id: item.id, name: item.name, type: 'PSADT Intune' }))
].slice(0, 6));
const dashboardSettingsPreview = computed(() => ['server_fqdn', 'trusted_origins', 'allow_script_execution', 'entra_enabled']
.filter((key) => settings.value[key])
.map((key) => ({ key, ...settings.value[key] })));
const configSections = computed(() => {
const sections = ['Deployment', 'Authentication', 'Integrations', 'Execution', 'Runtime', 'Other'];
return sections
.map((section) => ({
section,
rows: Object.entries(settingsDraft)
.filter(([key]) => (settingMetadata[key]?.section || 'Other') === section)
.map(([key, row]) => ({ key, row }))
}))
.filter((section) => section.rows.length);
});
const executionTrend = computed(() => {
const latest = jobs.value.slice(0, 8).reverse();
const padded = [
...Array.from({ length: Math.max(0, 8 - latest.length) }, (_, index) => ({
label: `Pending ${index + 1}`,
status: 'queued',
value: 18 + (index % 4) * 10
})),
...latest
];
const max = Math.max(padded.length, 1);
return padded.map((job, index) => ({
label: job.runplanName || job.label || `Job ${index + 1}`,
status: job.status,
value: Math.round(((index + 1) / max) * 100)
}));
});
const jobStatusStats = computed(() => ['queued', 'running', 'completed', 'failed'].map((status) => ({
status,
count: jobs.value.filter((job) => job.status === status).length
})));
const healthGauge = computed(() => {
const total = jobs.value.length || 1;
const completed = jobs.value.filter((job) => job.status === 'completed').length;
return Math.round((completed / total) * 100);
});
const activeLogRows = computed(() => {
const rows = logTab.value === 'job'
? (selectedJob.value?.logs || []).map((log) => ({
id: log.id,
time: log.createdAt,
level: log.stream,
source: log.hostName || 'system',
message: log.line
}))
: systemLogs.value.map((log, index) => ({
id: log.requestId || log.request_id || index,
time: log.timestamp,
level: log.level,
source: log.method ? `${log.method} ${log.path}` : (log.service || 'api'),
message: log.message || log.path || JSON.stringify(log)
}));
const query = logFilters.search.trim().toLowerCase();
return rows.filter((row) => {
const matchesStream = !logFilters.stream || row.level === logFilters.stream;
const haystack = `${row.level || ''} ${row.source || ''} ${row.message || ''}`.toLowerCase();
return matchesStream && (!query || haystack.includes(query));
});
});
const logPageCount = computed(() => Math.max(1, Math.ceil(activeLogRows.value.length / logFilters.pageSize)));
const pagedLogRows = computed(() => {
const start = (Math.min(logFilters.page, logPageCount.value) - 1) * logFilters.pageSize;
return activeLogRows.value.slice(start, start + logFilters.pageSize);
});
const filteredHosts = computed(() => filterRows(hosts.value, hostFilters, (host) => [
host.name,
host.address,
host.fqdn,
host.transport,
host.sourceType,
host.sourceConnectionName,
host.credentialName,
host.tags?.join(' ')
]));
const hostPageCount = computed(() => pageCount(filteredHosts.value, hostFilters));
const pagedHosts = computed(() => pageRows(filteredHosts.value, hostFilters));
const filteredCredentials = computed(() => filterRows(credentials.value, credentialFilters, (credential) => [
credential.name,
credential.kind,
credential.username,
credential.visibility
]));
const credentialPageCount = computed(() => pageCount(filteredCredentials.value, credentialFilters));
const pagedCredentials = computed(() => pageRows(filteredCredentials.value, credentialFilters));
const filteredRunPlans = computed(() => filterRows(runplans.value, runPlanFilters, (runplan) => [
runplan.name,
runplan.scriptName,
runplan.visibility,
runplan.description
]));
const runPlanPageCount = computed(() => pageCount(filteredRunPlans.value, runPlanFilters));
const pagedRunPlans = computed(() => pageRows(filteredRunPlans.value, runPlanFilters));
watch(view, async (nextView, previousView) => {
contextMenu.open = false;
if (previousView === 'scripts' && nextView !== 'scripts') disposeEditor();
if (nextView === 'scripts') await ensureEditorReady();
if (nextView === 'profile') hydrateProfileForm();
});
watch(() => scriptDraft.content, (value) => {
if (editor && editor.getValue() !== value) editor.setValue(value || '');
});
watch(user, () => hydrateProfileForm(), { immediate: true });
watch(() => [logFilters.search, logFilters.stream, logTab.value], () => {
logFilters.page = 1;
});
watch(() => [hostFilters.search, hostFilters.sort, hostFilters.direction, hostFilters.pageSize], () => {
hostFilters.page = 1;
});
watch(() => [credentialFilters.search, credentialFilters.sort, credentialFilters.direction, credentialFilters.pageSize], () => {
credentialFilters.page = 1;
});
watch(() => [runPlanFilters.search, runPlanFilters.sort, runPlanFilters.direction, runPlanFilters.pageSize], () => {
runPlanFilters.page = 1;
});
watch(logPageCount, () => {
if (logFilters.page > logPageCount.value) logFilters.page = logPageCount.value;
});
watch(hostPageCount, () => {
if (hostFilters.page > hostPageCount.value) hostFilters.page = hostPageCount.value;
});
watch(credentialPageCount, () => {
if (credentialFilters.page > credentialPageCount.value) credentialFilters.page = credentialPageCount.value;
});
watch(runPlanPageCount, () => {
if (runPlanFilters.page > runPlanPageCount.value) runPlanFilters.page = runPlanPageCount.value;
});
onMounted(async () => {
window.addEventListener('click', closeContext);
await consumeEntraRedirect();
await probeEntraLogin();
if (token.value) await refreshAll();
if (view.value === 'scripts') await ensureEditorReady();
});
// The Entra callback redirects back to the SPA with a one-time token (or an
// error) in the query string. Consume it, persist the session, and scrub the URL.
async function consumeEntraRedirect() {
const params = new URLSearchParams(window.location.search);
const entraToken = params.get('entraToken');
const entraError = params.get('entraError');
if (entraError) {
error.value = entraError;
} else if (entraToken) {
token.value = entraToken;
localStorage.setItem('poshmanager.token', entraToken);
}
if (entraToken || entraError) {
window.history.replaceState({}, document.title, window.location.pathname);
}
}
async function probeEntraLogin() {
if (token.value) return;
try {
const health = await api.get('/api/health');
entraLoginEnabled.value = Boolean(health?.entraLoginEnabled);
} catch {
entraLoginEnabled.value = false;
}
}
onBeforeUnmount(() => {
window.removeEventListener('click', closeContext);
stopScriptTestPolling();
disposeEditor();
});
function filterRows(rows, filters, fields) {
// Shared table filtering keeps Hosts, Credential Vault, and RunPlans behavior consistent.
const query = filters.search.trim().toLowerCase();
return [...rows]
.filter((row) => {
if (!query) return true;
return fields(row).filter(Boolean).join(' ').toLowerCase().includes(query);
})
.sort((a, b) => compareRows(a, b, filters.sort, filters.direction));
}
function compareRows(a, b, key, direction = 'asc') {
const left = a?.[key] ?? '';
const right = b?.[key] ?? '';
const result = typeof left === 'number' || typeof right === 'number'
? Number(left || 0) - Number(right || 0)
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
return direction === 'desc' ? result * -1 : result;
}
function pageCount(rows, filters) {
return Math.max(1, Math.ceil(rows.length / Number(filters.pageSize || 8)));
}
function pageRows(rows, filters) {
const page = Math.min(filters.page, pageCount(rows, filters));
const start = (page - 1) * Number(filters.pageSize || 8);
return rows.slice(start, start + Number(filters.pageSize || 8));
}
function setSort(filters, key) {
if (filters.sort === key) filters.direction = filters.direction === 'asc' ? 'desc' : 'asc';
else {
filters.sort = key;
filters.direction = 'asc';
}
}
async function login() {
try {
error.value = '';
const result = await api.post('/api/auth/login', loginForm);
token.value = result.token;
localStorage.setItem('poshmanager.token', result.token);
syncUser(result.user);
await refreshAll();
} catch (err) {
error.value = err.message;
}
}
function logout() {
token.value = '';
user.value = null;
localStorage.removeItem('poshmanager.token');
localStorage.removeItem('poshmanager.user');
}
function toggleNav() {
navCollapsed.value = !navCollapsed.value;
localStorage.setItem('poshmanager.navCollapsed', String(navCollapsed.value));
requestAnimationFrame(() => editor?.layout());
}
function setEditorMount(el) {
if (!el) {
editorMount.value = null;
disposeEditor();
return;
}
if (editorMount.value && editorMount.value !== el) {
disposeEditor();
}
editorMount.value = el;
if (view.value === 'scripts') void ensureEditorReady();
}
async function safeRefresh(label, promise, fallback, failures, quiet = false) {
try {
return await promise;
} catch (err) {
console.warn(`POSHManager refresh failed for ${label}`, err);
if (!quiet) failures.push(normalizeRefreshFailure(label, err));
return typeof fallback === 'function' ? fallback() : fallback;
}
}
function normalizeRefreshFailure(label, err) {
const message = err?.message || 'request failed';
return {
label,
message,
path: err?.path || '',
status: err?.status || null,
network: !err?.status
};
}
function formatRefreshFailures(failures) {
const visibleFailures = failures.filter(Boolean);
if (!visibleFailures.length) return '';
const networkFailures = visibleFailures.filter((failure) => failure.network);
if (networkFailures.length === visibleFailures.length) {
const first = networkFailures[0];
return `API is unreachable (${first.message}). Using last known data where available.`;
}
return `Some data did not refresh: ${visibleFailures.slice(0, 2).map((failure) => `${failure.label}: ${failure.message}`).join('; ')}`;
}
async function refreshAll() {
// Bootstrap all API-backed view state together so navigation does not trigger partial reload races.
try {
await api.get('/api/health');
} catch (err) {
console.warn('POSHManager refresh skipped because the API health probe failed', err);
notify(`API is unreachable (${err.message || 'health probe failed'}). Using last known data where available.`);
return;
}
const refreshFailures = [];
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, jobRows] = await Promise.all([
safeRefresh('session', api.get('/api/auth/me'), () => ({ user: user.value }), refreshFailures),
safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true),
safeRefresh('groups', api.get('/api/groups'), () => groups.value, refreshFailures),
safeRefresh('credentials', api.get('/api/credentials'), () => credentials.value, refreshFailures),
safeRefresh('hosts', api.get('/api/hosts'), () => hosts.value, refreshFailures),
safeRefresh('host groups', api.get('/api/hosts/groups'), () => hostGroups.value, refreshFailures),
safeRefresh('folders', api.get('/api/folders'), () => folders.value, refreshFailures),
safeRefresh('scripts', api.get('/api/scripts'), () => scripts.value, refreshFailures),
safeRefresh('asset folders', api.get('/api/assets/folders'), () => assetFolders.value, refreshFailures),
safeRefresh('assets', api.get('/api/assets'), () => assets.value, refreshFailures),
safeRefresh('variables', api.get('/api/variables/catalog'), () => variableCatalog.value, refreshFailures),
safeRefresh('PSADT catalog', api.get('/api/psadt/catalog'), () => psadtCatalog.value, refreshFailures),
safeRefresh('PSADT profiles', api.get('/api/psadt/profiles'), () => psadtProfiles.value, refreshFailures),
safeRefresh('Intune deployments', api.get('/api/psadt/intune/deployments'), () => psadtIntuneDeployments.value, refreshFailures),
safeRefresh('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
safeRefresh('vCenter connections', api.get('/api/hosts/import/vcenter/connections'), () => vcenterConnections.value, refreshFailures),
safeRefresh('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures),
safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures)
]);
syncUser(meResult.user);
summary.value = boot.summary;
settings.value = boot.settings;
Object.keys(settingsDraft).forEach((key) => delete settingsDraft[key]);
Object.entries(settings.value).forEach(([key, row]) => { settingsDraft[key] = { ...row }; });
users.value = userRows;
groups.value = groupRows;
credentials.value = credentialRows;
hosts.value = hostRows;
hostGroups.value = hostGroupRows;
folders.value = folderRows;
scripts.value = scriptRows;
assetFolders.value = assetFolderRows;
assets.value = assetRows;
variableCatalog.value = variableRows;
psadtCatalog.value = psadtCatalogRows;
psadtProfiles.value = psadtProfileRows;
psadtIntuneDeployments.value = psadtIntuneRows;
graphConnections.value = graphRows;
vcenterConnections.value = vcenterRows;
runplans.value = runplanRows;
jobs.value = jobRows;
if (selectedScript.value) {
const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id);
if (refreshedSelection) selectScript(refreshedSelection);
} else if (scriptRows.length) {
selectScript(scriptRows[0]);
}
const refreshMessage = formatRefreshFailures(refreshFailures);
if (refreshMessage) notify(refreshMessage);
}
async function openHelpCenter() {
helpOpen.value = true;
if (!helpArticles.value.length) await searchHelp(helpQuery.value);
}
async function searchHelp(query = '', category = '') {
helpLoading.value = true;
helpQuery.value = query;
try {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (category) params.set('category', category);
const result = await api.get(`/api/help${params.toString() ? `?${params}` : ''}`);
helpArticles.value = result.articles || [];
helpCategories.value = result.categories || [];
if (!selectedHelpArticle.value && helpArticles.value.length) {
await selectHelpArticle(helpArticles.value[0].id);
} else if (selectedHelpArticle.value && !helpArticles.value.some((article) => article.id === selectedHelpArticle.value.id)) {
selectedHelpArticle.value = null;
if (helpArticles.value.length) await selectHelpArticle(helpArticles.value[0].id);
}
} finally {
helpLoading.value = false;
}
}
async function selectHelpArticle(articleId) {
helpLoading.value = true;
try {
selectedHelpArticle.value = await api.get(`/api/help/${articleId}`);
} finally {
helpLoading.value = false;
}
}
function syncUser(nextUser) {
user.value = nextUser;
if (nextUser) localStorage.setItem('poshmanager.user', JSON.stringify(nextUser));
hydrateProfileForm();
}
function hydrateProfileForm() {
if (!user.value) return;
Object.assign(profileForm, {
displayName: user.value.displayName || '',
email: user.value.email || '',
jobTitle: user.value.jobTitle || '',
phone: user.value.phone || '',
timezone: user.value.timezone || '',
avatarUrl: user.value.avatarUrl || ''
});
}
function selectScript(script) {
selectedScript.value = script;
Object.assign(scriptDraft, { ...script });
activeFolderId.value = script.folderId;
if (editor) editor.setValue(script.content || '');
else if (view.value === 'scripts') void ensureEditorReady();
}
function newScript(folderId = null) {
selectedScript.value = null;
Object.assign(scriptDraft, {
id: null,
folderId,
name: 'Untitled Script.ps1',
description: '',
content: 'Write-Output "Hello from POSHManager"',
visibility: 'personal',
groupId: null
});
editor?.setValue(scriptDraft.content);
contextMenu.open = false;
}
async function saveScript() {
// Monaco is the source of truth for editor content; sync it before sending the API payload.
scriptDraft.content = editor?.getValue() ?? scriptDraft.content;
const payload = { ...scriptDraft };
const saved = scriptDraft.id
? await api.put(`/api/scripts/${scriptDraft.id}`, payload)
: await api.post('/api/scripts', payload);
await refreshAll();
selectScript(saved);
notify('Script saved');
}
async function deleteScript(script) {
if (!script) return;
closeContext();
scriptActionTarget.value = script;
scriptActionUsage.value = await fetchScriptUsage(script);
scriptDeleteModalOpen.value = true;
}
async function confirmDeleteScript() {
if (!scriptActionTarget.value) return;
await api.delete(`/api/scripts/${scriptActionTarget.value.id}?force=true`);
selectedScript.value = null;
await refreshAll();
closeScriptActionModals();
notify('Script deleted');
}
async function openRenameScript(script) {
if (!script) return;
closeContext();
scriptActionTarget.value = script;
scriptRenameForm.name = script.name;
scriptActionUsage.value = await fetchScriptUsage(script);
scriptRenameModalOpen.value = true;
}
async function renameScript() {
if (!scriptActionTarget.value) return;
const name = scriptRenameForm.name.trim();
if (!name) return;
const saved = await api.put(`/api/scripts/${scriptActionTarget.value.id}`, { ...scriptActionTarget.value, name });
await refreshAll();
selectScript(saved);
closeScriptActionModals();
notify('Script renamed');
}
function openCloneScript(script) {
if (!script) return;
closeContext();
scriptActionTarget.value = script;
scriptActionUsage.value = null;
scriptCloneForm.name = cloneScriptName(script.name);
scriptCloneModalOpen.value = true;
}
async function cloneScript() {
if (!scriptActionTarget.value) return;
const name = scriptCloneForm.name.trim();
if (!name) return;
const saved = await api.post(`/api/scripts/${scriptActionTarget.value.id}/clone`, { name });
await refreshAll();
selectScript(saved);
closeScriptActionModals();
notify('Script cloned');
}
async function fetchScriptUsage(script) {
return api.get(`/api/scripts/${script.id}/usage`).catch(() => ({ runplans: [], intuneDeployments: [], total: 0 }));
}
function closeScriptActionModals() {
scriptRenameModalOpen.value = false;
scriptCloneModalOpen.value = false;
scriptDeleteModalOpen.value = false;
scriptActionTarget.value = null;
scriptActionUsage.value = null;
Object.assign(scriptRenameForm, { name: '' });
Object.assign(scriptCloneForm, { name: '' });
}
function cloneScriptName(name = 'Untitled Script.ps1') {
const match = String(name).match(/^(.*?)(\.[^.]+)?$/);
return `${match?.[1] || name} copy${match?.[2] || ''}`;
}
function openScriptTestRun(script) {
if (!script) return;
closeContext();
scriptTestTarget.value = script;
scriptTestJob.value = null;
scriptTestRunning.value = false;
scriptTestError.value = '';
scriptTestPollFailures = 0;
scriptTestModalOpen.value = true;
}
function closeScriptTestRun() {
scriptTestModalOpen.value = false;
scriptTestTarget.value = null;
scriptTestJob.value = null;
scriptTestRunning.value = false;
scriptTestError.value = '';
scriptTestPollFailures = 0;
stopScriptTestPolling();
}
async function executeScriptTestRun(payload) {
if (!scriptTestTarget.value) return;
scriptTestRunning.value = true;
scriptTestJob.value = null;
scriptTestError.value = '';
scriptTestPollFailures = 0;
stopScriptTestPolling();
try {
const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload);
scriptTestJob.value = await api.get(`/api/jobs/${job.id}`).catch(() => job);
startScriptTestPolling(job.id);
notify('Script test run started');
} catch (err) {
scriptTestRunning.value = false;
scriptTestError.value = err.message;
scriptTestJob.value = {
id: 'not-started',
scriptName: scriptTestTarget.value?.name,
status: 'failed',
logs: [{ stream: 'stderr', line: err.message, createdAt: new Date().toISOString() }]
};
notify(err.message);
}
}
function startScriptTestPolling(jobId) {
stopScriptTestPolling();
scriptTestPollTimer = window.setInterval(async () => {
try {
const job = await api.get(`/api/jobs/${jobId}`);
scriptTestPollFailures = 0;
scriptTestError.value = '';
scriptTestJob.value = job;
if (!['queued', 'running'].includes(job.status)) {
scriptTestRunning.value = false;
stopScriptTestPolling();
}
} catch (err) {
scriptTestPollFailures += 1;
scriptTestError.value = `Could not refresh test output (${scriptTestPollFailures}/3): ${err.message}`;
if (scriptTestPollFailures >= 3) {
scriptTestRunning.value = false;
stopScriptTestPolling();
}
}
}, 1000);
}
function stopScriptTestPolling() {
if (scriptTestPollTimer) window.clearInterval(scriptTestPollTimer);
scriptTestPollTimer = null;
}
async function createFolder(parentId = null) {
const name = window.prompt('Folder name');
if (!name) return;
await api.post('/api/folders', { name, parentId, visibility: 'personal' });
await refreshAll();
closeContext();
}
async function deleteFolder(folder) {
if (!window.confirm(`Delete folder "${folder.name}" and its subfolders? Scripts inside the folder will move to Unfiled.`)) return;
await api.delete(`/api/folders/${folder.id}`);
await refreshAll();
closeContext();
}
async function refreshAssets() {
const [folderRows, assetRows] = await Promise.all([
api.get('/api/assets/folders'),
api.get('/api/assets')
]);
assetFolders.value = folderRows;
assets.value = assetRows;
notify('Asset library refreshed');
}
async function saveAssetFolder(payload) {
const body = { ...payload };
const folderId = body.id;
delete body.id;
if (folderId) await api.put(`/api/assets/folders/${folderId}`, body);
else await api.post('/api/assets/folders', body);
await refreshAssets();
notify('Asset folder saved');
}
async function deleteAssetFolder(folder) {
if (!window.confirm(`Delete asset folder "${folder.name}"? Assets in this folder will become unfiled.`)) return;
await api.delete(`/api/assets/folders/${folder.id}`);
await refreshAssets();
notify('Asset folder deleted');
}
async function uploadAsset(payload) {
const body = new FormData();
body.append('file', payload.file);
for (const [key, value] of Object.entries(payload)) {
if (key === 'file') continue;
if (value != null) body.append(key, String(value));
}
await api.postForm('/api/assets', body);
await refreshAssets();
notify('Asset uploaded');
}
async function deleteAsset(asset) {
if (!window.confirm(`Delete asset "${asset.name}"?`)) return;
await api.delete(`/api/assets/${asset.id}`);
await refreshAssets();
notify('Asset deleted');
}
async function linkAsset(payload) {
const body = { ...payload };
const assetId = body.assetId;
delete body.assetId;
await api.post(`/api/assets/${assetId}/links`, body);
notify('Asset link created');
}
async function insertAssetReference(asset) {
view.value = 'scripts';
const packagePath = asset.packagePath || asset.originalName;
await insertVariable(`\n# POSHManager asset: ${asset.name}\n$AssetReference = ${JSON.stringify(asset.referenceToken)}\n$AssetPackagePath = ${JSON.stringify(packagePath)}\n`);
notify('Asset reference inserted');
}
async function openVariableStudio() {
variableCatalog.value = await api.get('/api/variables/catalog');
variableModalOpen.value = true;
}
async function insertVariable(value) {
await ensureEditorReady();
const text = String(value || '');
if (!editor) {
scriptDraft.content = `${scriptDraft.content || ''}${text}`;
return;
}
const selection = editor.getSelection();
editor.executeEdits('variable-studio', [{ range: selection, text, forceMoveMarkers: true }]);
editor.focus();
}
async function saveCustomVariable(payload) {
const body = { ...payload };
const id = body.id;
delete body.id;
if (id) await api.put(`/api/variables/custom/${id}`, body);
else await api.post('/api/variables/custom', body);
variableCatalog.value = await api.get('/api/variables/catalog');
notify('Variable saved');
}
async function deleteCustomVariable(id) {
await api.delete(`/api/variables/custom/${id}`);
variableCatalog.value = await api.get('/api/variables/catalog');
notify('Variable deleted');
}
async function refreshPsadt() {
const [catalogRows, profileRows, intuneRows, graphRows, applicationRows] = await Promise.all([
api.get('/api/psadt/catalog'),
api.get('/api/psadt/profiles'),
api.get('/api/psadt/intune/deployments'),
api.get('/api/graph/connections'),
api.get('/api/intune/applications')
]);
psadtCatalog.value = catalogRows;
psadtProfiles.value = profileRows;
psadtIntuneDeployments.value = intuneRows;
graphConnections.value = graphRows;
catalogApplications.value = applicationRows;
await refreshGovernance(true);
builderStatus.value = await api.get('/api/psadt/intune/builder/status').catch(() => null);
notify('PSADT Workbench refreshed');
}
async function analyzeInstaller(payload) {
try {
installerAnalysis.value = await api.post('/api/packaging/analyze', payload);
notify(installerAnalysis.value.primary ? `Detected ${installerAnalysis.value.primary.label}` : (installerAnalysis.value.message || 'No match'));
} catch (err) {
notify(err.message, 'error');
}
}
async function buildIntunewin(payload) {
if (!payload.setupFile) return notify('Enter the setup file to build', 'error');
try {
notify('Building .intunewin package...');
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/build`, { setupFile: payload.setupFile });
await Promise.all([refreshPsadt(), refreshAssets?.()]);
notify(`Built ${result.asset.originalName}`);
} catch (err) {
notify(err.message, 'error');
}
}
async function refreshGovernance(silent = false) {
const [requests, overview] = await Promise.all([
api.get('/api/intune/change-requests'),
api.get('/api/intune/reporting/overview')
]);
changeRequests.value = requests;
reportingOverview.value = overview;
if (!silent) notify('Governance refreshed');
}
async function approveChangeRequest(id) {
try {
await api.post(`/api/intune/change-requests/${id}/approve`, {});
await refreshGovernance(true);
notify('Change request approved');
} catch (err) {
notify(err.message, 'error');
}
}
async function rejectChangeRequest(id) {
try {
await api.post(`/api/intune/change-requests/${id}/reject`, {});
await refreshGovernance(true);
notify('Change request rejected');
} catch (err) {
notify(err.message, 'error');
}
}
async function saveApplication(payload) {
const body = { ...payload };
const appId = body.id;
delete body.id;
if (appId) await api.put(`/api/intune/applications/${appId}`, body);
else await api.post('/api/intune/applications', body);
catalogApplications.value = await api.get('/api/intune/applications');
notify('Application saved');
}
async function deleteApplication(id) {
await api.delete(`/api/intune/applications/${id}`);
catalogApplications.value = await api.get('/api/intune/applications');
notify('Application deleted');
}
async function checkApplicationUpdate(id) {
try {
const result = await api.post(`/api/intune/applications/${id}/check-update`, {});
catalogApplications.value = await api.get('/api/intune/applications');
notify(result.application.updateState?.updateAvailable ? `Update available: ${result.application.latestKnownVersion}` : 'Up to date');
} catch (err) {
notify(err.message, 'error');
}
}
async function importApplication(payload) {
try {
await api.post('/api/intune/applications/import', payload);
catalogApplications.value = await api.get('/api/intune/applications');
notify('Application imported from tenant');
} catch (err) {
notify(err.message, 'error');
}
}
async function savePsadtProfile(payload) {
const body = { ...payload };
const profileId = body.id;
delete body.id;
if (profileId) await api.put(`/api/psadt/profiles/${profileId}`, body);
else await api.post('/api/psadt/profiles', body);
await refreshPsadt();
notify('PSADT profile saved');
}
async function deletePsadtProfile(id) {
await api.delete(`/api/psadt/profiles/${id}`);
await refreshPsadt();
notify('PSADT profile deleted');
}
async function previewPsadtProfile(id) {
const rendered = await api.get(`/api/psadt/profiles/${id}/render`);
view.value = 'scripts';
await nextTick();
newScript(activeFolderId.value);
Object.assign(scriptDraft, {
name: rendered.scriptName,
description: `Generated from PSADT profile ${rendered.profile.name}.`,
content: rendered.script,
visibility: rendered.profile.visibility,
groupId: rendered.profile.groupId || null
});
editor?.setValue(rendered.script);
notify('PSADT profile rendered into the editor');
}
async function createScriptFromPsadtProfile(id) {
const rendered = await api.get(`/api/psadt/profiles/${id}/render`);
const saved = await api.post('/api/scripts', {
folderId: activeFolderId.value,
name: rendered.scriptName,
description: `Generated from PSADT profile ${rendered.profile.name}. Command: ${rendered.intuneInstallCommand}`,
content: rendered.script,
visibility: rendered.profile.visibility,
groupId: rendered.profile.groupId || null
});
await refreshAll();
selectScript(saved);
view.value = 'scripts';
notify('PSADT script created');
}
async function insertPsadtSnippet(body) {
view.value = 'scripts';
await insertVariable(`\n${body}\n`);
notify('PSADT snippet inserted');
}
async function validatePsadtTarget(payload) {
psadtValidationResult.value = await api.post('/api/psadt/validate', payload);
notify(`PSADT verifier completed: score ${psadtValidationResult.value.score}`);
}
async function planPsadtMigration(payload) {
psadtMigrationPlan.value = await api.post('/api/psadt/migration/plan', payload);
notify(`Migration plan ready: ${psadtMigrationPlan.value.summary.steps} steps`);
}
async function applyPsadtMigration(payload) {
psadtMigrationPlan.value = await api.post('/api/psadt/migration/apply', { ...payload, createScript: true });
if (psadtMigrationPlan.value.migratedScript) {
await refreshAll();
selectScript(psadtMigrationPlan.value.migratedScript);
}
notify('Migrated PSADT script copy created');
}
async function saveIntuneDeployment(payload) {
const body = { ...payload };
const deploymentId = body.id;
delete body.id;
if (deploymentId) await api.put(`/api/psadt/intune/deployments/${deploymentId}`, body);
else await api.post('/api/psadt/intune/deployments', body);
await refreshPsadt();
notify('Intune deployment plan saved');
}
async function deleteIntuneDeployment(id) {
await api.delete(`/api/psadt/intune/deployments/${id}`);
await refreshPsadt();
notify('Intune deployment plan deleted');
}
async function saveGraphConnection(payload) {
const body = { ...payload };
const connectionId = body.id;
delete body.id;
if (connectionId) await api.put(`/api/graph/connections/${connectionId}`, body);
else await api.post('/api/graph/connections', body);
graphConnections.value = await api.get('/api/graph/connections');
notify('Graph environment saved');
}
async function deleteGraphConnection(id) {
await api.delete(`/api/graph/connections/${id}`);
graphConnections.value = await api.get('/api/graph/connections');
notify('Graph environment deleted');
}
async function testGraphConnection(id) {
const result = await api.post(`/api/graph/connections/${id}/test`, {});
graphConnections.value = await api.get('/api/graph/connections');
notify(result.message || 'Graph connection test completed');
}
async function saveVCenterConnection(payload) {
const body = { ...payload };
const connectionId = body.id;
delete body.id;
if (connectionId) await api.put(`/api/hosts/import/vcenter/connections/${connectionId}`, body);
else await api.post('/api/hosts/import/vcenter/connections', body);
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify('vCenter system saved');
}
async function deleteVCenterConnection(id) {
await api.delete(`/api/hosts/import/vcenter/connections/${id}`);
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify('vCenter system deleted');
}
async function testVCenterConnection(id) {
const result = await api.post(`/api/hosts/import/vcenter/connections/${id}/test`, {});
vcenterConnections.value = await api.get('/api/hosts/import/vcenter/connections');
notify(result.message || 'vCenter connection test completed');
}
async function linkGraphApp(payload) {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/link`, {
connectionId: payload.connectionId,
graphAppId: payload.graphAppId
});
await refreshPsadt();
notify(`Linked ${result.name} to Intune app ${result.graphAppId}`);
}
async function syncGraphStatus(payload) {
graphStatusResult.value = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/sync`, {
connectionId: payload.connectionId,
graphAppId: payload.graphAppId,
mode: payload.mode || 'reports'
});
await refreshPsadt();
notify(`Graph sync completed: ${graphStatusResult.value.summary.total} rows`);
}
async function loadGraphStatus(deploymentId) {
graphStatusResult.value = await api.get(`/api/psadt/intune/deployments/${deploymentId}/graph/status`);
notify(`Loaded ${graphStatusResult.value.summary.total} Intune status rows`);
}
async function loadGraphAudit(deploymentId) {
graphAuditResult.value = await api.get(`/api/psadt/intune/deployments/${deploymentId}/graph/audit`);
notify(`Loaded ${graphAuditResult.value.length} audit entr${graphAuditResult.value.length === 1 ? 'y' : 'ies'}`);
}
function notifyWarnings(warnings) {
if (warnings?.length) notify(`Note: ${warnings[0]}${warnings.length > 1 ? ` (+${warnings.length - 1} more)` : ''}`);
}
function handledApproval(result, deploymentId) {
if (result?.status === 'approval-required') {
notify('Approval required — a change request was created for an admin to approve.');
refreshGovernance(true);
loadGraphAudit(deploymentId);
return true;
}
return false;
}
async function publishGraphApp(payload) {
try {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/publish`, { connectionId: payload.connectionId });
if (handledApproval(result, payload.deploymentId)) return;
notify(`Published to Intune app ${result.graphAppId}`);
notifyWarnings(result.warnings);
await refreshPsadt();
await loadGraphAudit(payload.deploymentId);
} catch (err) {
notify(err.message, 'error');
}
}
async function uploadGraphContent(payload) {
if (!payload.assetId) return notify('Choose a .intunewin content asset first', 'error');
try {
notify('Uploading .intunewin content to Intune...');
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/upload-content`, { connectionId: payload.connectionId, assetId: payload.assetId });
if (handledApproval(result, payload.deploymentId)) return;
notify(`Committed content version ${result.content.contentVersionId}`);
await refreshPsadt();
await loadGraphAudit(payload.deploymentId);
} catch (err) {
notify(err.message, 'error');
}
}
async function assignGraphApp(payload) {
try {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/assign`, { connectionId: payload.connectionId });
if (handledApproval(result, payload.deploymentId)) return;
notify(`Assigned ${result.assigned} target(s)`);
notifyWarnings(result.warnings);
await loadGraphAudit(payload.deploymentId);
} catch (err) {
notify(err.message, 'error');
}
}
async function checkDrift(payload) {
try {
driftResult.value = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/drift`, { connectionId: payload.connectionId });
notify(driftResult.value.inSync ? 'In sync with tenant' : 'Drift detected against tenant');
} catch (err) {
notify(err.message, 'error');
}
}
async function reconcileGraphApp(payload) {
try {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/reconcile`, { connectionId: payload.connectionId });
if (handledApproval(result, payload.deploymentId)) return;
notify('Re-applied plan to tenant');
driftResult.value = null;
await loadGraphAudit(payload.deploymentId);
} catch (err) {
notify(err.message, 'error');
}
}
async function createDeploymentVersion(deploymentId) {
try {
const created = await api.post(`/api/psadt/intune/deployments/${deploymentId}/new-version`, {});
await refreshPsadt();
notify(`Created ${created.name}`);
} catch (err) {
notify(err.message, 'error');
}
}
async function setGraphRelationships(payload) {
try {
const result = await api.post(`/api/psadt/intune/deployments/${payload.deploymentId}/graph/relationships`, { connectionId: payload.connectionId });
if (handledApproval(result, payload.deploymentId)) return;
notify(`Set ${result.relationships} relationship(s)`);
await loadGraphAudit(payload.deploymentId);
} catch (err) {
notify(err.message, 'error');
}
}
async function saveHost() {
const payload = { ...hostForm, tags: hostForm.tagsText.split(',').map((tag) => tag.trim()).filter(Boolean) };
if (hostForm.id) await api.put(`/api/hosts/${hostForm.id}`, payload);
else await api.post('/api/hosts', payload);
resetHostForm();
hostModalOpen.value = false;
await refreshAll();
notify('Host saved');
}
async function saveHostGroup(payload) {
const body = { ...payload };
const groupId = body.id;
delete body.id;
if (groupId) await api.put(`/api/hosts/groups/${groupId}`, body);
else await api.post('/api/hosts/groups', body);
hostGroups.value = await api.get('/api/hosts/groups');
notify('Host group saved');
}
async function deleteHostGroup(groupId) {
await api.delete(`/api/hosts/groups/${groupId}`);
hostGroups.value = await api.get('/api/hosts/groups');
await refreshAll();
notify('Host group deleted');
}
async function syncHostGroup(groupId) {
await api.post(`/api/hosts/groups/${groupId}/sync`, {});
hostGroups.value = await api.get('/api/hosts/groups');
notify('Host group synced');
}
async function syncHostGroups() {
const result = await api.post('/api/hosts/groups/sync', {});
hostGroups.value = await api.get('/api/hosts/groups');
notify(`Dynamic host groups synced: ${result.synced || 0}`);
}
async function openVCenterImport() {
vcenterImportOpen.value = true;
vcenterImportError.value = '';
vcenterImportDiagnostics.value = null;
vcenterTraceEntries.value = [];
vcenterTraceOpen.value = false;
try {
vcenterImportStatus.value = await api.get('/api/hosts/import/vcenter/status');
vcenterConnections.value = vcenterImportStatus.value.connections || vcenterConnections.value;
} catch (err) {
vcenterImportError.value = err.message;
}
}
async function previewVCenterImport(payload) {
vcenterImportLoading.value = true;
vcenterImportError.value = '';
vcenterTraceEntries.value = [{
info: true,
message: 'vCenter preview request is running.',
details: {
filter: payload.filter,
limit: payload.limit,
startedAt: new Date().toISOString()
}
}];
vcenterTraceOpen.value = true;
const watchdogs = [
setTimeout(() => {
vcenterTraceEntries.value = [
...vcenterTraceEntries.value,
{
info: true,
message: 'Still waiting for the POSHManager API preview response.',
details: {
route: '/api/hosts/import/vcenter/preview',
elapsedMs: 8000,
note: 'If this remains here, the backend is still waiting on vCenter DNS, TCP, TLS, authentication, or VM inventory response.'
}
}
];
}, 8000),
setTimeout(() => {
vcenterTraceEntries.value = [
...vcenterTraceEntries.value,
{
info: true,
message: 'The preview request is still pending.',
details: {
route: '/api/hosts/import/vcenter/preview',
elapsedMs: 18000,
note: 'The backend vCenter request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.'
}
}
];
}, 18000)
];
try {
const result = await api.post('/api/hosts/import/vcenter/preview', payload);
vcenterImportStatus.value = result.status;
vcenterImportDiagnostics.value = result.diagnostics || null;
vcenterTraceEntries.value = result.trace?.length ? result.trace : [{
info: true,
message: 'Preview completed, but the API returned no trace entries.',
details: { count: result.count || 0, diagnostics: result.diagnostics || null }
}];
vcenterPreviewRows.value = result.candidates || [];
vcenterTraceOpen.value = true;
notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
} catch (err) {
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
info: true,
message: 'Preview failed before a vCenter HTTP trace could be captured.',
details: { error: err.message, status: err.status || null }
}];
vcenterTraceOpen.value = true;
vcenterImportError.value = err.message;
notify(err.message, 'error');
} finally {
watchdogs.forEach((timer) => clearTimeout(timer));
vcenterImportLoading.value = false;
}
}
async function importVCenterHosts(payload) {
vcenterImportLoading.value = true;
vcenterImportError.value = '';
vcenterTraceEntries.value = [{
info: true,
message: 'vCenter import request is running.',
details: { vmIds: payload.vmIds, startedAt: new Date().toISOString() }
}];
vcenterTraceOpen.value = true;
try {
const result = await api.post('/api/hosts/import/vcenter', payload);
vcenterTraceEntries.value = result.trace?.length ? result.trace : vcenterTraceEntries.value;
await refreshAll();
vcenterPreviewRows.value = [];
vcenterImportDiagnostics.value = null;
vcenterTraceOpen.value = false;
vcenterImportOpen.value = false;
const skipped = result.skipped?.length || 0;
notify(`Imported ${result.imported?.length || 0} host(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}`);
} catch (err) {
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
info: true,
message: 'Import failed before a vCenter HTTP trace could be captured.',
details: { error: err.message, status: err.status || null }
}];
vcenterTraceOpen.value = true;
vcenterImportError.value = err.message;
notify(err.message, 'error');
} finally {
vcenterImportLoading.value = false;
}
}
function resetHostForm() {
Object.assign(hostForm, { id: null, name: '', address: '', fqdn: '', osFamily: 'windows', transport: 'winrm', credentialId: null, tagsText: '', visibility: 'shared', groupId: null, sourceType: 'manual', sourceConnectionId: null, sourceRef: '' });
}
function openHostModal(host = null) {
if (host) Object.assign(hostForm, { ...host, tagsText: host.tags?.join(', ') || '' });
else resetHostForm();
hostModalOpen.value = true;
}
async function saveCredential() {
const payload = { ...credentialForm };
if (credentialForm.id && !payload.secret) delete payload.secret;
if (credentialForm.id) await api.put(`/api/credentials/${credentialForm.id}`, payload);
else await api.post('/api/credentials', payload);
resetCredentialForm();
credentialModalOpen.value = false;
await refreshAll();
notify('Credential encrypted and stored');
}
function resetCredentialForm() {
Object.assign(credentialForm, { id: null, name: '', kind: 'username_password', username: '', secret: '', visibility: 'personal', groupId: null });
}
function openCredentialModal(credential = null) {
if (credential) Object.assign(credentialForm, { ...credential, secret: '', groupId: credential.groupId || null });
else resetCredentialForm();
credentialModalOpen.value = true;
}
async function saveRunPlan() {
const payload = { ...runPlanForm };
if (runPlanForm.id) await api.put(`/api/runplans/${runPlanForm.id}`, payload);
else await api.post('/api/runplans', payload);
resetRunPlanForm();
runPlanModalOpen.value = false;
await refreshAll();
notify('RunPlan saved');
}
function resetRunPlanForm() {
Object.assign(runPlanForm, { id: null, name: '', description: '', scriptId: '', visibility: 'personal', groupId: null, parallel: true, hostIds: [], hostGroupIds: [] });
}
function openRunPlanModal(runplan = null) {
if (runplan) Object.assign(runPlanForm, { ...runplan, hostIds: runplan.hostIds || [], hostGroupIds: runplan.hostGroupIds || [] });
else resetRunPlanForm();
runPlanModalOpen.value = true;
}
async function executeRunPlan(id) {
const job = await api.post(`/api/runplans/${id}/execute`, {});
await refreshAll();
await openJob(job.id);
view.value = 'logs';
notify('RunPlan queued');
}
async function openJob(id) {
selectedJob.value = await api.get(`/api/jobs/${id}`);
view.value = 'logs';
logTab.value = 'job';
logFilters.page = 1;
}
async function loadSystemLogs() {
logTab.value = 'system';
logFilters.page = 1;
systemLogs.value = await api.get('/api/logs/system?limit=300');
}
async function saveSettings() {
const payload = {};
Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; });
settings.value = await api.put('/api/settings', payload);
notify('Settings saved');
}
async function saveGroup() {
await api.post('/api/groups', groupForm);
Object.assign(groupForm, { name: '', description: '' });
groupModalOpen.value = false;
await refreshAll();
notify('Group created');
}
async function saveUser() {
const payload = { ...userForm };
if (!payload.password) delete payload.password;
await api.post('/api/users', payload);
Object.assign(userForm, { email: '', displayName: '', password: '', role: 'user', groupIds: [] });
userModalOpen.value = false;
await refreshAll();
notify('User created');
}
async function saveProfile() {
try {
const result = await api.put('/api/auth/profile', profileForm);
syncUser(result.user);
notify('Profile updated');
} catch (err) {
notify(err.message);
}
}
async function changeTheme(theme) {
if (activeTheme.value === theme) return;
try {
const result = await api.put('/api/auth/theme', { theme });
syncUser(result.user);
notify('Theme updated');
} catch (err) {
notify(err.message);
}
}
async function changePassword() {
if (!isExternalAuth.value && passwordForm.newPassword !== passwordForm.confirmPassword) {
notify('New password confirmation does not match');
return;
}
try {
const result = await api.post('/api/auth/change-password', {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
});
if (result.external && result.redirectUrl) {
window.open(result.redirectUrl, '_blank', 'noopener,noreferrer');
notify('Opening Entra password flow');
return;
}
Object.assign(passwordForm, { currentPassword: '', newPassword: '', confirmPassword: '' });
notify('Password changed');
} catch (err) {
notify(err.message);
}
}
async function ensureEditorReady(attempt = 0) {
// Monaco needs a mounted, measurable container; retry briefly when switching into Scripts.
if (view.value !== 'scripts') return;
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
if (!editorMount.value) {
if (attempt < 4) setTimeout(() => void ensureEditorReady(attempt + 1), 60);
return;
}
const box = editorMount.value.getBoundingClientRect();
if ((box.width < 80 || box.height < 80) && attempt < 6) {
setTimeout(() => void ensureEditorReady(attempt + 1), 80);
return;
}
if (!editor) mountEditor();
if (editor) {
editor.setValue(scriptDraft.content || '');
editor.layout({ width: Math.floor(box.width), height: Math.floor(box.height) });
}
}
function disposeEditor() {
if (editor) {
scriptDraft.content = editor.getValue();
editor.dispose();
editor = null;
}
}
function mountEditor() {
if (!editorMount.value) return;
configurePowerShellMonaco(monaco, () => ({
variableCatalog: variableCatalog.value,
psadtCatalog: psadtCatalog.value,
assets: assets.value
}));
editor = monaco.editor.create(editorMount.value, {
value: scriptDraft.content || '',
language: 'powershell',
theme: 'posh-vscode-dark',
automaticLayout: true,
minimap: { enabled: true, side: 'right', renderCharacters: false, scale: 1 },
fontFamily: '"Cascadia Code", "Cascadia Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace',
fontLigatures: false,
fontSize: 13,
lineHeight: 21,
cursorBlinking: 'smooth',
cursorStyle: 'line',
cursorWidth: 2,
renderLineHighlight: 'all',
renderWhitespace: 'selection',
glyphMargin: true,
folding: true,
foldingHighlight: true,
links: true,
mouseWheelZoom: true,
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
acceptSuggestionOnEnter: 'on',
tabCompletion: 'on',
wordBasedSuggestions: 'matchingDocuments',
snippetSuggestions: 'top',
parameterHints: { enabled: true, cycle: true },
overviewRulerBorder: false,
hideCursorInOverviewRuler: true,
bracketPairColorization: { enabled: true },
guides: { bracketPairs: true, indentation: true },
roundedSelection: true,
scrollBeyondLastLine: false
});
editor.onDidChangeModelContent(() => {
scriptDraft.content = editor.getValue();
triggerPowerShellSuggest();
});
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space, () => {
editor.trigger('poshmanager', 'editor.action.triggerSuggest', {});
});
}
function triggerPowerShellSuggest() {
if (!editor) return;
const position = editor.getPosition();
const line = editor.getModel()?.getLineContent(position.lineNumber).slice(0, position.column - 1) || '';
if (!/(^|[\s;|])([A-Za-z]+-[A-Za-z]*|\$[A-Za-z_][\w]*|\{\{[A-Za-z_:.-]*)$/.test(line)) return;
window.clearTimeout(triggerPowerShellSuggest.timer);
triggerPowerShellSuggest.timer = window.setTimeout(() => {
if (view.value === 'scripts') editor?.trigger('poshmanager', 'editor.action.triggerSuggest', {});
}, 120);
}
function openContext(event, item, type) {
contextMenu.open = true;
contextMenu.x = event.clientX;
contextMenu.y = event.clientY;
contextMenu.item = item;
contextMenu.type = type;
}
function closeContext() {
contextMenu.open = false;
}
function contextTargetFolderId() {
if (contextMenu.type === 'folder') return contextMenu.item?.id || null;
if (contextMenu.type === 'script') return contextMenu.item?.folderId || activeFolderId.value || null;
return activeFolderId.value || null;
}
function notify(message) {
toast.value = message;
setTimeout(() => {
if (toast.value === message) toast.value = '';
}, 2400);
}
function loadDashboardLayout() {
try {
const stored = JSON.parse(localStorage.getItem('poshmanager.dashboardLayout') || '[]');
if (!Array.isArray(stored) || !stored.length) return defaultDashboardLayout.map((widget) => ({ ...widget }));
return normalizeGridLayout(stored, defaultDashboardLayout, dashboardWidgetCatalog);
} catch {
return defaultDashboardLayout.map((widget) => ({ ...widget }));
}
}
function saveDashboardLayout() {
localStorage.setItem('poshmanager.dashboardLayout', JSON.stringify(dashboardLayout));
}
function resetDashboardLayout() {
dashboardLayout.splice(0, dashboardLayout.length, ...defaultDashboardLayout.map((widget) => ({ ...widget })));
saveDashboardLayout();
notify('Dashboard layout reset');
}
function normalizeGridLayout(stored, defaults, catalog) {
// LocalStorage layouts are merged with the catalog so new widgets appear without breaking old users.
const catalogById = new Map(catalog.map((item) => [item.id, item]));
const defaultsById = new Map(defaults.map((item) => [item.id, item]));
const normalized = [];
const seen = new Set();
for (const item of stored) {
if (!catalogById.has(item.id) || seen.has(item.id)) continue;
const definition = catalogById.get(item.id);
const fallback = defaultsById.get(item.id) || {};
seen.add(item.id);
normalized.push({
id: item.id,
w: Math.min(12, Math.max(definition.minW || 2, Number(item.w || fallback.w || definition.defaultW || 3))),
h: Math.max(definition.minH || 1, Number(item.h || fallback.h || definition.defaultH || 2)),
visible: item.visible !== false,
collapsed: Boolean(item.collapsed)
});
}
for (const item of defaults) {
if (!seen.has(item.id)) normalized.push({ ...item });
}
return normalized;
}
function toggleWidgetCollapse(id) {
const widget = dashboardLayout.find((item) => item.id === id);
if (!widget) return;
widget.collapsed = !widget.collapsed;
saveDashboardLayout();
}
function resizeDashboardWidget(id, axis, delta) {
const widget = dashboardLayout.find((item) => item.id === id);
const definition = dashboardCatalogById.value.get(id) || {};
if (!widget) return;
const min = axis === 'w' ? definition.minW || 2 : definition.minH || 1;
const max = axis === 'w' ? 12 : 5;
widget[axis] = Math.min(max, Math.max(min, Number(widget[axis] || min) + delta));
saveDashboardLayout();
}
function setDashboardWidgetVisible(id, visible) {
const widget = dashboardLayout.find((item) => item.id === id);
if (!widget) return;
widget.visible = visible;
if (visible) dashboardWidgetLibraryOpen.value = false;
saveDashboardLayout();
}
function dragDashboardStart(id) {
if (!dashboardEditMode.value) return;
draggedDashboardWidgetId.value = id;
}
function dropDashboardOn(targetId) {
if (!dashboardEditMode.value || !draggedDashboardWidgetId.value || draggedDashboardWidgetId.value === targetId) return;
moveGridItem(dashboardLayout, draggedDashboardWidgetId.value, targetId);
draggedDashboardWidgetId.value = '';
saveDashboardLayout();
}
function moveGridItem(layout, sourceId, targetId) {
const sourceIndex = layout.findIndex((item) => item.id === sourceId);
const targetIndex = layout.findIndex((item) => item.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return;
const [item] = layout.splice(sourceIndex, 1);
layout.splice(targetIndex, 0, item);
}
function loadScriptLayout() {
try {
const stored = JSON.parse(localStorage.getItem('poshmanager.scriptLayout.v2') || '[]');
if (!Array.isArray(stored) || !stored.length) return defaultScriptLayout.map((panel) => ({ ...panel }));
return normalizeGridLayout(stored, defaultScriptLayout, scriptPanelCatalog);
} catch {
return defaultScriptLayout.map((panel) => ({ ...panel }));
}
}
function saveScriptLayout() {
localStorage.setItem('poshmanager.scriptLayout.v2', JSON.stringify(scriptLayout));
requestAnimationFrame(() => editor?.layout());
}
function resetScriptLayout() {
scriptLayout.splice(0, scriptLayout.length, ...defaultScriptLayout.map((panel) => ({ ...panel })));
saveScriptLayout();
notify('Script workspace layout reset');
}
function setScriptPanelVisible(id, visible) {
const panel = scriptLayout.find((item) => item.id === id);
if (!panel) return;
panel.visible = visible;
if (visible) panel.collapsed = false;
if (visible) scriptPanelLibraryOpen.value = false;
saveScriptLayout();
}
function isScriptPanelVisible(id) {
return scriptLayout.find((item) => item.id === id)?.visible !== false;
}
function loadPanelCollapsed() {
try {
return JSON.parse(localStorage.getItem('poshmanager.panelCollapsed') || '{}');
} catch {
return {};
}
}
function togglePanel(id) {
panelCollapsed[id] = !panelCollapsed[id];
localStorage.setItem('poshmanager.panelCollapsed', JSON.stringify(panelCollapsed));
}
function shortDate(value) {
return value ? new Date(value).toLocaleString() : '';
}
function settingLabel(key) {
return settingMetadata[key]?.label || key
.split('_')
.filter(Boolean)
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ');
}
function settingDescription(key) {
return settingMetadata[key]?.description || 'Application setting managed by the POSHManager API.';
}
function settingPlaceholder(key) {
return settingMetadata[key]?.placeholder || '';
}
function isSecretSetting(key) {
return Boolean(settingMetadata[key]?.secret || /(?:password|secret|token|key)$/i.test(key));
}
function settingSourceLabel(source) {
return source === 'env' ? 'ENV' : 'DB';
}
function configSectionMeta(section) {
const meta = {
Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' },
Authentication: { icon: ShieldCheck, description: 'Identity provider and password-flow configuration.' },
Integrations: { icon: Download, description: 'External systems used for inventory, publishing, and governance workflows.' },
Execution: { icon: PlayCircle, description: 'PowerShell execution guardrails.' },
Runtime: { icon: Database, description: 'Container runtime paths and API process settings.' },
Other: { icon: Settings, description: 'Additional app settings exposed by the API.' }
};
return meta[section] || meta.Other;
}
function loadCollapsedConfigSections() {
try {
return JSON.parse(localStorage.getItem('poshmanager.collapsedConfigSections') || '{}');
} catch {
return {};
}
}
function toggleConfigSection(section) {
collapsedConfigSections[section] = !collapsedConfigSections[section];
localStorage.setItem('poshmanager.collapsedConfigSections', JSON.stringify(collapsedConfigSections));
}
function normalizedBooleanSetting(value) {
return String(value).toLowerCase() === 'true';
}
function toggleBooleanSetting(row) {
row.value = normalizedBooleanSetting(row.value) ? 'false' : 'true';
}
function isBooleanSetting(value) {
return ['true', 'false'].includes(String(value).toLowerCase());
}
</script>