3175 lines
135 KiB
Vue
3175 lines
135 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-osFamily="{ row }"><span :class="['status-pill', row.osFamily || 'other']">{{ osFamilyLabel(row.osFamily) }}</span></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>Operating system type</span><select v-model="hostForm.osFamily"><option value="windows">Windows</option><option value="linux">Linux</option><option value="other">Other / unknown</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>
|
|
<TargetBrowser
|
|
v-model="runPlanForm.hostGroupIds"
|
|
:items="hostGroups"
|
|
kind="group"
|
|
eyebrow="HOST GROUP TARGETS"
|
|
title="Target host groups"
|
|
description="Select dynamic or manual collections. Dynamic groups use their latest synced membership when the RunPlan executes."
|
|
search-placeholder="Search groups, mode, rule text..."
|
|
/>
|
|
<TargetBrowser
|
|
v-model="runPlanForm.hostIds"
|
|
:items="hosts"
|
|
kind="host"
|
|
eyebrow="DIRECT HOST TARGETS"
|
|
title="Target hosts"
|
|
description="Select individual systems when this RunPlan needs exact one-off targets in addition to host groups."
|
|
search-placeholder="Search host, FQDN, address, tags..."
|
|
/>
|
|
<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>
|
|
|
|
<SystemJobsView
|
|
v-if="view === 'system-jobs'"
|
|
:jobs="systemJobs"
|
|
:actions="systemJobActions"
|
|
:loading="systemJobsLoading"
|
|
@refresh="refreshSystemJobs"
|
|
@run="runSystemJob"
|
|
@cancel="cancelSystemJob"
|
|
/>
|
|
|
|
<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 TargetBrowser from './components/ui/TargetBrowser.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';
|
|
import SystemJobsView from './views/SystemJobsView.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 systemJobs = ref([]);
|
|
const systemJobActions = ref([]);
|
|
const systemJobsLoading = ref(false);
|
|
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: 'system-jobs', label: 'System Jobs', icon: Database },
|
|
{ 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: 'osFamily', label: 'OS' },
|
|
{ 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.osFamily,
|
|
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 (user.value?.role === 'admin') await refreshSystemJobs(true);
|
|
else {
|
|
systemJobs.value = [];
|
|
systemJobActions.value = [];
|
|
}
|
|
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 {
|
|
if (!(await confirmLinuxCompatibility(`/api/scripts/${scriptTestTarget.value.id}/compatibility`, payload))) {
|
|
scriptTestRunning.value = false;
|
|
return;
|
|
}
|
|
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');
|
|
}
|
|
|
|
function osFamilyLabel(value) {
|
|
if (value === 'windows') return 'Windows';
|
|
if (value === 'linux') return 'Linux';
|
|
return 'Other';
|
|
}
|
|
|
|
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: 'VMware host 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 VMware DNS, TCP, TLS, authentication, or 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 VMware 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;
|
|
const standalone = result.diagnostics?.standaloneHost;
|
|
notify(standalone
|
|
? `Prepared ${result.count || 0} standalone ESXi host candidate(s)`
|
|
: `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 VMware API 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: 'VMware host 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 VMware API 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) {
|
|
if (!(await confirmLinuxCompatibility(`/api/runplans/${id}/compatibility`))) return;
|
|
const job = await api.post(`/api/runplans/${id}/execute`, {});
|
|
await refreshAll();
|
|
await openJob(job.id);
|
|
view.value = 'logs';
|
|
notify('RunPlan queued');
|
|
}
|
|
|
|
async function confirmLinuxCompatibility(path, payload = null) {
|
|
const result = payload ? await api.post(path, payload) : await api.get(path);
|
|
const warnings = (result.findings || []).filter((finding) => finding.level === 'warning');
|
|
if (!result.hasLinuxTargets || !warnings.length) return true;
|
|
const lines = warnings.slice(0, 5).map((finding) => `- ${finding.title}${finding.line ? ` line ${finding.line}` : ''}: ${finding.message}`);
|
|
return window.confirm([
|
|
'This script is targeting one or more Linux hosts and POSHManager found possible compatibility issues.',
|
|
'',
|
|
...lines,
|
|
'',
|
|
'Continue anyway? The same warnings will be written to the job logs.'
|
|
].join('\n'));
|
|
}
|
|
|
|
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 refreshSystemJobs(quiet = false) {
|
|
if (user.value?.role !== 'admin') return;
|
|
systemJobsLoading.value = true;
|
|
try {
|
|
const result = await api.get('/api/system-jobs');
|
|
systemJobs.value = result.jobs || [];
|
|
systemJobActions.value = result.actions || [];
|
|
} catch (err) {
|
|
if (!quiet) notify(err.message, 'error');
|
|
} finally {
|
|
systemJobsLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function runSystemJob(jobId) {
|
|
systemJobsLoading.value = true;
|
|
try {
|
|
const result = await api.post(`/api/system-jobs/${encodeURIComponent(jobId)}/run`, {});
|
|
notify(result.action?.message || 'System job completed');
|
|
await refreshSystemJobs(true);
|
|
} catch (err) {
|
|
notify(err.message, 'error');
|
|
await refreshSystemJobs(true);
|
|
} finally {
|
|
systemJobsLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function cancelSystemJob({ id, reason }) {
|
|
systemJobsLoading.value = true;
|
|
try {
|
|
const result = await api.post(`/api/system-jobs/${encodeURIComponent(id)}/cancel`, { reason });
|
|
notify(result.action?.message || 'Cancel requested');
|
|
jobs.value = await api.get('/api/jobs');
|
|
await refreshSystemJobs(true);
|
|
} catch (err) {
|
|
notify(err.message, 'error');
|
|
await refreshSystemJobs(true);
|
|
} finally {
|
|
systemJobsLoading.value = false;
|
|
}
|
|
}
|
|
|
|
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>
|