This commit is contained in:
2026-06-25 12:54:29 -05:00
parent 56e34475fc
commit 6c6514e70f
4 changed files with 101 additions and 31 deletions

View File

@@ -216,6 +216,7 @@
:credentials="credentials" :credentials="credentials"
:job="scriptTestJob" :job="scriptTestJob"
:running="scriptTestRunning" :running="scriptTestRunning"
:error="scriptTestError"
@close="closeScriptTestRun" @close="closeScriptTestRun"
@execute="executeScriptTestRun" @execute="executeScriptTestRun"
/> />
@@ -952,6 +953,7 @@ const scriptTestModalOpen = ref(false);
const scriptTestTarget = ref(null); const scriptTestTarget = ref(null);
const scriptTestJob = ref(null); const scriptTestJob = ref(null);
const scriptTestRunning = ref(false); const scriptTestRunning = ref(false);
const scriptTestError = ref('');
const userModalOpen = ref(false); const userModalOpen = ref(false);
const groupModalOpen = ref(false); const groupModalOpen = ref(false);
const hostModalOpen = ref(false); const hostModalOpen = ref(false);
@@ -1113,6 +1115,7 @@ const selectedScript = ref(null);
const editorMount = ref(null); const editorMount = ref(null);
let editor; let editor;
let scriptTestPollTimer = null; let scriptTestPollTimer = null;
let scriptTestPollFailures = 0;
const loginForm = reactive({ email: 'admin@posh.local', password: 'change-me-now' }); 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 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 runPlanPageCount = computed(() => pageCount(filteredRunPlans.value, runPlanFilters));
const pagedRunPlans = computed(() => pageRows(filteredRunPlans.value, runPlanFilters)); const pagedRunPlans = computed(() => pageRows(filteredRunPlans.value, runPlanFilters));
watch(view, async () => { watch(view, async (nextView, previousView) => {
contextMenu.open = false; contextMenu.open = false;
if (view.value === 'scripts') await ensureEditorReady(); if (previousView === 'scripts' && nextView !== 'scripts') disposeEditor();
if (view.value === 'profile') hydrateProfileForm(); if (nextView === 'scripts') await ensureEditorReady();
if (nextView === 'profile') hydrateProfileForm();
}); });
watch(() => scriptDraft.content, (value) => { watch(() => scriptDraft.content, (value) => {
@@ -1452,7 +1456,7 @@ async function probeEntraLogin() {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', closeContext); window.removeEventListener('click', closeContext);
stopScriptTestPolling(); stopScriptTestPolling();
editor?.dispose(); disposeEditor();
}); });
function filterRows(rows, filters, fields) { function filterRows(rows, filters, fields) {
@@ -1520,32 +1524,49 @@ function toggleNav() {
} }
function setEditorMount(el) { function setEditorMount(el) {
if (el) { if (!el) {
editorMount.value = null;
disposeEditor();
return;
}
if (editorMount.value && editorMount.value !== el) {
disposeEditor();
}
editorMount.value = el; editorMount.value = el;
if (view.value === 'scripts') void ensureEditorReady(); 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() { async function refreshAll() {
// Bootstrap all API-backed view state together so navigation does not trigger partial reload races. // 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([ 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/auth/me'),
api.get('/api/bootstrap'), safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
api.get('/api/users').catch(() => []), safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true),
api.get('/api/groups'), safeRefresh('groups', api.get('/api/groups'), () => groups.value, refreshFailures),
api.get('/api/credentials'), safeRefresh('credentials', api.get('/api/credentials'), () => credentials.value, refreshFailures),
api.get('/api/hosts'), safeRefresh('hosts', api.get('/api/hosts'), () => hosts.value, refreshFailures),
api.get('/api/folders'), safeRefresh('folders', api.get('/api/folders'), () => folders.value, refreshFailures),
api.get('/api/scripts'), safeRefresh('scripts', api.get('/api/scripts'), () => scripts.value, refreshFailures),
api.get('/api/assets/folders'), safeRefresh('asset folders', api.get('/api/assets/folders'), () => assetFolders.value, refreshFailures),
api.get('/api/assets'), safeRefresh('assets', api.get('/api/assets'), () => assets.value, refreshFailures),
api.get('/api/variables/catalog'), safeRefresh('variables', api.get('/api/variables/catalog'), () => variableCatalog.value, refreshFailures),
api.get('/api/psadt/catalog'), safeRefresh('PSADT catalog', api.get('/api/psadt/catalog'), () => psadtCatalog.value, refreshFailures),
api.get('/api/psadt/profiles'), safeRefresh('PSADT profiles', api.get('/api/psadt/profiles'), () => psadtProfiles.value, refreshFailures),
api.get('/api/psadt/intune/deployments'), safeRefresh('Intune deployments', api.get('/api/psadt/intune/deployments'), () => psadtIntuneDeployments.value, refreshFailures),
api.get('/api/graph/connections'), safeRefresh('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
api.get('/api/runplans'), safeRefresh('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures),
api.get('/api/jobs') safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures)
]); ]);
syncUser(meResult.user); syncUser(meResult.user);
summary.value = boot.summary; summary.value = boot.summary;
@@ -1567,7 +1588,13 @@ async function refreshAll() {
graphConnections.value = graphRows; graphConnections.value = graphRows;
runplans.value = runplanRows; runplans.value = runplanRows;
jobs.value = jobRows; 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() { async function openHelpCenter() {
@@ -1740,6 +1767,8 @@ function openScriptTestRun(script) {
scriptTestTarget.value = script; scriptTestTarget.value = script;
scriptTestJob.value = null; scriptTestJob.value = null;
scriptTestRunning.value = false; scriptTestRunning.value = false;
scriptTestError.value = '';
scriptTestPollFailures = 0;
scriptTestModalOpen.value = true; scriptTestModalOpen.value = true;
} }
@@ -1748,6 +1777,8 @@ function closeScriptTestRun() {
scriptTestTarget.value = null; scriptTestTarget.value = null;
scriptTestJob.value = null; scriptTestJob.value = null;
scriptTestRunning.value = false; scriptTestRunning.value = false;
scriptTestError.value = '';
scriptTestPollFailures = 0;
stopScriptTestPolling(); stopScriptTestPolling();
} }
@@ -1755,6 +1786,8 @@ async function executeScriptTestRun(payload) {
if (!scriptTestTarget.value) return; if (!scriptTestTarget.value) return;
scriptTestRunning.value = true; scriptTestRunning.value = true;
scriptTestJob.value = null; scriptTestJob.value = null;
scriptTestError.value = '';
scriptTestPollFailures = 0;
stopScriptTestPolling(); stopScriptTestPolling();
try { try {
const job = await api.post(`/api/scripts/${scriptTestTarget.value.id}/test-run`, payload); 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'); notify('Script test run started');
} catch (err) { } catch (err) {
scriptTestRunning.value = false; 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); notify(err.message);
} }
} }
@@ -1772,15 +1812,21 @@ function startScriptTestPolling(jobId) {
scriptTestPollTimer = window.setInterval(async () => { scriptTestPollTimer = window.setInterval(async () => {
try { try {
const job = await api.get(`/api/jobs/${jobId}`); const job = await api.get(`/api/jobs/${jobId}`);
scriptTestPollFailures = 0;
scriptTestError.value = '';
scriptTestJob.value = job; scriptTestJob.value = job;
if (!['queued', 'running'].includes(job.status)) { if (!['queued', 'running'].includes(job.status)) {
scriptTestRunning.value = false; scriptTestRunning.value = false;
stopScriptTestPolling(); stopScriptTestPolling();
} }
} catch { } catch (err) {
scriptTestPollFailures += 1;
scriptTestError.value = `Could not refresh test output (${scriptTestPollFailures}/3): ${err.message}`;
if (scriptTestPollFailures >= 3) {
scriptTestRunning.value = false; scriptTestRunning.value = false;
stopScriptTestPolling(); stopScriptTestPolling();
} }
}
}, 1000); }, 1000);
} }
@@ -2406,10 +2452,22 @@ async function ensureEditorReady(attempt = 0) {
return; return;
} }
if (!editor) mountEditor(); 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() { function mountEditor() {
if (!editorMount.value) return;
configurePowerShellMonaco(monaco, () => ({ configurePowerShellMonaco(monaco, () => ({
variableCatalog: variableCatalog.value, variableCatalog: variableCatalog.value,
psadtCatalog: psadtCatalog.value, psadtCatalog: psadtCatalog.value,

View File

@@ -69,7 +69,8 @@ const props = defineProps({
hosts: { type: Array, default: () => [] }, hosts: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] }, credentials: { type: Array, default: () => [] },
job: { type: Object, default: null }, job: { type: Object, default: null },
running: Boolean running: Boolean,
error: { type: String, default: '' }
}); });
const emit = defineEmits(['close', 'execute']); const emit = defineEmits(['close', 'execute']);
@@ -95,6 +96,7 @@ const terminalRows = computed(() => {
`Status: ${props.job.status || 'queued'}`, `Status: ${props.job.status || 'queued'}`,
'' ''
]; ];
if (props.error) lines.push(`[${new Date().toISOString()}] [ERROR] ${props.error}`);
for (const log of props.job.logs || []) { for (const log of props.job.logs || []) {
lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`); lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`);
} }

View File

@@ -149,11 +149,11 @@ async function runHost(jobId, script, host, executionContext) {
return; return;
} }
const secret = decryptSecret(host);
const wrapper = buildWrapper(script.content, host, executionContext);
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`); const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
try { try {
const secret = decryptSecret(host);
const wrapper = buildWrapper(script.content, host, executionContext);
await fs.writeFile(file, wrapper, { mode: 0o600 }); await fs.writeFile(file, wrapper, { mode: 0o600 });
await spawnPowerShell(jobId, host, file, secret, executionContext); await spawnPowerShell(jobId, host, file, secret, executionContext);
} catch (error) { } catch (error) {

View File

@@ -20,12 +20,22 @@ db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id
const jobId = 'job_test_1'; const jobId = 'job_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at) db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, created_at)
VALUES (?, ?, ?, 'completed', ?, ?)`).run(jobId, runplanId, scriptId, owner, ts); 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', () => { test('the triggering operator can list and read their job', () => {
assert.ok(listJobs(owner, false).some((j) => j.id === jobId)); assert.ok(listJobs(owner, false).some((j) => j.id === jobId));
assert.ok(getJob(jobId, owner, false)); 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', () => { test('an unrelated user cannot see a personal job', () => {
assert.ok(!listJobs(stranger, false).some((j) => j.id === jobId)); assert.ok(!listJobs(stranger, false).some((j) => j.id === jobId));
assert.equal(getJob(jobId, stranger, false), null); assert.equal(getJob(jobId, stranger, false), null);