From 6c6514e70fa22115983d4ac8fa01b20eb85677a0 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 25 Jun 2026 12:54:29 -0500 Subject: [PATCH] Initial --- client/src/App.vue | 114 +++++++++++++----- .../components/scripts/ScriptTestRunModal.vue | 4 +- server/services/powershellRunner.js | 4 +- server/test/jobs.test.mjs | 10 ++ 4 files changed, 101 insertions(+), 31 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index ce14617..d400db4 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -216,6 +216,7 @@ :credentials="credentials" :job="scriptTestJob" :running="scriptTestRunning" + :error="scriptTestError" @close="closeScriptTestRun" @execute="executeScriptTestRun" /> @@ -952,6 +953,7 @@ 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); @@ -1113,6 +1115,7 @@ 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 }); @@ -1370,10 +1373,11 @@ const filteredRunPlans = computed(() => filterRows(runplans.value, runPlanFilter const runPlanPageCount = computed(() => pageCount(filteredRunPlans.value, runPlanFilters)); const pagedRunPlans = computed(() => pageRows(filteredRunPlans.value, runPlanFilters)); -watch(view, async () => { +watch(view, async (nextView, previousView) => { contextMenu.open = false; - if (view.value === 'scripts') await ensureEditorReady(); - if (view.value === 'profile') hydrateProfileForm(); + if (previousView === 'scripts' && nextView !== 'scripts') disposeEditor(); + if (nextView === 'scripts') await ensureEditorReady(); + if (nextView === 'profile') hydrateProfileForm(); }); watch(() => scriptDraft.content, (value) => { @@ -1452,7 +1456,7 @@ async function probeEntraLogin() { onBeforeUnmount(() => { window.removeEventListener('click', closeContext); stopScriptTestPolling(); - editor?.dispose(); + disposeEditor(); }); function filterRows(rows, filters, fields) { @@ -1520,32 +1524,49 @@ function toggleNav() { } function setEditorMount(el) { - if (el) { - editorMount.value = el; - if (view.value === 'scripts') void ensureEditorReady(); + 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(`${label}: ${err.message || 'request failed'}`); + return typeof fallback === 'function' ? fallback() : fallback; } } async function refreshAll() { // Bootstrap all API-backed view state together so navigation does not trigger partial reload races. + const refreshFailures = []; const [meResult, boot, userRows, groupRows, credentialRows, hostRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, runplanRows, jobRows] = await Promise.all([ api.get('/api/auth/me'), - api.get('/api/bootstrap'), - api.get('/api/users').catch(() => []), - api.get('/api/groups'), - api.get('/api/credentials'), - api.get('/api/hosts'), - api.get('/api/folders'), - api.get('/api/scripts'), - api.get('/api/assets/folders'), - api.get('/api/assets'), - api.get('/api/variables/catalog'), - api.get('/api/psadt/catalog'), - api.get('/api/psadt/profiles'), - api.get('/api/psadt/intune/deployments'), - api.get('/api/graph/connections'), - api.get('/api/runplans'), - api.get('/api/jobs') + 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('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('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures), + safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures) ]); syncUser(meResult.user); summary.value = boot.summary; @@ -1567,7 +1588,13 @@ async function refreshAll() { graphConnections.value = graphRows; runplans.value = runplanRows; jobs.value = jobRows; - if (!selectedScript.value && scriptRows.length) selectScript(scriptRows[0]); + if (selectedScript.value) { + const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id); + if (refreshedSelection) selectScript(refreshedSelection); + } else if (scriptRows.length) { + selectScript(scriptRows[0]); + } + if (refreshFailures.length) notify(`Some data did not refresh: ${refreshFailures.slice(0, 2).join('; ')}`); } async function openHelpCenter() { @@ -1740,6 +1767,8 @@ function openScriptTestRun(script) { scriptTestTarget.value = script; scriptTestJob.value = null; scriptTestRunning.value = false; + scriptTestError.value = ''; + scriptTestPollFailures = 0; scriptTestModalOpen.value = true; } @@ -1748,6 +1777,8 @@ function closeScriptTestRun() { scriptTestTarget.value = null; scriptTestJob.value = null; scriptTestRunning.value = false; + scriptTestError.value = ''; + scriptTestPollFailures = 0; stopScriptTestPolling(); } @@ -1755,6 +1786,8 @@ async function executeScriptTestRun(payload) { if (!scriptTestTarget.value) return; scriptTestRunning.value = true; scriptTestJob.value = null; + scriptTestError.value = ''; + scriptTestPollFailures = 0; stopScriptTestPolling(); try { const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload); @@ -1763,6 +1796,13 @@ async function executeScriptTestRun(payload) { 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); } } @@ -1772,14 +1812,20 @@ function startScriptTestPolling(jobId) { 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 { - 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); } @@ -2406,10 +2452,22 @@ async function ensureEditorReady(attempt = 0) { return; } if (!editor) mountEditor(); - editor?.layout(); + 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, diff --git a/client/src/components/scripts/ScriptTestRunModal.vue b/client/src/components/scripts/ScriptTestRunModal.vue index 2d9a1c1..8220ca7 100644 --- a/client/src/components/scripts/ScriptTestRunModal.vue +++ b/client/src/components/scripts/ScriptTestRunModal.vue @@ -69,7 +69,8 @@ const props = defineProps({ hosts: { type: Array, default: () => [] }, credentials: { type: Array, default: () => [] }, job: { type: Object, default: null }, - running: Boolean + running: Boolean, + error: { type: String, default: '' } }); const emit = defineEmits(['close', 'execute']); @@ -95,6 +96,7 @@ const terminalRows = computed(() => { `Status: ${props.job.status || 'queued'}`, '' ]; + if (props.error) lines.push(`[${new Date().toISOString()}] [ERROR] ${props.error}`); for (const log of props.job.logs || []) { lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`); } diff --git a/server/services/powershellRunner.js b/server/services/powershellRunner.js index 1d7aa08..4510adc 100644 --- a/server/services/powershellRunner.js +++ b/server/services/powershellRunner.js @@ -149,11 +149,11 @@ async function runHost(jobId, script, host, executionContext) { return; } - const secret = decryptSecret(host); - const wrapper = buildWrapper(script.content, host, executionContext); const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`); try { + const secret = decryptSecret(host); + const wrapper = buildWrapper(script.content, host, executionContext); await fs.writeFile(file, wrapper, { mode: 0o600 }); await spawnPowerShell(jobId, host, file, secret, executionContext); } catch (error) { diff --git a/server/test/jobs.test.mjs b/server/test/jobs.test.mjs index 9440850..f6df6af 100644 --- a/server/test/jobs.test.mjs +++ b/server/test/jobs.test.mjs @@ -20,12 +20,22 @@ db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id const jobId = 'job_test_1'; db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at) VALUES (?, ?, ?, 'completed', ?, ?)`).run(jobId, runplanId, scriptId, owner, ts); +const scriptTestJobId = 'job_script_test_1'; +db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at) + VALUES (?, NULL, ?, 'failed', ?, ?)`).run(scriptTestJobId, scriptId, owner, ts); test('the triggering operator can list and read their job', () => { assert.ok(listJobs(owner, false).some((j) => j.id === jobId)); assert.ok(getJob(jobId, owner, false)); }); +test('script test jobs without a RunPlan stay visible to their triggering operator', () => { + assert.ok(listJobs(owner, false).some((j) => j.id === scriptTestJobId)); + const job = getJob(scriptTestJobId, owner, false); + assert.equal(job.id, scriptTestJobId); + assert.equal(job.runplanId, null); +}); + test('an unrelated user cannot see a personal job', () => { assert.ok(!listJobs(stranger, false).some((j) => j.id === jobId)); assert.equal(getJob(jobId, stranger, false), null);