From e47d09d7d6c9d89b7de694d2e797b3533d688276 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 25 Jun 2026 20:57:09 -0500 Subject: [PATCH] Initial --- .env.example | 2 + README.md | 93 +- client/src/App.vue | 344 ++++- client/src/components/AppSidebar.vue | 86 +- .../src/components/settings/ConfigSection.vue | 8 +- .../settings/VCenterConnectionsConfig.vue | 330 ++++- client/src/style.css | 209 +++ client/src/views/JobOutputView.vue | 526 ++++++++ client/src/views/SchedulingView.vue | 1146 +++++++++++++++++ package-lock.json | 202 ++- package.json | 1 + server/controllers/jobOutputController.js | 55 + .../controllers/runPlanScheduleController.js | 80 ++ server/data/helpCatalog.js | 16 +- server/db.js | 63 +- server/forms/schemas.js | 39 + server/index.js | 2 + server/models/jobOutputModel.js | 76 ++ server/models/mappers.js | 72 ++ server/models/runPlanScheduleModel.js | 330 +++++ server/models/settingsModel.js | 4 +- server/routes/index.js | 4 + server/routes/jobOutputRoutes.js | 13 + server/routes/runPlanScheduleRoutes.js | 15 + server/services/jobOutputService.js | 307 +++++ server/services/powershellRunner.js | 93 +- server/services/runPlanScheduleWorker.js | 71 + server/services/systemJobService.js | 8 + server/services/vcenterService.js | 47 +- server/settingsRegistry.js | 3 +- server/test/job-output.test.mjs | 159 +++ server/test/jobs.test.mjs | 66 + server/test/runplan-schedules.test.mjs | 89 ++ server/test/settings.test.mjs | 28 +- server/test/vcenter.test.mjs | 21 +- 35 files changed, 4507 insertions(+), 101 deletions(-) create mode 100644 client/src/views/JobOutputView.vue create mode 100644 client/src/views/SchedulingView.vue create mode 100644 server/controllers/jobOutputController.js create mode 100644 server/controllers/runPlanScheduleController.js create mode 100644 server/models/jobOutputModel.js create mode 100644 server/models/runPlanScheduleModel.js create mode 100644 server/routes/jobOutputRoutes.js create mode 100644 server/routes/runPlanScheduleRoutes.js create mode 100644 server/services/jobOutputService.js create mode 100644 server/services/runPlanScheduleWorker.js create mode 100644 server/test/job-output.test.mjs create mode 100644 server/test/runplan-schedules.test.mjs diff --git a/.env.example b/.env.example index a6d2378..7c7f72d 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,8 @@ POWERSHELL_BIN=pwsh ALLOW_SCRIPT_EXECUTION=true # Minutes between dynamic Host Group rule syncs. 0 disables the worker. HOST_GROUP_SYNC_INTERVAL_MINUTES=60 +# Minutes between automated RunPlan schedule dispatch scans. 0 disables the worker. +RUNPLAN_SCHEDULE_INTERVAL_MINUTES=1 # Minutes between automatic catalog upstream-version checks. 0 disables the worker. CATALOG_CHECK_INTERVAL_MINUTES=0 # Minutes between auto-promote soak checks. 0 disables the worker. diff --git a/README.md b/README.md index 50940f9..bb74ed5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ The current UI is a Vue/Vite view layer over the Express API. All create/update/ - Host Library with searchable/sortable/paginated table, modal add/edit flow, VMware vCenter VM import wizard, standalone ESXi connection records, and manual/dynamic Host Groups for RunPlan and script-test targeting. - Credential Vault as its own menu item with encrypted-at-rest secrets and modal add/edit flow. - RunPlan Library with searchable/sortable/paginated table, modal composer, and execute action. +- Scheduling workspace for automated RunPlan execution with one-time, interval, daily, weekly, monthly, and yearly cadences plus calendar, Gantt-style timeline, and datatable views. +- Job Output workspace for RunPlans and Script Test / Run jobs, grouping generated STDOUT PDF, datatable JSON, and terminal transcript artifacts by job. - Admin System Jobs console for background worker run-now actions, stuck execution cancellation, and audited job-management history. - Aggregate job log viewer with search/filter/pagination. - System log tab for Winston request/application logs. @@ -90,6 +92,8 @@ Dashboard widgets are intentionally split: Script authoring helpers are split under `client/src/components/scripts`. `VariableEditorModal.vue` owns the PSADT/POSHManager variable catalog browser and custom variable editor so future PSADT snippets, Intune deployment helpers, and variable packs can be added without bloating `App.vue`. +Scheduling is implemented as its own view in `client/src/views/SchedulingView.vue` and API domain modules under `server/routes/runPlanScheduleRoutes.js`, `server/controllers/runPlanScheduleController.js`, `server/models/runPlanScheduleModel.js`, and `server/services/runPlanScheduleWorker.js`. The Vue view renders calendar, timeline, and datatable projections; recurrence calculation, dispatch, and history remain backend-owned. + ## Quick Start ```bash @@ -157,7 +161,7 @@ Important environment variables: | `DEFAULT_ADMIN_EMAIL` | First-run admin email. | | `DEFAULT_ADMIN_PASSWORD` | First-run admin password. | | `DEFAULT_ADMIN_NAME` | First-run admin display name. | -| `POWERSHELL_BIN` | PowerShell executable. Defaults to `pwsh`. | +| `POWERSHELL_BIN` | Initial/default PowerShell executable. Defaults to `pwsh`; admins can override the live runtime value with Config -> Application Config -> Execution -> PowerShell Binary. | | `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. | | `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. | | `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. | @@ -167,6 +171,7 @@ Important environment variables: | `VCENTER_REQUEST_TIMEOUT_MS` | Per-request vCenter HTTP timeout. Defaults to `20000`. | | `VCENTER_ALLOW_UNTRUSTED_TLS` | Set `true` to allow self-signed/private CA vCenter certificates. | | `HOST_GROUP_SYNC_INTERVAL_MINUTES` | Minutes between dynamic Host Group rule syncs. Defaults to `60`; set `0` to disable the worker. | +| `RUNPLAN_SCHEDULE_INTERVAL_MINUTES` | Minutes between automated RunPlan schedule dispatch scans. Defaults to `1`; set `0` to disable the scheduler. | | `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. | | `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. | | `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. | @@ -1170,10 +1175,23 @@ with per-host log rows. | `PUT` | `/api/runplans/:id` | User | Update RunPlan. | | `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. | | `POST` | `/api/runplans/:id/execute` | User | Queue/execute a RunPlan. | +| `GET` | `/api/schedules` | User | List visible RunPlan schedules plus recent schedule dispatch history. | +| `POST` | `/api/schedules` | User | Create an automated RunPlan schedule. | +| `POST` | `/api/schedules/preview` | User | Preview upcoming occurrences for a schedule payload. | +| `PUT` | `/api/schedules/:id` | User | Update a saved schedule. | +| `DELETE` | `/api/schedules/:id` | User | Delete a saved schedule. | +| `POST` | `/api/schedules/:id/toggle` | User | Enable or pause a saved schedule. | +| `POST` | `/api/schedules/:id/run-now` | User | Queue the schedule's RunPlan immediately and record schedule history. | | `GET` | `/api/jobs` | User | List jobs. | | `GET` | `/api/jobs/:id` | User | Read job with logs. | +| `GET` | `/api/job-output` | User | List generated job output artifacts visible to the operator. | +| `GET` | `/api/job-output/jobs/:jobId` | User | List generated artifacts for one visible job. | +| `POST` | `/api/job-output/jobs/:jobId/generate` | User | Regenerate FileLocker artifacts for a completed/failed/canceled job. | +| `GET` | `/api/job-output/:id/content` | User | Read table JSON or terminal text through a JSON wrapper for UI/API clients. | +| `GET` | `/api/job-output/:id/view` | User | Inline-view the stored artifact file. | +| `GET` | `/api/job-output/:id/download` | User | Download the stored artifact file. | | `GET` | `/api/system-jobs` | Admin | List running/queued execution jobs, controllable background workers, and recent job-management audit actions. | -| `POST` | `/api/system-jobs/:jobId/run` | Admin | Run a supported background worker now, such as dynamic Host Group sync, catalog auto-check, or Intune auto-promotion. | +| `POST` | `/api/system-jobs/:jobId/run` | Admin | Run a supported background worker now, such as dynamic Host Group sync, RunPlan schedule dispatch, catalog auto-check, or Intune auto-promotion. | | `POST` | `/api/system-jobs/:jobId/cancel` | Admin | Cancel a queued/running script execution job, signal active PowerShell child processes, and audit the action. | RunPlan payload: @@ -1206,6 +1224,42 @@ curl -X POST http://localhost:3000/api/runplans/rp_123/execute \ -d '{}' ``` +Scheduling: + +```bash +curl http://localhost:3000/api/schedules \ + -H "Authorization: Bearer $TOKEN" + +curl -X POST http://localhost:3000/api/schedules \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "name":"Engineering patch validation", + "runplanId":"run_123", + "scheduleType":"weekly", + "startAt":"2026-06-29T14:00:00.000Z", + "timeOfDay":"09:00", + "daysOfWeek":[1,3], + "enabled":true, + "visibility":"shared" + }' + +curl -X POST http://localhost:3000/api/schedules/sched_123/run-now \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +Schedules support `once`, `interval`, `daily`, `weekly`, `monthly`, and +`yearly` recurrence. Interval schedules use `intervalValue` plus +`intervalUnit` (`minutes`, `hours`, `days`, `months`, or `years`). Weekly +schedules use `daysOfWeek` with `0` = Sunday through `6` = Saturday. Monthly +and yearly schedules can pin `dayOfMonth`; yearly schedules can also set +`monthOfYear`. The backend stores `nextRunAt`, updates it after each dispatch, +and records rows in `runplan_schedule_runs`. The API worker scans every +`runplan_schedule_interval_minutes` minutes and can be triggered manually from +System Jobs with `worker:runplan-schedule-dispatch`. + Linux compatibility preflight: ```bash @@ -1226,6 +1280,34 @@ Windows-specific environment variables, and alias-heavy script style. RunPlan execution and Script Test / Run also write the same warning summary into per-host job logs for Linux targets. +Job Output artifacts: + +```bash +curl http://localhost:3000/api/job-output \ + -H "Authorization: Bearer $TOKEN" + +curl -X POST http://localhost:3000/api/job-output/jobs/job_123/generate \ + -H "Authorization: Bearer $TOKEN" + +curl http://localhost:3000/api/job-output/jout_123/content \ + -H "Authorization: Bearer $TOKEN" +``` + +When a RunPlan, scheduled RunPlan, or Script Test / Run reaches a terminal state, the API writes +generated output artifacts to `FileLocker/job-output//` and records them +in SQLite. The generated set includes a combined PDF containing only `STDOUT` +grouped by host, a parsed datatable JSON file, and a terminal transcript text +file with all streams. The datatable parser treats hosts as the row axis. It +prefers structured output in this order: JSON/JSON-lines, CSV/TSV/pipe-delimited +text, PowerShell table output, and PowerShell `Name : Value` object output. +When STDOUT is plain text instead of a table/object/array, the datatable keeps +one row per host and uses only that host's last non-empty STDOUT line. Job +Output artifacts use the +same visibility rules as jobs and logs. The Job Output UI groups the three +artifact types into one row per job; operators choose PDF, datatable, or +terminal transcript from the row-level artifact selector before opening a +preview in a separate browser window or downloading. + Admin System Jobs console: ```bash @@ -1235,6 +1317,9 @@ curl http://localhost:3000/api/system-jobs \ curl -X POST http://localhost:3000/api/system-jobs/worker%3Ahost-group-sync/run \ -H "Authorization: Bearer $TOKEN" +curl -X POST http://localhost:3000/api/system-jobs/worker%3Arunplan-schedule-dispatch/run \ + -H "Authorization: Bearer $TOKEN" + curl -X POST http://localhost:3000/api/system-jobs/job_123/cancel \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ @@ -1268,11 +1353,13 @@ Settings update: ## Execution Notes -The runner writes a temporary PowerShell wrapper and launches `pwsh` with environment variables instead of shell interpolation. +The runner writes a temporary PowerShell wrapper and launches the configured PowerShell binary with environment variables instead of shell interpolation. - `local` hosts run the script directly in the API container. - `winrm` hosts use `Invoke-Command -ComputerName`. - `ssh` hosts use `Invoke-Command -HostName`. +- From a Linux API host/container, `winrm` targets require Linux WSMan client support for PowerShell remoting. If PowerShell reports `no supported WSMan client library`, install/configure the WSMan dependencies in the API image or use SSH transport for PowerShell 7 targets. +- Wrapper failures use `$ErrorActionPreference = "Stop"` and remote `Invoke-Command -ErrorAction Stop`, so remoting errors mark the host failed instead of completing with exit code 0. - Host credential resolution happens inside backend execution flows. - POSHManager runtime variables are injected as PowerShell variables and `POSHM_*` environment variables. - `POSHM_HOST_OS_FAMILY` is injected with `windows`, `linux`, or `other` so scripts can branch on the target OS type. diff --git a/client/src/App.vue b/client/src/App.vue index e47798f..bf0b632 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -597,6 +597,31 @@ + + + + @@ -967,8 +993,8 @@ import 'monaco-editor/esm/vs/editor/contrib/snippet/browser/snippetController2.j 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, + Activity, AlertTriangle, BarChart3, BookOpenText, CalendarClock, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink, + FileCode2, FilePlus2, FileText, 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'; @@ -996,6 +1022,8 @@ import LoginView from './views/LoginView.vue'; import PsadtView from './views/PsadtView.vue'; import AssetsView from './views/AssetsView.vue'; import SystemJobsView from './views/SystemJobsView.vue'; +import JobOutputView from './views/JobOutputView.vue'; +import SchedulingView from './views/SchedulingView.vue'; self.MonacoEnvironment = { getWorker() { @@ -1164,6 +1192,12 @@ const settingMetadata = { placeholder: '60', section: 'Execution' }, + runplan_schedule_interval_minutes: { + label: 'RunPlan Schedule Interval', + description: 'Minutes between API worker scans for due automated RunPlan schedules. Set 0 to disable schedule dispatch.', + placeholder: '1', + section: 'Execution' + }, vcenter_enabled: { label: 'Legacy vCenter Default', description: 'Enable the legacy single-vCenter fallback. Saved VMware connections should use Config / VMware with Credential Vault authentication.', @@ -1216,7 +1250,11 @@ const scripts = ref([]); const assetFolders = ref([]); const assets = ref([]); const runplans = ref([]); +const runPlanSchedules = ref([]); +const runPlanScheduleHistory = ref([]); const jobs = ref([]); +const jobOutputs = ref([]); +const schedulingLoading = ref(false); const systemJobs = ref([]); const systemJobActions = ref([]); const systemJobsLoading = ref(false); @@ -1237,6 +1275,8 @@ const installerAnalysis = ref(null); const psadtValidationResult = ref(null); const psadtMigrationPlan = ref(null); const selectedJob = ref(null); +const selectedJobOutput = ref(null); +const jobOutputLoading = ref(false); const systemLogs = ref([]); const logTab = ref('job'); const logFilters = reactive({ search: '', stream: '', page: 1, pageSize: 14 }); @@ -1307,16 +1347,53 @@ 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 } + { + id: 'nav-scripts', + label: 'Scripts', + icon: BookOpenText, + children: [ + { id: 'scripts', label: 'Script Editor', icon: FileCode2 }, + { id: 'psadt', label: 'PSADT Bench', icon: Sparkles } + ] + }, + { + id: 'nav-scheduling', + label: 'Scheduling', + icon: CalendarClock, + children: [ + { id: 'runplans', label: 'RunPlans', icon: Rocket }, + { id: 'job-output', label: 'Job Output', icon: FileText }, + { id: 'scheduling', label: 'Schedules', icon: CalendarClock }, + { id: 'logs', label: 'Logs', icon: Activity } + ] + }, + { + id: 'nav-shared-libraries', + label: 'Shared Libraries', + icon: PackageOpen, + children: [ + { id: 'assets', label: 'Asset Library', icon: PackageOpen }, + { id: 'credentials', label: 'Credential Vault', icon: KeyRound } + ] + }, + { + id: 'nav-resources', + label: 'Resources', + icon: Server, + children: [ + { id: 'hosts', label: 'Hosts', icon: Server } + ] + }, + { + id: 'nav-administration', + label: 'Administration', + icon: Settings, + children: [ + { id: 'users', label: 'Users & Groups', icon: UsersRound }, + { id: 'admin', label: 'Configuration', icon: Settings }, + { id: 'system-jobs', label: 'System Jobs', icon: Database } + ] + } ]; const availableThemes = [ @@ -1369,8 +1446,9 @@ const runPlanColumns = [ { 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 navLeaves = computed(() => nav.flatMap((item) => item.children?.length ? item.children : [item])); +const activeTitle = computed(() => (view.value === 'profile' ? 'My Profile' : navLeaves.value.find((item) => item.id === view.value)?.label || 'Dashboard')); +const routedViewIds = computed(() => new Set([...navLeaves.value.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')); @@ -1718,7 +1796,7 @@ async function refreshAll() { 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([ + const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, schedulePayload, jobRows, jobOutputRows] = 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), @@ -1737,13 +1815,14 @@ async function refreshAll() { 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) + safeRefresh('schedules', api.get('/api/schedules'), () => ({ schedules: runPlanSchedules.value, history: runPlanScheduleHistory.value }), refreshFailures), + safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures), + safeRefresh('job outputs', api.get('/api/job-output'), () => jobOutputs.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 }; }); + syncSettingsDraft(); users.value = userRows; groups.value = groupRows; credentials.value = credentialRows; @@ -1760,7 +1839,15 @@ async function refreshAll() { graphConnections.value = graphRows; vcenterConnections.value = vcenterRows; runplans.value = runplanRows; + runPlanSchedules.value = schedulePayload.schedules || []; + runPlanScheduleHistory.value = schedulePayload.history || []; jobs.value = jobRows; + jobOutputs.value = jobOutputRows; + if (selectedJobOutput.value) { + const refreshedOutput = jobOutputRows.find((output) => output.id === selectedJobOutput.value.id); + selectedJobOutput.value = refreshedOutput || null; + if (!refreshedOutput) clearSelectedJobOutput(); + } if (user.value?.role === 'admin') await refreshSystemJobs(true); else { systemJobs.value = []; @@ -2714,6 +2801,76 @@ async function executeRunPlan(id) { notify('RunPlan queued'); } +async function refreshSchedules() { + schedulingLoading.value = true; + try { + const payload = await api.get('/api/schedules'); + runPlanSchedules.value = payload.schedules || []; + runPlanScheduleHistory.value = payload.history || []; + if (user.value?.role === 'admin') await refreshSystemJobs(true); + notify('Schedules refreshed'); + } catch (err) { + notify(err.message || 'Unable to refresh schedules', 'error'); + } finally { + schedulingLoading.value = false; + } +} + +async function saveSchedule(payload) { + schedulingLoading.value = true; + try { + if (payload.id) await api.put(`/api/schedules/${encodeURIComponent(payload.id)}`, payload); + else await api.post('/api/schedules', payload); + await refreshSchedules(); + notify('Schedule saved'); + } catch (err) { + notify(err.message || 'Unable to save schedule', 'error'); + } finally { + schedulingLoading.value = false; + } +} + +async function deleteSchedule(id) { + schedulingLoading.value = true; + try { + await api.delete(`/api/schedules/${encodeURIComponent(id)}`); + await refreshSchedules(); + notify('Schedule deleted'); + } catch (err) { + notify(err.message || 'Unable to delete schedule', 'error'); + } finally { + schedulingLoading.value = false; + } +} + +async function toggleSchedule({ id, enabled }) { + schedulingLoading.value = true; + try { + await api.post(`/api/schedules/${encodeURIComponent(id)}/toggle`, { enabled }); + await refreshSchedules(); + notify(enabled ? 'Schedule enabled' : 'Schedule paused'); + } catch (err) { + notify(err.message || 'Unable to update schedule', 'error'); + } finally { + schedulingLoading.value = false; + } +} + +async function runScheduleNow(id) { + schedulingLoading.value = true; + try { + const job = await api.post(`/api/schedules/${encodeURIComponent(id)}/run-now`, {}); + await refreshAll(); + await openJob(job.id); + view.value = 'logs'; + notify('Scheduled RunPlan queued'); + } catch (err) { + notify(err.message || 'Unable to run schedule', 'error'); + } finally { + schedulingLoading.value = false; + } +} + 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'); @@ -2735,6 +2892,143 @@ async function openJob(id) { logFilters.page = 1; } +function clearSelectedJobOutput() { + selectedJobOutput.value = null; +} + +async function fetchAuthedBlob(url) { + const response = await fetch(url, { + headers: token.value ? { Authorization: `Bearer ${token.value}` } : {} + }); + if (!response.ok) throw new Error(`Download failed: ${response.status}`); + return response.blob(); +} + +async function refreshJobOutputs() { + jobOutputs.value = await api.get('/api/job-output'); + notify('Job outputs refreshed'); +} + +async function generateJobOutputs(jobId) { + if (!jobId) return; + await api.post(`/api/job-output/jobs/${encodeURIComponent(jobId)}/generate`, {}); + jobOutputs.value = await api.get('/api/job-output'); + const firstOutput = jobOutputs.value.find((output) => output.jobId === jobId); + if (firstOutput) selectedJobOutput.value = firstOutput; + notify('Job output artifacts generated'); +} + +function escapePreviewHtml(value = '') { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function previewDocument(title, body) { + return ` + + + + ${escapePreviewHtml(title)} + + + +

${escapePreviewHtml(title)}

+
Opened from POSHManager Job Output
+
${body}
+ +`; +} + +function tablePreviewHtml(table = {}) { + const columns = table.columns || []; + const rows = table.rows || []; + const head = `${columns.map((column) => `${escapePreviewHtml(column.label || column.key)}`).join('')}`; + const body = `${rows.map((row) => `${columns.map((column) => `${escapePreviewHtml(row[column.key] ?? '')}`).join('')}`).join('')}`; + return `${head}${body}
`; +} + +function openPreviewBlob(blob, label) { + const url = URL.createObjectURL(blob); + const popup = window.open(url, '_blank'); + if (!popup) { + URL.revokeObjectURL(url); + throw new Error('Browser blocked the preview window. Allow popups for POSHManager and try again.'); + } + setTimeout(() => URL.revokeObjectURL(url), 60000); + try { + popup.document.title = label; + } catch { + // Blob/PDF viewers may not expose a writable document during navigation. + } +} + +async function viewJobOutput(output) { + selectedJobOutput.value = output; + jobOutputLoading.value = true; + try { + if (output.kind === 'stdout-pdf') { + const blob = await fetchAuthedBlob(output.viewUrl); + openPreviewBlob(blob, output.name || 'Job STDOUT PDF'); + } else { + const payload = await api.get(`/api/job-output/${encodeURIComponent(output.id)}/content`); + const title = output.name || 'Job Output'; + const body = output.kind === 'datatable' + ? tablePreviewHtml(payload.table) + : `
${escapePreviewHtml(payload.text || '')}
`; + openPreviewBlob(new Blob([previewDocument(title, body)], { type: 'text/html' }), title); + } + } catch (err) { + notify(err.message || 'Unable to load job output', 'error'); + } finally { + jobOutputLoading.value = false; + } +} + +async function downloadJobOutput(output) { + const blob = await fetchAuthedBlob(output.downloadUrl); + const href = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.download = output.name || 'job-output'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(href); +} + async function loadSystemLogs() { logTab.value = 'system'; logFilters.page = 1; @@ -2786,8 +3080,11 @@ async function cancelSystemJob({ id, reason }) { async function saveSettings() { const payload = {}; - Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; }); + Object.entries(settingsDraft).forEach(([key, row]) => { + if (!isLockedSetting(key, row)) payload[key] = row.value; + }); settings.value = await api.put('/api/settings', payload); + syncSettingsDraft(); notify('Settings saved'); } @@ -3135,6 +3432,15 @@ function settingSourceLabel(source) { return source === 'env' ? 'ENV' : 'DB'; } +function isLockedSetting(key, row = settingsDraft[key]) { + return row?.source === 'env' && key !== 'powershell_bin'; +} + +function syncSettingsDraft() { + Object.keys(settingsDraft).forEach((key) => delete settingsDraft[key]); + Object.entries(settings.value || {}).forEach(([key, row]) => { settingsDraft[key] = { ...row }; }); +} + function configSectionMeta(section) { const meta = { Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' }, diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index 51c7421..5eec81b 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -8,16 +8,46 @@ {{ sourceLabel(row.source) }} @@ -51,7 +52,8 @@ defineProps({ isSecretSetting: { type: Function, default: () => false }, sourceLabel: { type: Function, required: true }, isBooleanSetting: { type: Function, required: true }, - normalizedBooleanSetting: { type: Function, required: true } + normalizedBooleanSetting: { type: Function, required: true }, + isLockedSetting: { type: Function, default: () => false } }); defineEmits(['toggle', 'toggle-boolean']); diff --git a/client/src/components/settings/VCenterConnectionsConfig.vue b/client/src/components/settings/VCenterConnectionsConfig.vue index d81bff4..112857f 100644 --- a/client/src/components/settings/VCenterConnectionsConfig.vue +++ b/client/src/components/settings/VCenterConnectionsConfig.vue @@ -11,35 +11,61 @@ -
-
- Inventory sources +
+
+
+ +
+ Inventory sources + Credential-backed VMware inventory and power-state checks. +
+

vCenter and standalone ESXi entries use Credential Vault entries for VMware API authentication, inventory import, and power-state checks.

-
- {{ connections.length }}systems - {{ enabledCount }}enabled +
+ {{ connections.length }}Systems + {{ enabledCount }}Enabled {{ vcenterCount }}vCenter - {{ standaloneCount }}hosts - {{ testedCount }}tested + {{ standaloneCount }}ESXi Hosts + {{ testedCount }}Tested
-
- VMware systems -
- {{ connection.name }} - {{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }} - - - - - +

No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.

@@ -165,4 +191,272 @@ function save() { function connectionTypeLabel(connection) { return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory'; } + +function credentialLabel(connection) { + return connection.credentialName || connection.username || 'No vault credential'; +} + +function connectionStatusTone(connection) { + const status = String(connection.lastTestStatus || '').toLowerCase(); + if (!status) return 'neutral'; + if (status === 'success' || status === 'ok' || status === 'passed') return 'success'; + if (status === 'failed' || status === 'error') return 'failed'; + return 'warning'; +} + +function formatDate(value) { + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'short', + timeStyle: 'short' + }).format(new Date(value)); + } catch { + return value; + } +} + + diff --git a/client/src/style.css b/client/src/style.css index ac93c60..7e45b7a 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -668,6 +668,86 @@ textarea:focus { color: rgba(255, 255, 255, .6); } +.nav-group { + display: grid; + gap: 3px; +} + +.nav-group-toggle .group-chevron { + margin-left: auto; + color: rgba(230, 238, 255, .58); + transition: transform .18s ease, color .18s ease; +} + +.nav-group.open .group-chevron { + transform: rotate(180deg); +} + +.nav-group.active > .nav-group-toggle { + color: #fff; + background: linear-gradient(90deg, rgba(116, 236, 231, .12), rgba(255, 39, 149, .13)); + border-color: rgba(255, 255, 255, .1); +} + +.nav-group.active > .nav-group-toggle .parent-icon { + background: linear-gradient(135deg, rgba(116, 236, 231, .35), rgba(255, 91, 152, .28)); + color: #fff; +} + +.nav-group.active > .nav-group-toggle .group-chevron { + color: #8fe8ff; +} + +.nav-submenu { + display: grid; + gap: 2px; + margin: 1px 0 6px 18px; + padding-left: 11px; + border-left: 1px solid rgba(143, 232, 255, .14); +} + +.metismenu .nav-child-button { + min-height: 36px; + padding-left: 8px; + border-radius: 11px; + font-size: 11px; +} + +.metismenu .nav-child-button .child-icon { + width: 27px; + height: 27px; + border-radius: 9px; + background: rgba(255, 255, 255, .08); +} + +.child-rail { + width: 5px; + height: 5px; + border-radius: 999px; + background: rgba(143, 232, 255, .34); +} + +.nav-child-button.active .child-rail { + background: #8fe8ff; + box-shadow: 0 0 12px rgba(143, 232, 255, .7); +} + +.sidebar.collapsed .nav-group-toggle .group-chevron, +.sidebar.collapsed .child-rail { + display: none; +} + +.sidebar.collapsed .nav-submenu { + margin: 2px 0 8px; + padding-left: 0; + border-left: 0; +} + +.sidebar.collapsed .metismenu .nav-child-button { + justify-content: center; + min-height: 40px; +} + .user-orb { width: 34px; height: 34px; @@ -3644,6 +3724,13 @@ textarea:focus { box-shadow: 0 8px 18px rgba(100, 231, 189, .25), inset 0 1px 0 rgba(255, 255, 255, .5); } +.settings-input:disabled, +.switch-control:disabled { + cursor: not-allowed; + opacity: .62; + filter: saturate(.72); +} + .setting-source { justify-self: end; min-width: 54px; @@ -8032,6 +8119,128 @@ input:read-only { padding: 0 !important; } +/* Grouped left navigation. Kept late in the file so theme/mobile overrides do + not flatten the hierarchy back into the old single-level menu. */ +.metismenu .nav-root-button, +.metismenu .nav-group-toggle, +.metismenu .nav-child-button { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +.nav-group { + display: grid; + gap: 3px; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.nav-group-toggle .group-chevron { + margin-left: auto; + flex: 0 0 auto; + color: rgba(230, 238, 255, .58); + transition: transform .18s ease, color .18s ease; +} + +.nav-group.open .group-chevron { + transform: rotate(180deg); +} + +.nav-group.active > .nav-group-toggle { + color: #fff7ff; + background: linear-gradient(90deg, rgba(116, 236, 231, .13), rgba(255, 39, 149, .16)); + border-color: rgba(255, 255, 255, .12); +} + +.nav-submenu { + display: grid; + gap: 2px; + margin: 1px 0 7px 18px; + padding-left: 11px; + border-left: 1px solid rgba(143, 232, 255, .14); + max-width: calc(100% - 18px); + box-sizing: border-box; +} + +.metismenu .nav-child-button { + min-height: 36px; + padding-left: 8px; + border-radius: 11px; + font-size: 11px; +} + +.metismenu .nav-child-button .child-icon { + width: 27px; + height: 27px; + border-radius: 9px; + background: rgba(255, 255, 255, .08); +} + +.child-rail { + flex: 0 0 5px; + width: 5px; + height: 5px; + border-radius: 999px; + background: rgba(143, 232, 255, .34); +} + +.nav-child-button.active .child-rail { + background: #8fe8ff; + box-shadow: 0 0 12px rgba(143, 232, 255, .7); +} + +.sidebar.collapsed .nav-group-toggle .group-chevron, +.sidebar.collapsed .child-rail { + display: none; +} + +.sidebar.collapsed .nav-submenu { + margin: 2px 0 8px; + padding-left: 0; + border-left: 0; +} + +.sidebar.collapsed .metismenu .nav-child-button { + justify-content: center; + min-height: 40px; + padding: 0; +} + +@media (max-width: 900px) { + .metismenu .nav-group { + display: flex; + align-items: center; + gap: 7px; + } + + .metismenu .nav-submenu { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + padding-left: 0; + border-left: 0; + } + + .metismenu .nav-root-button, + .metismenu .nav-group-toggle, + .metismenu .nav-child-button, + .sidebar.collapsed .metismenu .nav-child-button { + width: 40px; + min-width: 40px; + height: 40px; + padding: 0; + justify-content: center; + } + + .metismenu .nav-group-toggle .group-chevron, + .metismenu .child-rail { + display: none; + } +} + .form-modal .host-picker input[type="checkbox"], .modal-form .host-picker input[type="checkbox"], .grid-form .host-picker input[type="checkbox"], diff --git a/client/src/views/JobOutputView.vue b/client/src/views/JobOutputView.vue new file mode 100644 index 0000000..2d18c50 --- /dev/null +++ b/client/src/views/JobOutputView.vue @@ -0,0 +1,526 @@ + + + + + diff --git a/client/src/views/SchedulingView.vue b/client/src/views/SchedulingView.vue new file mode 100644 index 0000000..4b3f851 --- /dev/null +++ b/client/src/views/SchedulingView.vue @@ -0,0 +1,1146 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 56ff982..09342bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "multer": "^2.2.0", "nanoid": "^5.1.6", "path": "^0.12.7", + "pdfkit": "^0.19.1", "tailwindcss": "^4.3.1", "undici": "^8.5.0", "vite": "^8.1.0", @@ -214,6 +215,30 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", @@ -499,6 +524,15 @@ "text-hex": "1.0.x" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", @@ -982,6 +1016,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -1067,6 +1121,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1181,6 +1253,15 @@ "node": ">=20" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -1374,6 +1455,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -1566,6 +1653,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -1612,6 +1705,23 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1911,6 +2021,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2221,6 +2337,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -2577,6 +2712,12 @@ "fn.name": "1.x.x" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2606,6 +2747,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2672,6 +2827,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2813,6 +2976,12 @@ "node": ">=8.10.0" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -3184,6 +3353,12 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3284,7 +3459,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/type-is": { @@ -3340,6 +3514,32 @@ "node": ">=22.19.0" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index f27cfdc..5342cda 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "multer": "^2.2.0", "nanoid": "^5.1.6", "path": "^0.12.7", + "pdfkit": "^0.19.1", "tailwindcss": "^4.3.1", "undici": "^8.5.0", "vite": "^8.1.0", diff --git a/server/controllers/jobOutputController.js b/server/controllers/jobOutputController.js new file mode 100644 index 0000000..847332e --- /dev/null +++ b/server/controllers/jobOutputController.js @@ -0,0 +1,55 @@ +import fs from 'node:fs'; +import { + getJobOutputFile, + listJobOutputs, + listJobOutputsForJob +} from '../models/jobOutputModel.js'; +import { getJob } from '../models/jobModel.js'; +import { generateJobOutputs } from '../services/jobOutputService.js'; + +function isAdmin(req) { + return req.user.role === 'admin'; +} + +export function index(req, res) { + res.json(listJobOutputs(req.user.id, isAdmin(req))); +} + +export function byJob(req, res) { + res.json(listJobOutputsForJob(req.params.jobId, req.user.id, isAdmin(req))); +} + +export async function generate(req, res) { + const job = getJob(req.params.jobId, req.user.id, isAdmin(req)); + if (!job) return res.status(404).json({ error: 'Job not found' }); + await generateJobOutputs(req.params.jobId); + const outputs = listJobOutputsForJob(req.params.jobId, req.user.id, isAdmin(req)); + if (!outputs.length) return res.status(409).json({ error: 'Job is not ready for output generation' }); + res.json(outputs); +} + +export function content(req, res) { + const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req)); + if (!result) return res.status(404).json({ error: 'Job output not found' }); + if (result.output.mimeType === 'application/json') { + return res.json({ output: result.output, table: JSON.parse(fs.readFileSync(result.filePath, 'utf8')) }); + } + if (result.output.mimeType.startsWith('text/')) { + return res.json({ output: result.output, text: fs.readFileSync(result.filePath, 'utf8') }); + } + return res.json({ output: result.output }); +} + +export function view(req, res) { + const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req)); + if (!result) return res.status(404).json({ error: 'Job output not found' }); + res.type(result.output.mimeType || 'application/octet-stream'); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.output.name)}"`); + res.sendFile(result.filePath); +} + +export function download(req, res) { + const result = getJobOutputFile(req.params.id, req.user.id, isAdmin(req)); + if (!result) return res.status(404).json({ error: 'Job output not found' }); + res.download(result.filePath, result.output.name); +} diff --git a/server/controllers/runPlanScheduleController.js b/server/controllers/runPlanScheduleController.js new file mode 100644 index 0000000..86b9774 --- /dev/null +++ b/server/controllers/runPlanScheduleController.js @@ -0,0 +1,80 @@ +import { camelJob } from '../models/mappers.js'; +import { + createRunPlanSchedule, + deleteRunPlanSchedule, + getRunPlanSchedule, + listRunPlanScheduleRuns, + listRunPlanSchedules, + previewRunPlanSchedule, + recordRunPlanScheduleDispatch, + setRunPlanScheduleEnabled, + updateRunPlanSchedule +} from '../models/runPlanScheduleModel.js'; +import { executeRunPlan } from '../services/powershellRunner.js'; + +function isAdmin(req) { + return req.user?.role === 'admin'; +} + +export function index(req, res) { + res.json({ + schedules: listRunPlanSchedules(req.user.id, isAdmin(req)), + history: listRunPlanScheduleRuns(req.user.id, isAdmin(req)) + }); +} + +export function create(req, res) { + try { + res.status(201).json(createRunPlanSchedule(req.body, req.user.id)); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} + +export function update(req, res) { + try { + const schedule = updateRunPlanSchedule(req.params.id, req.body, req.user.id, isAdmin(req)); + if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); + res.json(schedule); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} + +export function destroy(req, res) { + if (!deleteRunPlanSchedule(req.params.id, req.user.id, isAdmin(req))) { + return res.status(404).json({ error: 'Schedule not found' }); + } + res.status(204).end(); +} + +export function toggle(req, res) { + const schedule = setRunPlanScheduleEnabled(req.params.id, Boolean(req.body?.enabled), req.user.id, isAdmin(req)); + if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); + res.json(schedule); +} + +export function preview(req, res) { + try { + res.json({ occurrences: previewRunPlanSchedule(req.body) }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} + +export function runNow(req, res) { + const schedule = getRunPlanSchedule(req.params.id, req.user.id, isAdmin(req)); + if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); + try { + const job = executeRunPlan(schedule.runplanId, req.user.id); + recordRunPlanScheduleDispatch(schedule, { + jobId: job.id, + plannedFor: new Date().toISOString(), + status: 'manual', + message: `Operator launched schedule ${schedule.name} manually.` + }); + res.status(202).json(camelJob(job)); + } catch (error) { + res.status(400).json({ error: error.message }); + } +} diff --git a/server/data/helpCatalog.js b/server/data/helpCatalog.js index b8a2604..1908535 100644 --- a/server/data/helpCatalog.js +++ b/server/data/helpCatalog.js @@ -32,7 +32,7 @@ const operationsArticles = [ 'Dashboard shows metrics, charts, recent jobs, and configurable widgets.', 'Scripts contains the VS Code-style Monaco PowerShell editor, script library, variable studio, and metadata panel.', 'PSADT contains the deployment factory, verifier, migration wizard, and Intune Publishing Wizard.', - 'Hosts, Credential Vault, RunPlans, Logs, Users & Groups, and Config are separate operational surfaces.' + 'Hosts, Credential Vault, RunPlans, Scheduling, Logs, Job Output, Users & Groups, and Config are separate operational surfaces.' ]) ], ['dashboard', 'operations', 'navigation']), article('ops-scripts', 'Operations', 'Scripts And Variables', 'Create folders, author scripts, use variables, and keep versions.', [ @@ -75,9 +75,15 @@ const operationsArticles = [ 'A RunPlan joins one script with one or more hosts. The backend creates a job and host-level log rows during execution.', 'RunPlans can be personal, shared, or group-scoped. Use the execute action to queue the plan.' ]), + section('Scheduling', [ + 'Scheduling automates RunPlan execution with one-time, interval, daily, weekly, monthly, and yearly recurrence modes.', + 'The Scheduling screen has calendar, Gantt-style timeline, and datatable views. Create and edit schedules in modal flows so the operational view stays clean.', + 'The API worker scans for due schedules using runplan_schedule_interval_minutes / RUNPLAN_SCHEDULE_INTERVAL_MINUTES. Set it to 0 to disable automated dispatch.', + 'Scheduled runs use the same RunPlan execution service as manual runs, so logs and Job Output artifacts are generated the same way.' + ]), section('System Jobs', [ 'Admins can open System Jobs to see queued/running script executions, controllable background workers, and recent management actions in one place.', - 'Use Run now for supported background workers such as dynamic Host Group sync, application catalog auto-check, and Intune auto-promotion. Use Cancel on stuck execution jobs to mark queued/running hosts canceled and signal active PowerShell child processes.', + 'Use Run now for supported background workers such as dynamic Host Group sync, RunPlan schedule dispatch, application catalog auto-check, and Intune auto-promotion. Use Cancel on stuck execution jobs to mark queued/running hosts canceled and signal active PowerShell child processes.', 'Every run-now and cancel action is written to the system job action audit trail with actor, target, status, message, and timestamp.' ]), section('Logs', [ @@ -132,10 +138,14 @@ const apiArticles = [ apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'), apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'), apiExample('Post', '/api/runplans/rp_123/execute', 'Execute a RunPlan and create a job. Replace rp_123 with the RunPlan id.'), + apiExample('Get', '/api/schedules', 'List visible RunPlan schedules and recent scheduler dispatch history.'), + apiExample('Post', '/api/schedules', 'Create a recurring RunPlan schedule.', '@{ name = "Engineering patch validation"; runplanId = "run_123"; scheduleType = "weekly"; startAt = "2026-06-29T14:00:00.000Z"; timeOfDay = "09:00"; daysOfWeek = @(1,3); enabled = $true; visibility = "shared" }'), + apiExample('Post', '/api/schedules/sched_123/run-now', 'Queue the RunPlan behind a saved schedule immediately.'), + apiExample('Post', '/api/schedules/sched_123/toggle', 'Enable or pause a schedule.', '@{ enabled = $false }'), apiExample('Get', '/api/jobs', 'List job history.'), apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'), apiExample('Get', '/api/system-jobs', 'Admin-only control plane for execution jobs, background workers, and recent management audit actions.'), - apiExample('Post', '/api/system-jobs/worker%3Ahost-group-sync/run', 'Run a supported background worker immediately. Other worker ids include worker:catalog-auto-check and worker:intune-auto-promote.'), + apiExample('Post', '/api/system-jobs/worker%3Ahost-group-sync/run', 'Run a supported background worker immediately. Other worker ids include worker:runplan-schedule-dispatch, worker:catalog-auto-check, and worker:intune-auto-promote.'), apiExample('Post', '/api/system-jobs/job_123/cancel', 'Cancel a queued or running execution job and audit the reason.', '@{ reason = "Operator canceled stuck remoting session" }'), apiExample('Get', '/api/logs/system', 'Read system/request logs from the Winston log stream.') , diff --git a/server/db.js b/server/db.js index 1912e99..ca74cc5 100644 --- a/server/db.js +++ b/server/db.js @@ -442,6 +442,32 @@ export function migrate() { PRIMARY KEY (runplan_id, host_group_id) ); + CREATE TABLE IF NOT EXISTS runplan_schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + runplan_id TEXT NOT NULL REFERENCES runplans(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 1, + schedule_type TEXT NOT NULL DEFAULT 'once', + start_at TEXT NOT NULL, + end_at TEXT, + time_of_day TEXT, + interval_value INTEGER, + interval_unit TEXT, + days_of_week TEXT NOT NULL DEFAULT '[]', + day_of_month INTEGER, + month_of_year INTEGER, + next_run_at TEXT, + last_run_at TEXT, + last_job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL, + visibility TEXT NOT NULL DEFAULT 'personal', + owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + group_id TEXT REFERENCES groups(id) ON DELETE SET NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL, @@ -472,6 +498,32 @@ export function migrate() { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS job_outputs ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + name TEXT NOT NULL, + mime_type TEXT NOT NULL, + storage_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + checksum TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(job_id, kind) + ); + + CREATE TABLE IF NOT EXISTS runplan_schedule_runs ( + id TEXT PRIMARY KEY, + schedule_id TEXT NOT NULL REFERENCES runplan_schedules(id) ON DELETE CASCADE, + runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL, + job_id TEXT REFERENCES jobs(id) ON DELETE SET NULL, + planned_for TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS system_job_actions ( id TEXT PRIMARY KEY, job_id TEXT, @@ -531,6 +583,7 @@ export function migrate() { ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'"); ensureColumn('vcenter_connections', 'credential_id', 'TEXT REFERENCES credentials(id) ON DELETE SET NULL'); ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'"); + ensureColumn('job_outputs', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'"); // Tenant write-back is opt-in per Graph connection. Default 0 so existing // read-only connections can never be used to change a tenant by accident. ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0'); @@ -601,9 +654,17 @@ export function seed() { VALUES (?, ?, 'default', ?) ON CONFLICT(key) DO NOTHING `); + const releaseEnvSource = db.prepare(` + UPDATE settings + SET value = ?, source = 'default', updated_at = ? + WHERE key = ? AND source = 'env' + `); for (const def of settingDefinitions()) { if (def.pinned) pinned.run(def.key, def.value, now()); - else defaulted.run(def.key, def.value, now()); + else { + defaulted.run(def.key, def.value, now()); + releaseEnvSource.run(def.value, now(), def.key); + } } } diff --git a/server/forms/schemas.js b/server/forms/schemas.js index 58bce2b..165b215 100644 --- a/server/forms/schemas.js +++ b/server/forms/schemas.js @@ -367,6 +367,24 @@ const runPlanPayloadSchema = z.object({ hostGroupIds: z.array(z.string()).optional() }); +const runPlanSchedulePayloadSchema = z.object({ + name: z.string().min(1).max(180), + description: z.string().max(800).optional().default(''), + runplanId: z.string().min(1), + enabled: z.boolean().or(z.number()).optional().default(true), + scheduleType: z.enum(['once', 'interval', 'daily', 'weekly', 'monthly', 'yearly']).default('once'), + startAt: z.string().min(1), + endAt: z.string().optional().default(''), + timeOfDay: z.string().regex(/^\d{2}:\d{2}$/).optional().or(z.literal('')).default(''), + intervalValue: z.coerce.number().int().min(1).max(100000).optional().default(1), + intervalUnit: z.enum(['minutes', 'hours', 'days', 'months', 'years']).optional().default('hours'), + daysOfWeek: z.array(z.coerce.number().int().min(0).max(6)).optional().default([]), + dayOfMonth: z.coerce.number().int().min(1).max(31).nullable().optional(), + monthOfYear: z.coerce.number().int().min(1).max(12).nullable().optional(), + visibility: visibilitySchema.default('personal'), + groupId: z.string().nullable().optional() +}); + export const scriptCompatibilitySchema = z.object({ hostId: z.string().optional().default(''), hostGroupId: z.string().optional().default(''), @@ -399,3 +417,24 @@ export function normalizeRunPlanPayload(body) { hostGroupIds: parsed.hostGroupIds || [] }; } + +export function normalizeRunPlanSchedulePayload(body) { + const parsed = runPlanSchedulePayloadSchema.parse(body); + return { + name: parsed.name, + description: parsed.description || '', + runplanId: parsed.runplanId, + enabled: Boolean(parsed.enabled), + scheduleType: parsed.scheduleType, + startAt: parsed.startAt, + endAt: parsed.endAt || '', + timeOfDay: parsed.timeOfDay || '', + intervalValue: parsed.intervalValue || 1, + intervalUnit: parsed.intervalUnit || 'hours', + daysOfWeek: [...new Set(parsed.daysOfWeek || [])].sort((a, b) => a - b), + dayOfMonth: parsed.dayOfMonth || null, + monthOfYear: parsed.monthOfYear || null, + visibility: parsed.visibility, + groupId: parsed.visibility === 'group' ? parsed.groupId ?? null : null + }; +} diff --git a/server/index.js b/server/index.js index 8408da5..4f4cc45 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ import { effectiveTrustedOrigins } from './models/settingsModel.js'; import { startCatalogWorker } from './services/catalogWorker.js'; import { startPromotionWorker } from './services/promotionWorker.js'; import { startHostGroupWorker } from './services/hostGroupWorker.js'; +import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js'; // Fail fast in production rather than ship with well-known default secrets. // These defaults are fine for local development but are publicly known, so a @@ -65,4 +66,5 @@ app.listen(config.port, () => { startCatalogWorker(); startPromotionWorker(); startHostGroupWorker(); + startRunPlanScheduleWorker(); }); diff --git a/server/models/jobOutputModel.js b/server/models/jobOutputModel.js new file mode 100644 index 0000000..85c7601 --- /dev/null +++ b/server/models/jobOutputModel.js @@ -0,0 +1,76 @@ +import { config } from '../config.js'; +import { db, parseJson } from '../db.js'; +import { camelJobOutput } from './mappers.js'; +import { isPathInside, path } from '../utils/pathUtils.js'; + +// Keep output artifact visibility identical to job visibility so logs and +// generated files do not leak across users. +function visibleJobClause() { + return `( + j.triggered_by = ? + OR r.visibility = 'shared' + OR r.owner_user_id = ? + OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) + )`; +} + +function outputSelect() { + return ` + SELECT jo.*, j.status AS job_status, j.runplan_id, j.script_id, j.triggered_by, + r.name AS runplan_name, s.name AS script_name + FROM job_outputs jo + JOIN jobs j ON j.id = jo.job_id + LEFT JOIN runplans r ON r.id = j.runplan_id + LEFT JOIN scripts s ON s.id = j.script_id + `; +} + +export function listJobOutputs(userId, isAdmin = false) { + const rows = isAdmin + ? db.prepare(` + ${outputSelect()} + ORDER BY jo.updated_at DESC + LIMIT 250 + `).all() + : db.prepare(` + ${outputSelect()} + WHERE ${visibleJobClause()} + ORDER BY jo.updated_at DESC + LIMIT 250 + `).all(userId, userId, userId); + return rows.map(camelJobOutput); +} + +export function listJobOutputsForJob(jobId, userId, isAdmin = false) { + const rows = isAdmin + ? db.prepare(` + ${outputSelect()} + WHERE jo.job_id = ? + ORDER BY jo.kind + `).all(jobId) + : db.prepare(` + ${outputSelect()} + WHERE jo.job_id = ? AND ${visibleJobClause()} + ORDER BY jo.kind + `).all(jobId, userId, userId, userId); + return rows.map(camelJobOutput); +} + +export function getJobOutput(outputId, userId, isAdmin = false) { + const row = isAdmin + ? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId) + : db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId); + return row ? camelJobOutput(row) : null; +} + +export function getJobOutputFile(outputId, userId, isAdmin = false) { + const row = isAdmin + ? db.prepare(`${outputSelect()} WHERE jo.id = ?`).get(outputId) + : db.prepare(`${outputSelect()} WHERE jo.id = ? AND ${visibleJobClause()}`).get(outputId, userId, userId, userId); + if (!row) return null; + const filePath = path.resolve(row.storage_path); + if (!isPathInside(config.fileLockerDir, filePath)) { + throw new Error('Job output path is outside the configured FileLocker directory'); + } + return { output: camelJobOutput(row), filePath, metadata: parseJson(row.metadata_json, {}) }; +} diff --git a/server/models/mappers.js b/server/models/mappers.js index c9ed5cc..8ff705c 100644 --- a/server/models/mappers.js +++ b/server/models/mappers.js @@ -200,6 +200,55 @@ export function camelRunPlan(row) { }; } +export function camelRunPlanSchedule(row) { + const occurrences = parseJson(row.preview_json, []); + return { + id: row.id, + name: row.name, + description: row.description || '', + runplanId: row.runplan_id, + runplanName: row.runplan_name || null, + scriptId: row.script_id || null, + scriptName: row.script_name || null, + enabled: Boolean(row.enabled), + scheduleType: row.schedule_type || 'once', + startAt: row.start_at, + endAt: row.end_at || '', + timeOfDay: row.time_of_day || '', + intervalValue: row.interval_value || 1, + intervalUnit: row.interval_unit || 'hours', + daysOfWeek: parseJson(row.days_of_week, []), + dayOfMonth: row.day_of_month || null, + monthOfYear: row.month_of_year || null, + nextRunAt: row.next_run_at || '', + lastRunAt: row.last_run_at || '', + lastJobId: row.last_job_id || '', + visibility: row.visibility || 'personal', + ownerUserId: row.owner_user_id || null, + groupId: row.group_id || null, + groupName: row.group_name || null, + preview: occurrences, + createdBy: row.created_by || null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function camelRunPlanScheduleRun(row) { + return { + id: row.id, + scheduleId: row.schedule_id, + runplanId: row.runplan_id, + runplanName: row.runplan_name || null, + jobId: row.job_id || '', + jobStatus: row.job_status || '', + plannedFor: row.planned_for, + status: row.status, + message: row.message || '', + createdAt: row.created_at + }; +} + export function camelCustomVariable(row, includeValue = false) { return { id: row.id, @@ -403,6 +452,29 @@ export function camelJobLog(row) { }; } +export function camelJobOutput(row) { + return { + id: row.id, + jobId: row.job_id, + kind: row.kind, + name: row.name, + mimeType: row.mime_type, + sizeBytes: row.size_bytes || 0, + checksum: row.checksum, + metadata: parseJson(row.metadata_json, {}), + runplanId: row.runplan_id || null, + runplanName: row.runplan_name || null, + scriptId: row.script_id || null, + scriptName: row.script_name || null, + jobStatus: row.job_status || row.status || '', + triggeredBy: row.triggered_by || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + downloadUrl: `/api/job-output/${row.id}/download`, + viewUrl: `/api/job-output/${row.id}/view` + }; +} + export function camelSystemJobAction(row) { return { id: row.id, diff --git a/server/models/runPlanScheduleModel.js b/server/models/runPlanScheduleModel.js new file mode 100644 index 0000000..f7d67c8 --- /dev/null +++ b/server/models/runPlanScheduleModel.js @@ -0,0 +1,330 @@ +import { db, id, json, now, parseJson, visibleClause } from '../db.js'; +import { normalizeRunPlanSchedulePayload } from '../forms/schemas.js'; +import { camelRunPlanSchedule, camelRunPlanScheduleRun } from './mappers.js'; +import { loadVisibleRunPlan } from './runPlanModel.js'; + +const MAX_PREVIEW_OCCURRENCES = 12; + +export function listRunPlanSchedules(userId, isAdmin = false) { + const scope = isAdmin ? '1=1' : visibleClause('rs'); + return db.prepare(` + SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name + FROM runplan_schedules rs + JOIN runplans r ON r.id = rs.runplan_id + LEFT JOIN scripts s ON s.id = r.script_id + LEFT JOIN groups g ON g.id = rs.group_id + WHERE ${scope} + ORDER BY COALESCE(rs.next_run_at, rs.updated_at) ASC, rs.name ASC + `).all(...(isAdmin ? [] : [userId, userId])).map(withPreview).map(camelRunPlanSchedule); +} + +export function getRunPlanSchedule(scheduleId, userId, isAdmin = false) { + const scope = isAdmin ? '1=1' : visibleClause('rs'); + const row = db.prepare(` + SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name, g.name AS group_name + FROM runplan_schedules rs + JOIN runplans r ON r.id = rs.runplan_id + LEFT JOIN scripts s ON s.id = r.script_id + LEFT JOIN groups g ON g.id = rs.group_id + WHERE rs.id = ? AND ${scope} + `).get(scheduleId, ...(isAdmin ? [] : [userId, userId])); + return row ? camelRunPlanSchedule(withPreview(row)) : null; +} + +export function createRunPlanSchedule(body, userId) { + const payload = normalizeRunPlanSchedulePayload(body); + if (!loadVisibleRunPlan(payload.runplanId, userId)) throw new Error('RunPlan not found or not visible'); + const scheduleId = id('sched'); + const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : ''; + db.prepare(` + INSERT INTO runplan_schedules ( + id, name, description, runplan_id, enabled, schedule_type, start_at, end_at, time_of_day, + interval_value, interval_unit, days_of_week, day_of_month, month_of_year, next_run_at, + visibility, owner_user_id, group_id, created_by, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + scheduleId, + payload.name, + payload.description, + payload.runplanId, + payload.enabled ? 1 : 0, + payload.scheduleType, + payload.startAt, + payload.endAt || null, + payload.timeOfDay || null, + payload.intervalValue, + payload.intervalUnit, + json(payload.daysOfWeek, []), + payload.dayOfMonth, + payload.monthOfYear, + nextRunAt, + payload.visibility, + userId, + payload.groupId, + userId, + now(), + now() + ); + return getRunPlanSchedule(scheduleId, userId, false); +} + +export function updateRunPlanSchedule(scheduleId, body, userId, isAdmin = false) { + const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); + if (!existing) return null; + const payload = normalizeRunPlanSchedulePayload({ + ...camelRunPlanSchedule(withPreview(existing)), + ...body, + runplanId: body.runplanId || body.runplan_id || existing.runplan_id + }); + if (!loadVisibleRunPlan(payload.runplanId, userId) && !isAdmin) throw new Error('RunPlan not found or not visible'); + const nextRunAt = payload.enabled ? computeNextRun(payload, new Date(Date.now() - 1000)) : ''; + db.prepare(` + UPDATE runplan_schedules + SET name = ?, description = ?, runplan_id = ?, enabled = ?, schedule_type = ?, start_at = ?, + end_at = ?, time_of_day = ?, interval_value = ?, interval_unit = ?, days_of_week = ?, + day_of_month = ?, month_of_year = ?, next_run_at = ?, visibility = ?, group_id = ?, updated_at = ? + WHERE id = ? + `).run( + payload.name, + payload.description, + payload.runplanId, + payload.enabled ? 1 : 0, + payload.scheduleType, + payload.startAt, + payload.endAt || null, + payload.timeOfDay || null, + payload.intervalValue, + payload.intervalUnit, + json(payload.daysOfWeek, []), + payload.dayOfMonth, + payload.monthOfYear, + nextRunAt, + payload.visibility, + payload.groupId, + now(), + scheduleId + ); + return getRunPlanSchedule(scheduleId, userId, isAdmin); +} + +export function deleteRunPlanSchedule(scheduleId, userId, isAdmin = false) { + const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); + if (!existing) return false; + db.prepare('DELETE FROM runplan_schedules WHERE id = ?').run(scheduleId); + return true; +} + +export function setRunPlanScheduleEnabled(scheduleId, enabled, userId, isAdmin = false) { + const existing = getRawVisibleSchedule(scheduleId, userId, isAdmin); + if (!existing) return null; + const nextRunAt = enabled ? computeNextRun(rowToSchedule(existing), new Date(Date.now() - 1000)) : ''; + db.prepare('UPDATE runplan_schedules SET enabled = ?, next_run_at = ?, updated_at = ? WHERE id = ?') + .run(enabled ? 1 : 0, nextRunAt, now(), scheduleId); + return getRunPlanSchedule(scheduleId, userId, isAdmin); +} + +export function listDueRunPlanSchedules(nowIso = now()) { + return db.prepare(` + SELECT rs.*, r.name AS runplan_name, r.script_id, s.name AS script_name + FROM runplan_schedules rs + JOIN runplans r ON r.id = rs.runplan_id + LEFT JOIN scripts s ON s.id = r.script_id + WHERE rs.enabled = 1 + AND rs.next_run_at IS NOT NULL + AND rs.next_run_at != '' + AND rs.next_run_at <= ? + ORDER BY rs.next_run_at ASC + LIMIT 50 + `).all(nowIso).map((row) => camelRunPlanSchedule(withPreview(row))); +} + +export function recordRunPlanScheduleDispatch(schedule, { jobId = '', plannedFor = '', status = 'started', message = '' } = {}) { + const raw = getRawSchedule(schedule.id); + if (!raw) return null; + const effectivePlannedFor = plannedFor || raw.next_run_at || now(); + const nextRunAt = computeNextRun(rowToSchedule(raw), new Date(Date.now() + 1000)); + const nextEnabled = raw.schedule_type === 'once' && !nextRunAt ? 0 : raw.enabled; + db.prepare(` + INSERT INTO runplan_schedule_runs (id, schedule_id, runplan_id, job_id, planned_for, status, message, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(id('srn'), raw.id, raw.runplan_id, jobId || null, effectivePlannedFor, status, message, now()); + db.prepare(` + UPDATE runplan_schedules + SET last_run_at = ?, last_job_id = ?, next_run_at = ?, enabled = ?, updated_at = ? + WHERE id = ? + `).run(now(), jobId || null, nextRunAt, nextEnabled, now(), raw.id); + return getRawSchedule(raw.id); +} + +export function listRunPlanScheduleRuns(userId, isAdmin = false, limit = 100) { + const scope = isAdmin ? '1=1' : visibleClause('rs'); + return db.prepare(` + SELECT sr.*, r.name AS runplan_name, j.status AS job_status + FROM runplan_schedule_runs sr + JOIN runplan_schedules rs ON rs.id = sr.schedule_id + LEFT JOIN runplans r ON r.id = sr.runplan_id + LEFT JOIN jobs j ON j.id = sr.job_id + WHERE ${scope} + ORDER BY sr.created_at DESC + LIMIT ? + `).all(...(isAdmin ? [limit] : [userId, userId, limit])).map(camelRunPlanScheduleRun); +} + +export function previewRunPlanSchedule(body) { + const payload = normalizeRunPlanSchedulePayload(body); + return previewScheduleOccurrences(payload); +} + +export function previewScheduleOccurrences(schedule, count = MAX_PREVIEW_OCCURRENCES, fromDate = new Date(Date.now() - 1000)) { + const occurrences = []; + let cursor = new Date(fromDate); + for (let i = 0; i < count; i += 1) { + const next = computeNextRun(schedule, cursor); + if (!next) break; + occurrences.push(next); + cursor = new Date(Date.parse(next) + 1000); + } + return occurrences; +} + +export function computeNextRun(schedule, fromDate = new Date()) { + const start = validDate(schedule.startAt || schedule.start_at); + if (!start) return ''; + const end = validDate(schedule.endAt || schedule.end_at); + const from = validDate(fromDate) || new Date(); + const type = schedule.scheduleType || schedule.schedule_type || 'once'; + const after = new Date(Math.max(from.getTime(), start.getTime() - 1000)); + let next = null; + + if (type === 'once') next = start > after ? start : null; + else if (type === 'interval') next = nextIntervalRun(schedule, start, after); + else if (type === 'daily') next = nextDailyRun(schedule, start, after); + else if (type === 'weekly') next = nextWeeklyRun(schedule, start, after); + else if (type === 'monthly') next = nextMonthlyRun(schedule, start, after); + else if (type === 'yearly') next = nextYearlyRun(schedule, start, after); + + if (!next) return ''; + if (end && next > end) return ''; + return next.toISOString(); +} + +function nextIntervalRun(schedule, start, after) { + const value = Math.max(1, Number(schedule.intervalValue ?? schedule.interval_value) || 1); + const unit = schedule.intervalUnit || schedule.interval_unit || 'hours'; + let candidate = new Date(start); + for (let i = 0; i < 10000 && candidate <= after; i += 1) candidate = addInterval(candidate, value, unit); + return candidate > after ? candidate : null; +} + +function nextDailyRun(schedule, start, after) { + return nextByDayScan(schedule, start, after, () => true); +} + +function nextWeeklyRun(schedule, start, after) { + const days = parseScheduleDays(schedule); + const daySet = new Set(days.length ? days : [start.getDay()]); + return nextByDayScan(schedule, start, after, (date) => daySet.has(date.getDay()), 370); +} + +function nextMonthlyRun(schedule, start, after) { + const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate())); + let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime()))); + for (let i = 0; i < 240; i += 1) { + const candidate = withTime(clampMonthDate(cursor.getFullYear(), cursor.getMonth(), day), schedule, start); + if (candidate >= start && candidate > after) return candidate; + cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); + } + return null; +} + +function nextYearlyRun(schedule, start, after) { + const month = Math.min(12, Math.max(1, Number(schedule.monthOfYear ?? schedule.month_of_year) || start.getMonth() + 1)) - 1; + const day = Math.min(31, Math.max(1, Number(schedule.dayOfMonth ?? schedule.day_of_month) || start.getDate())); + const firstYear = Math.max(start.getFullYear(), after.getFullYear()); + for (let year = firstYear; year < firstYear + 25; year += 1) { + const candidate = withTime(clampMonthDate(year, month, day), schedule, start); + if (candidate >= start && candidate > after) return candidate; + } + return null; +} + +function nextByDayScan(schedule, start, after, matches, maxDays = 730) { + let cursor = startOfDay(new Date(Math.max(start.getTime(), after.getTime()))); + for (let i = 0; i < maxDays; i += 1) { + if (matches(cursor)) { + const candidate = withTime(cursor, schedule, start); + if (candidate >= start && candidate > after) return candidate; + } + cursor = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1); + } + return null; +} + +function parseScheduleDays(schedule) { + const raw = schedule.daysOfWeek ?? schedule.days_of_week ?? []; + const days = Array.isArray(raw) ? raw : parseJson(raw, []); + return [...new Set(days.map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b); +} + +function addInterval(date, value, unit) { + const next = new Date(date); + if (unit === 'minutes') next.setMinutes(next.getMinutes() + value); + else if (unit === 'hours') next.setHours(next.getHours() + value); + else if (unit === 'days') next.setDate(next.getDate() + value); + else if (unit === 'months') next.setMonth(next.getMonth() + value); + else if (unit === 'years') next.setFullYear(next.getFullYear() + value); + return next; +} + +function withTime(date, schedule, fallback) { + const time = schedule.timeOfDay || schedule.time_of_day || `${String(fallback.getHours()).padStart(2, '0')}:${String(fallback.getMinutes()).padStart(2, '0')}`; + const [hours, minutes] = String(time).split(':').map(Number); + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0); +} + +function clampMonthDate(year, month, day) { + const lastDay = new Date(year, month + 1, 0).getDate(); + return new Date(year, month, Math.min(day, lastDay)); +} + +function startOfDay(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function validDate(value) { + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function withPreview(row) { + return { + ...row, + preview_json: json(previewScheduleOccurrences(rowToSchedule(row)), []) + }; +} + +function rowToSchedule(row) { + return { + id: row.id, + scheduleType: row.schedule_type, + startAt: row.start_at, + endAt: row.end_at || '', + timeOfDay: row.time_of_day || '', + intervalValue: row.interval_value || 1, + intervalUnit: row.interval_unit || 'hours', + daysOfWeek: parseJson(row.days_of_week, []), + dayOfMonth: row.day_of_month || null, + monthOfYear: row.month_of_year || null + }; +} + +function getRawVisibleSchedule(scheduleId, userId, isAdmin = false) { + const scope = isAdmin ? '1=1' : visibleClause(); + return db.prepare(`SELECT * FROM runplan_schedules WHERE id = ? AND ${scope}`) + .get(scheduleId, ...(isAdmin ? [] : [userId, userId])); +} + +function getRawSchedule(scheduleId) { + return db.prepare('SELECT * FROM runplan_schedules WHERE id = ?').get(scheduleId); +} diff --git a/server/models/settingsModel.js b/server/models/settingsModel.js index 41999e8..45d0ecf 100644 --- a/server/models/settingsModel.js +++ b/server/models/settingsModel.js @@ -1,6 +1,8 @@ import { db, now } from '../db.js'; import { config } from '../config.js'; +const runtimeEditableEnvKeys = new Set(['powershell_bin']); + export function getSetting(key) { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return row ? row.value : undefined; @@ -45,7 +47,7 @@ export function updateSettings(payload) { for (const [key, value] of entries) { // Env-pinned settings are owned by the deployment environment; ignore // attempts to override them so the UI and process never disagree. - if (sourceOf.get(key)?.source === 'env') continue; + if (sourceOf.get(key)?.source === 'env' && !runtimeEditableEnvKeys.has(key)) continue; stmt.run(key, String(value ?? ''), now()); } return settingsPayload(); diff --git a/server/routes/index.js b/server/routes/index.js index 95b3358..4851637 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -11,10 +11,12 @@ import { graphRoutes } from './graphRoutes.js'; import { helpRoutes } from './helpRoutes.js'; import { hostRoutes } from './hostRoutes.js'; import { jobRoutes } from './jobRoutes.js'; +import { jobOutputRoutes } from './jobOutputRoutes.js'; import { logRoutes } from './logRoutes.js'; import { packagingRoutes } from './packagingRoutes.js'; import { psadtRoutes } from './psadtRoutes.js'; import { runPlanRoutes } from './runPlanRoutes.js'; +import { runPlanScheduleRoutes } from './runPlanScheduleRoutes.js'; import { settingsRoutes } from './settingsRoutes.js'; import { systemJobRoutes } from './systemJobRoutes.js'; import { userRoutes } from './userRoutes.js'; @@ -41,6 +43,8 @@ apiRoutes.use('/packaging', packagingRoutes); apiRoutes.use('/intune/applications', applicationRoutes); apiRoutes.use('/intune', governanceRoutes); apiRoutes.use('/runplans', runPlanRoutes); +apiRoutes.use('/schedules', runPlanScheduleRoutes); apiRoutes.use('/jobs', jobRoutes); +apiRoutes.use('/job-output', jobOutputRoutes); apiRoutes.use('/system-jobs', systemJobRoutes); apiRoutes.use('/logs', logRoutes); diff --git a/server/routes/jobOutputRoutes.js b/server/routes/jobOutputRoutes.js new file mode 100644 index 0000000..eddc316 --- /dev/null +++ b/server/routes/jobOutputRoutes.js @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { byJob, content, download, generate, index, view } from '../controllers/jobOutputController.js'; +import { requireAuth } from '../middleware/auth.js'; + +export const jobOutputRoutes = Router(); + +// Job Output artifacts are generated by the backend and stored in FileLocker. +jobOutputRoutes.get('/', requireAuth, index); +jobOutputRoutes.get('/jobs/:jobId', requireAuth, byJob); +jobOutputRoutes.post('/jobs/:jobId/generate', requireAuth, generate); +jobOutputRoutes.get('/:id/content', requireAuth, content); +jobOutputRoutes.get('/:id/view', requireAuth, view); +jobOutputRoutes.get('/:id/download', requireAuth, download); diff --git a/server/routes/runPlanScheduleRoutes.js b/server/routes/runPlanScheduleRoutes.js new file mode 100644 index 0000000..df1d9de --- /dev/null +++ b/server/routes/runPlanScheduleRoutes.js @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { create, destroy, index, preview, runNow, toggle, update } from '../controllers/runPlanScheduleController.js'; +import { requireAuth } from '../middleware/auth.js'; + +export const runPlanScheduleRoutes = Router(); + +// API-first RunPlan automation. The browser only defines schedules; the API +// owns recurrence evaluation, dispatch, history, and execution. +runPlanScheduleRoutes.get('/', requireAuth, index); +runPlanScheduleRoutes.post('/', requireAuth, create); +runPlanScheduleRoutes.post('/preview', requireAuth, preview); +runPlanScheduleRoutes.put('/:id', requireAuth, update); +runPlanScheduleRoutes.delete('/:id', requireAuth, destroy); +runPlanScheduleRoutes.post('/:id/toggle', requireAuth, toggle); +runPlanScheduleRoutes.post('/:id/run-now', requireAuth, runNow); diff --git a/server/services/jobOutputService.js b/server/services/jobOutputService.js new file mode 100644 index 0000000..23630c7 --- /dev/null +++ b/server/services/jobOutputService.js @@ -0,0 +1,307 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import PDFDocument from 'pdfkit'; +import { config } from '../config.js'; +import { db, id, json, now } from '../db.js'; +import { path, sanitizeFileName } from '../utils/pathUtils.js'; +import { logger } from './logger.js'; + +const terminalStatuses = new Set(['completed', 'failed', 'canceled']); + +function stripAnsi(value = '') { + return String(value).replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); +} + +function artifactDir(jobId) { + return path.join(config.fileLockerDir, 'job-output', sanitizeFileName(jobId, 'job')); +} + +function checksumBuffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +function safeArtifactName(job, suffix) { + const script = sanitizeFileName(job.script_name || 'script', 'script'); + return sanitizeFileName(`${job.id}-${script}-${suffix}`, `${job.id}-${suffix}`); +} + +function getJobRecord(jobId) { + return db.prepare(` + SELECT j.*, r.name AS runplan_name, s.name AS script_name + FROM jobs j + LEFT JOIN runplans r ON r.id = j.runplan_id + LEFT JOIN scripts s ON s.id = j.script_id + WHERE j.id = ? + `).get(jobId); +} + +function getHostRows(jobId) { + return db.prepare(` + SELECT jh.*, h.name AS host_name, h.address AS host_address + FROM job_hosts jh + LEFT JOIN hosts h ON h.id = jh.host_id + WHERE jh.job_id = ? + ORDER BY COALESCE(h.name, jh.host_id, jh.id) + `).all(jobId); +} + +function getLogs(jobId) { + return db.prepare(` + SELECT jl.*, h.name AS host_name + FROM job_logs jl + LEFT JOIN hosts h ON h.id = jl.host_id + WHERE jl.job_id = ? + ORDER BY jl.created_at, jl.id + `).all(jobId); +} + +function stdoutByHost(hosts, logs) { + const buckets = new Map(hosts.map((host) => [host.host_id || host.id, { + hostId: host.host_id || '', + hostName: host.host_name || host.host_id || 'Unassigned host', + hostAddress: host.host_address || '', + status: host.status, + exitCode: host.exit_code, + lines: [] + }])); + for (const log of logs.filter((entry) => entry.stream === 'stdout')) { + const key = log.host_id || 'job'; + if (!buckets.has(key)) { + buckets.set(key, { + hostId: log.host_id || '', + hostName: log.host_name || 'Job', + hostAddress: '', + status: '', + exitCode: null, + lines: [] + }); + } + buckets.get(key).lines.push(stripAnsi(log.line)); + } + return [...buckets.values()]; +} + +function terminalTranscript(logs) { + return logs.map((log) => { + const host = log.host_name || log.host_id || 'system'; + return `[${log.created_at}] [${String(log.stream || '').toUpperCase()}] [${host}] ${stripAnsi(log.line)}`; + }).join('\n') + '\n'; +} + +function jsonRows(lines) { + const text = lines.join('\n').trim(); + if (!text) return null; + try { + const parsed = JSON.parse(text); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + if (rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) return { rows, parser: 'json' }; + } catch { + // Try line-delimited JSON next. + } + const parsedLines = []; + for (const line of lines.map((item) => item.trim()).filter(Boolean)) { + try { + const parsed = JSON.parse(line); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + parsedLines.push(parsed); + } catch { + return null; + } + } + return parsedLines.length ? { rows: parsedLines, parser: 'json-lines' } : null; +} + +function splitDelimited(line, delimiter) { + if (delimiter === '\t') return line.split('\t').map((part) => part.trim()); + return line.split(delimiter).map((part) => part.trim()); +} + +function delimitedRows(lines) { + const clean = lines.map((line) => line.trim()).filter(Boolean); + if (clean.length < 2) return null; + const delimiter = ['\t', ',', '|'].find((candidate) => splitDelimited(clean[0], candidate).length > 1); + if (!delimiter) return null; + const headers = splitDelimited(clean[0], delimiter).filter(Boolean); + if (headers.length < 2) return null; + const rows = clean.slice(1).map((line) => { + const values = splitDelimited(line, delimiter); + if (values.length < 2) return null; + return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); + }).filter(Boolean); + return rows.length ? { rows, parser: delimiter === '\t' ? 'tsv' : delimiter === ',' ? 'csv' : 'pipe-delimited' } : null; +} + +function powershellTableRows(lines) { + const clean = lines.map((line) => line.replace(/\s+$/g, '')).filter((line) => line.trim()); + if (clean.length < 3) return null; + const separatorIndex = clean.findIndex((line, index) => index > 0 && /^[-\s]+$/.test(line) && line.trim().includes('-')); + if (separatorIndex < 1) return null; + const headers = clean[separatorIndex - 1].trim().split(/\s{2,}/).filter(Boolean); + if (headers.length < 2) return null; + const rows = clean.slice(separatorIndex + 1).map((line) => { + const values = line.trim().split(/\s{2,}/); + if (!values.length) return null; + return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])); + }).filter(Boolean); + return rows.length ? { rows, parser: 'powershell-table' } : null; +} + +function powershellObjectRows(lines) { + const clean = lines.map((line) => line.trim()).filter(Boolean); + if (!clean.length || !clean.every((line) => /^[^:]+:\s*.*$/.test(line))) return null; + const row = {}; + for (const line of clean) { + const index = line.indexOf(':'); + const key = line.slice(0, index).trim(); + if (!key) return null; + row[key] = line.slice(index + 1).trim(); + } + return Object.keys(row).length > 1 ? { rows: [row], parser: 'powershell-object' } : null; +} + +function normalizeCellValue(value) { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function parseHostRows(host) { + const parsed = jsonRows(host.lines) || delimitedRows(host.lines) || powershellTableRows(host.lines) || powershellObjectRows(host.lines); + if (parsed) { + return { + parser: parsed.parser, + rows: parsed.rows.map((row) => ({ + Host: host.hostName, + HostStatus: host.status || '', + ...Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCellValue(value)])) + })) + }; + } + const lastLine = [...host.lines].reverse().find((line) => line.trim()) || ''; + return { + parser: 'stdout-last-line', + rows: [{ + Host: host.hostName, + HostStatus: host.status || '', + Output: lastLine + }] + }; +} + +function tablePayload(job, hosts) { + const parsedHosts = hosts.map(parseHostRows); + const rows = parsedHosts.flatMap((entry) => entry.rows).map((row, index) => ({ id: `row-${index + 1}`, ...row })); + const keys = [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => key !== 'id')))]; + const preferred = ['Host', 'HostStatus', 'Output']; + const ordered = [...preferred.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferred.includes(key))]; + return { + jobId: job.id, + runplanName: job.runplan_name || '', + scriptName: job.script_name || '', + generatedAt: now(), + parsers: [...new Set(parsedHosts.map((entry) => entry.parser))], + columns: ordered.map((key) => ({ key, label: key })), + rows + }; +} + +async function pdfBuffer(job, hosts) { + return new Promise((resolve, reject) => { + const chunks = []; + const doc = new PDFDocument({ autoFirstPage: false, margins: { top: 54, bottom: 48, left: 48, right: 48 } }); + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('error', reject); + doc.on('end', () => resolve(Buffer.concat(chunks))); + + doc.addPage(); + doc.font('Helvetica-Bold').fontSize(18).fillColor('#101827').text('POSHManager Job STDOUT Report'); + doc.moveDown(0.35); + doc.font('Helvetica').fontSize(9).fillColor('#45556c') + .text(`Job: ${job.id}`) + .text(`Script: ${job.script_name || 'Unknown script'}`) + .text(`RunPlan: ${job.runplan_name || 'Script test / ad hoc run'}`) + .text(`Status: ${job.status}`) + .text(`Generated: ${new Date().toISOString()}`); + doc.moveDown(1); + + for (const host of hosts) { + if (doc.y > 680) doc.addPage(); + doc.font('Helvetica-Bold').fontSize(12).fillColor('#101827').text(host.hostName || 'Host'); + doc.font('Helvetica').fontSize(8).fillColor('#64748b') + .text(`Address: ${host.hostAddress || '-'} Status: ${host.status || '-'} Exit code: ${host.exitCode ?? '-'}`); + doc.moveDown(0.4); + const lines = host.lines.length ? host.lines : ['(No STDOUT captured for this host.)']; + doc.font('Courier').fontSize(8).fillColor('#111827'); + for (const line of lines) { + if (doc.y > 730) { + doc.addPage(); + doc.font('Courier').fontSize(8).fillColor('#111827'); + } + doc.text(line || ' ', { width: 500 }); + } + doc.moveDown(0.9); + } + doc.end(); + }); +} + +function writeArtifact(job, kind, name, mimeType, content, metadata) { + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); + const dir = artifactDir(job.id); + fs.mkdirSync(dir, { recursive: true }); + const storagePath = path.join(dir, sanitizeFileName(name)); + fs.writeFileSync(storagePath, buffer); + const checksum = checksumBuffer(buffer); + const timestamp = now(); + const outputId = db.prepare('SELECT id FROM job_outputs WHERE job_id = ? AND kind = ?').get(job.id, kind)?.id || id('jout'); + db.prepare(` + INSERT INTO job_outputs (id, job_id, kind, name, mime_type, storage_path, size_bytes, checksum, metadata_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(job_id, kind) DO UPDATE SET + name = excluded.name, + mime_type = excluded.mime_type, + storage_path = excluded.storage_path, + size_bytes = excluded.size_bytes, + checksum = excluded.checksum, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at + `).run(outputId, job.id, kind, name, mimeType, storagePath, buffer.length, checksum, json(metadata, {}), timestamp, timestamp); +} + +export async function generateJobOutputs(jobId) { + const job = getJobRecord(jobId); + if (!job || !terminalStatuses.has(job.status)) return []; + const hosts = stdoutByHost(getHostRows(jobId), getLogs(jobId)); + const logs = getLogs(jobId); + const table = tablePayload(job, hosts); + const pdf = await pdfBuffer(job, hosts); + const stdoutLineCount = hosts.reduce((sum, host) => sum + host.lines.length, 0); + const metadata = { + hostCount: hosts.length, + stdoutLineCount, + generatedAt: now() + }; + + writeArtifact(job, 'stdout-pdf', safeArtifactName(job, 'stdout.pdf'), 'application/pdf', pdf, metadata); + writeArtifact(job, 'datatable', safeArtifactName(job, 'datatable.json'), 'application/json', JSON.stringify(table, null, 2), { + ...metadata, + rowCount: table.rows.length, + columns: table.columns.map((column) => column.key), + parsers: table.parsers + }); + writeArtifact(job, 'terminal', safeArtifactName(job, 'terminal.log'), 'text/plain', terminalTranscript(logs), { + ...metadata, + logLineCount: logs.length + }); + + return db.prepare('SELECT * FROM job_outputs WHERE job_id = ? ORDER BY kind').all(jobId); +} + +export async function generateJobOutputsSafe(jobId) { + try { + return await generateJobOutputs(jobId); + } catch (error) { + logger.error('failed to generate job outputs', { jobId, error }); + return []; + } +} diff --git a/server/services/powershellRunner.js b/server/services/powershellRunner.js index 4d853f5..9b5d84b 100644 --- a/server/services/powershellRunner.js +++ b/server/services/powershellRunner.js @@ -13,6 +13,7 @@ import { getVCenterVmPowerState } from './vcenterService.js'; import { path } from '../utils/pathUtils.js'; import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js'; import { normalizeOsFamily } from '../utils/osFamily.js'; +import { generateJobOutputsSafe } from './jobOutputService.js'; const activeJobs = new Map(); @@ -42,6 +43,19 @@ export function powerShellSpawnFailureLines(error, executable) { return lines; } +export function powerShellSignalFailureLines(signal, executable) { + const lines = [ + `PowerShell executable "${executable}" terminated by signal ${signal}.`, + `API process platform: ${process.platform}; PATH: ${process.env.PATH || '(empty)'}` + ]; + if (signal === 'SIGABRT') { + lines.push('SIGABRT usually means the PowerShell/.NET process aborted after launch, not that POSHManager could not find the executable.'); + lines.push('On Linux API hosts, verify the configured pwsh can run non-interactively with: pwsh -NoProfile -Command "$PSVersionTable".'); + lines.push('For Windows remoting targets, also verify PowerShell remoting/WinRM connectivity from the API host using the same credential and target address.'); + } + return lines; +} + export function executeRunPlan(runplanId, userId) { // Create the durable job record before async execution so the UI can attach to logs immediately. const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId); @@ -88,9 +102,10 @@ export function executeRunPlan(runplanId, userId) { assets: listExecutableAssets(userId) }; - void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => { + void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch(async (error) => { logger.error('job runner crashed', { jobId, error }); db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId); + await generateJobOutputsSafe(jobId); }); return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId); @@ -154,7 +169,7 @@ export function executeScriptTest(scriptId, payload, userId) { INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at) VALUES (?, NULL, ?, 'running', ?, ?, ?) `).run(jobId, script.id, userId, now(), now()); - db.prepare(` + const insertJobHost = db.prepare(` INSERT INTO job_hosts (id, job_id, host_id, status, started_at) VALUES (?, ?, ?, 'queued', NULL) `); @@ -172,10 +187,11 @@ export function executeScriptTest(scriptId, payload, userId) { }; for (const host of hosts) appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`); - void runJob(jobId, script, hosts, false, executionContext).catch((error) => { + void runJob(jobId, script, hosts, false, executionContext).catch(async (error) => { logger.error('script test runner crashed', { jobId, error }); for (const host of hosts) appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`); db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId); + await generateJobOutputsSafe(jobId); }); return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId); @@ -203,10 +219,12 @@ async function runJob(jobId, script, hosts, parallel, executionContext) { if (isJobCanceled(jobId)) { cancelQueuedHosts(jobId); db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId); + await generateJobOutputsSafe(jobId); return; } const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count; db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId); + await generateJobOutputsSafe(jobId); } finally { activeJobs.delete(jobId); } @@ -238,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) { } if (!(await vCenterPowerPreflight(jobId, host))) return; + logTransportPreflight(jobId, host); const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`); @@ -284,7 +303,13 @@ async function vCenterPowerPreflight(jobId, host) { return true; } -function buildWrapper(content, host, executionContext = {}) { +function logTransportPreflight(jobId, host) { + if (host.transport === 'winrm' && process.platform !== 'win32') { + appendLog(jobId, host.id, 'system', 'WinRM target from a Linux API host: Invoke-Command -ComputerName requires WSMan client support inside the API environment. If PowerShell reports no supported WSMan client library, install/configure the Linux WSMan dependencies or use SSH transport for PowerShell 7 targets.'); + } +} + +export function buildWrapper(content, host, executionContext = {}) { // Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals. const runtimeVariables = runtimeVariableValues(host, executionContext); const customVariables = executionContext.customVariables || []; @@ -296,37 +321,53 @@ function buildWrapper(content, host, executionContext = {}) { const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]); const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`; const escaped = scriptWithPrelude.replace(/'@/g, "'`@"); + const scriptSetup = [ + '$ErrorActionPreference = "Stop"', + '$ProgressPreference = "SilentlyContinue"', + 'if (Get-Variable -Name PSStyle -ErrorAction SilentlyContinue) { $PSStyle.OutputRendering = "PlainText" }', + 'try {' + ]; + const scriptExit = [ + ' if ($global:LASTEXITCODE -is [int] -and $global:LASTEXITCODE -ne 0) { exit $global:LASTEXITCODE }', + ' exit 0', + '} catch {', + ' $message = ($_ | Out-String).TrimEnd()', + ' if (-not $message) { $message = $_.Exception.Message }', + ' [Console]::Error.WriteLine($message)', + ' exit 1', + '}' + ]; + const scriptTextBlock = [ + ` $scriptText = @'`, + escaped, + "'@", + ' $scriptBlock = [ScriptBlock]::Create($scriptText)' + ]; if (host.transport === 'local') { return [ - '$ErrorActionPreference = "Continue"', - `$scriptText = @'`, - escaped, - "'@", - '$scriptBlock = [ScriptBlock]::Create($scriptText)', - '& $scriptBlock' + ...scriptSetup, + ...scriptTextBlock, + ' & $scriptBlock', + ...scriptExit ].join('\n'); } if (host.transport === 'ssh') { return [ - '$ErrorActionPreference = "Continue"', - `$scriptText = @'`, - escaped, - "'@", - '$scriptBlock = [ScriptBlock]::Create($scriptText)', - `Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock` + ...scriptSetup, + ...scriptTextBlock, + ` Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock -ErrorAction Stop`, + ...scriptExit ].join('\n'); } return [ - '$ErrorActionPreference = "Continue"', - '$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force', - '$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)', - `$scriptText = @'`, - escaped, - "'@", - '$scriptBlock = [ScriptBlock]::Create($scriptText)', - `Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock` + ...scriptSetup, + ' $securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force', + ' $credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)', + ...scriptTextBlock, + ` Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock -ErrorAction Stop`, + ...scriptExit ].join('\n'); } @@ -380,7 +421,9 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) { }); child.on('close', (code, signal) => { if (settled) return; - if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`); + if (signal) { + for (const line of powerShellSignalFailureLines(signal, executable)) appendLog(jobId, host.id, 'stderr', line); + } if (isJobCanceled(jobId)) { settle('canceled', 130); return; diff --git a/server/services/runPlanScheduleWorker.js b/server/services/runPlanScheduleWorker.js new file mode 100644 index 0000000..b5a5079 --- /dev/null +++ b/server/services/runPlanScheduleWorker.js @@ -0,0 +1,71 @@ +import { getStringSetting } from '../models/settingsModel.js'; +import { + listDueRunPlanSchedules, + recordRunPlanScheduleDispatch +} from '../models/runPlanScheduleModel.js'; +import { executeRunPlan } from './powershellRunner.js'; +import { logger } from './logger.js'; + +let timer = null; +let running = false; + +export function runPlanScheduleIntervalMinutes() { + const minutes = Number(getStringSetting('runplan_schedule_interval_minutes', process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1')); + return Number.isFinite(minutes) ? minutes : 1; +} + +export async function dispatchDueRunPlanSchedules({ nowIso = new Date().toISOString(), signal } = {}) { + const due = listDueRunPlanSchedules(nowIso); + const summary = { evaluated: due.length, dispatched: 0, skipped: 0, errors: 0 }; + for (const schedule of due) { + if (signal?.aborted) { + summary.skipped += 1; + break; + } + try { + const job = executeRunPlan(schedule.runplanId, schedule.createdBy || schedule.ownerUserId || null); + recordRunPlanScheduleDispatch(schedule, { + jobId: job.id, + plannedFor: schedule.nextRunAt || nowIso, + status: 'queued', + message: `Scheduled RunPlan ${schedule.runplanName || schedule.runplanId} queued as ${job.id}.` + }); + summary.dispatched += 1; + } catch (error) { + recordRunPlanScheduleDispatch(schedule, { + plannedFor: schedule.nextRunAt || nowIso, + status: 'failed', + message: error.message + }); + summary.errors += 1; + logger.error('runplan schedule dispatch failed', { scheduleId: schedule.id, error: error.message }); + } + } + return summary; +} + +export function startRunPlanScheduleWorker() { + const minutes = runPlanScheduleIntervalMinutes(); + if (minutes <= 0) return null; + running = true; + scheduleNext(minutes); + logger.info(`runplan schedule dispatcher scheduled every ${minutes} minute(s)`); + return timer; +} + +export function stopRunPlanScheduleWorker() { + running = false; + if (timer) clearTimeout(timer); + timer = null; +} + +function scheduleNext(minutes) { + if (!running || minutes <= 0) return; + timer = setTimeout(() => { + dispatchDueRunPlanSchedules() + .then((summary) => logger.info('runplan schedule dispatch complete', summary)) + .catch((error) => logger.error('runplan schedule dispatch failed', { error: error.message })) + .finally(() => scheduleNext(runPlanScheduleIntervalMinutes())); + }, minutes * 60 * 1000); + timer.unref?.(); +} diff --git a/server/services/systemJobService.js b/server/services/systemJobService.js index 3ffcc71..a31739e 100644 --- a/server/services/systemJobService.js +++ b/server/services/systemJobService.js @@ -4,6 +4,7 @@ import { listSystemJobActions, recordSystemJobAction } from '../models/systemJob import { syncDynamicHostGroups } from '../models/hostGroupModel.js'; import { runCatalogChecks } from './catalogWorker.js'; import { runAutoPromotions } from './promotionWorker.js'; +import { dispatchDueRunPlanSchedules } from './runPlanScheduleWorker.js'; import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js'; const backgroundRuns = new Map(); @@ -29,6 +30,13 @@ const BACKGROUND_JOBS = [ category: 'Intune', description: 'Promotes deployment rings when configured soak windows elapse.', run: async ({ signal } = {}) => runAutoPromotions({ signal }) + }, + { + id: 'worker:runplan-schedule-dispatch', + name: 'RunPlan Schedule Dispatch', + category: 'Scheduling', + description: 'Queues due automated RunPlan schedules and records dispatch history.', + run: async ({ signal } = {}) => dispatchDueRunPlanSchedules({ signal }) } ]; diff --git a/server/services/vcenterService.js b/server/services/vcenterService.js index 709af07..0a299c7 100644 --- a/server/services/vcenterService.js +++ b/server/services/vcenterService.js @@ -784,6 +784,35 @@ function soapRefElement(name, ref, fallbackType) { return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}`; } +function soapBoolElement(name, value) { + return `<${name}>${value ? 'true' : 'false'}`; +} + +function propertySpecXml(type, paths = []) { + return `` + + `${xmlEscape(type)}` + + soapBoolElement('all', false) + + paths.map((pathSet) => `${xmlEscape(pathSet)}`).join('') + + ``; +} + +function objectSpecXml(ref, { skip = false, selectSet = '' } = {}) { + return `` + + soapRefElement('obj', ref, ref?.type || 'ManagedObjectReference') + + soapBoolElement('skip', skip) + + selectSet + + ``; +} + +function traversalSpecXml() { + return `` + + `containerViewTraversal` + + `ContainerView` + + `view` + + soapBoolElement('skip', false) + + ``; +} + async function connectStandaloneSoap(settings, trace = null) { pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, { mode: 'web-services', @@ -826,7 +855,7 @@ async function createVmContainerView(settings, refs, trace = null) { return extractManagedObject(response, 'returnval', 'ContainerView'); } -function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) { +export function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) { const paths = [ 'name', 'guest.hostName', @@ -841,12 +870,10 @@ function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) { 'summary.runtime.powerState' ]; return `` + - `${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` + + soapRefElement('_this', propertyCollector, 'PropertyCollector') + `` + - `VirtualMachinefalse${paths.map((pathSet) => `${pathSet}`).join('')}` + - `${soapRefElement('obj', viewRef, 'ContainerView')}true` + - `containerViewTraversalContainerViewviewfalse` + - `` + + propertySpecXml('VirtualMachine', paths) + + objectSpecXml(viewRef, { skip: true, selectSet: traversalSpecXml() }) + `` + `${Math.max(1, Math.min(Number(limit) || 100, 500))}` + ``; @@ -888,9 +915,11 @@ async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) { const refs = await connectStandaloneSoap(settings, trace); const vmRef = { type: 'VirtualMachine', value: vmId }; const body = `` + - `${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` + - `VirtualMachinefalse>runtime.powerState` + - `${soapRefElement('obj', vmRef, 'VirtualMachine')}false` + + soapRefElement('_this', refs.propertyCollector, 'PropertyCollector') + + `` + + propertySpecXml('VirtualMachine', ['runtime.powerState']) + + objectSpecXml(vmRef, { skip: false }) + + `` + `1`; const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint }); const object = parseVmPropertyObjects(response)[0]; diff --git a/server/settingsRegistry.js b/server/settingsRegistry.js index a874be4..81e88a3 100644 --- a/server/settingsRegistry.js +++ b/server/settingsRegistry.js @@ -33,9 +33,10 @@ export function settingDefinitions() { { key: 'entra_client_id', value: config.entra.clientId, pinned: envProvided('ENTRA_CLIENT_ID') }, { key: 'entra_callback_url', value: config.entra.callbackUrl, pinned: envProvided('ENTRA_CALLBACK_URL') }, { key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') }, - { key: 'powershell_bin', value: config.powershellBin, pinned: envProvided('POWERSHELL_BIN') }, + { key: 'powershell_bin', value: config.powershellBin, pinned: false }, { key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') }, { key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') }, + { key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') }, { key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') }, { key: 'vcenter_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') }, { key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') }, diff --git a/server/test/job-output.test.mjs b/server/test/job-output.test.mjs new file mode 100644 index 0000000..d32c119 --- /dev/null +++ b/server/test/job-output.test.mjs @@ -0,0 +1,159 @@ +import { db, load, makeUser } from './setup.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const { createHost } = await load('../models/hostModel.js'); +const { createCredential } = await load('../models/credentialModel.js'); +const { updateSettings } = await load('../models/settingsModel.js'); +const { generateJobOutputs } = await load('../services/jobOutputService.js'); +const { getJobOutputFile, listJobOutputs, listJobOutputsForJob } = await load('../models/jobOutputModel.js'); +const { executeScriptTest } = await load('../services/powershellRunner.js'); + +const owner = makeUser({ email: 'job-output-owner@test.local' }); +const stranger = makeUser({ email: 'job-output-stranger@test.local' }); +const ts = new Date().toISOString(); + +function addLog(jobId, hostId, stream, line, offset = 0) { + db.prepare(` + INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(`log_output_${jobId}_${hostId}_${stream}_${offset}`, jobId, hostId, stream, line, new Date(Date.parse(ts) + offset).toISOString()); +} + +test('job output generation writes stdout PDF, datatable JSON, and terminal transcript artifacts', async () => { + const scriptId = 'scr_job_output_test'; + const jobId = 'job_output_test'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Inventory Table.ps1', 'Get-Process', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts); + const hostA = createHost({ name: 'Output Host A', address: 'host-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + const hostB = createHost({ name: 'Output Host B', address: 'host-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at) + VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_a', jobId, hostA.id, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_output_b', jobId, hostB.id, ts, ts); + addLog(jobId, hostA.id, 'stdout', 'Name,Status', 1); + addLog(jobId, hostA.id, 'stdout', 'Spooler,Running', 2); + addLog(jobId, hostB.id, 'stdout', 'Name,Status', 3); + addLog(jobId, hostB.id, 'stdout', 'WinRM,Running', 4); + addLog(jobId, hostB.id, 'stderr', 'Ignored by stdout PDF', 5); + + await generateJobOutputs(jobId); + const outputs = listJobOutputsForJob(jobId, owner, false); + const kinds = outputs.map((output) => output.kind).sort(); + + assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']); + assert.equal(listJobOutputs(stranger, false).some((output) => output.jobId === jobId), false); + + const tableOutput = outputs.find((output) => output.kind === 'datatable'); + const tableFile = getJobOutputFile(tableOutput.id, owner, false); + const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8')); + assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status']); + assert.equal(table.rows.length, 2); + assert.ok(table.rows.some((row) => row.Host === 'Output Host A' && row.HostStatus === 'completed' && row.Name === 'Spooler' && row.Status === 'Running')); + assert.ok(table.rows.some((row) => row.Host === 'Output Host B' && row.HostStatus === 'completed' && row.Name === 'WinRM' && row.Status === 'Running')); + + const pdfOutput = outputs.find((output) => output.kind === 'stdout-pdf'); + const pdfFile = getJobOutputFile(pdfOutput.id, owner, false); + assert.equal(pdfOutput.mimeType, 'application/pdf'); + assert.equal(fs.readFileSync(pdfFile.filePath).subarray(0, 4).toString(), '%PDF'); + + const terminalOutput = outputs.find((output) => output.kind === 'terminal'); + const terminalFile = getJobOutputFile(terminalOutput.id, owner, false); + const transcript = fs.readFileSync(terminalFile.filePath, 'utf8'); + assert.match(transcript, /Ignored by stdout PDF/); +}); + +test('script test runs generate the same FileLocker job output artifacts', async () => { + const scriptOwner = makeUser({ email: 'job-output-script-test@test.local' }); + const scriptId = 'scr_job_output_script_test'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Script Test Output.ps1', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(scriptId, scriptOwner, ts, ts); + const credential = createCredential({ + name: 'Output Test Credential', + kind: 'username_password', + username: 'TEST\\svc', + secret: 'secret', + visibility: 'personal' + }, scriptOwner); + const host = createHost({ + name: 'Output Test Host', + address: 'output-test.local', + transport: 'local', + osFamily: 'linux', + credentialId: credential.id, + visibility: 'personal' + }, scriptOwner); + updateSettings({ allow_script_execution: 'false' }); + + const job = executeScriptTest(scriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner); + await new Promise((resolve) => setTimeout(resolve, 50)); + const outputs = listJobOutputsForJob(job.id, scriptOwner, false); + const kinds = outputs.map((output) => output.kind).sort(); + + assert.deepEqual(kinds, ['datatable', 'stdout-pdf', 'terminal']); + updateSettings({ allow_script_execution: 'true' }); +}); + +test('job output datatable uses one last-stdout row per host for unstructured output', async () => { + const scriptId = 'scr_job_output_last_line_test'; + const jobId = 'job_output_last_line_test'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Last Line.ps1', 'Write-Output lines', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts); + const hostA = createHost({ name: 'Last Host A', address: 'last-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + const hostB = createHost({ name: 'Last Host B', address: 'last-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at) + VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_a', jobId, hostA.id, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_last_b', jobId, hostB.id, ts, ts); + addLog(jobId, hostA.id, 'stdout', 'starting host A', 1); + addLog(jobId, hostA.id, 'stdout', 'final host A', 2); + addLog(jobId, hostB.id, 'stdout', 'starting host B', 3); + addLog(jobId, hostB.id, 'stdout', '', 4); + addLog(jobId, hostB.id, 'stdout', 'final host B', 5); + + await generateJobOutputs(jobId); + const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable'); + const tableFile = getJobOutputFile(tableOutput.id, owner, false); + const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8')); + + assert.deepEqual(table.parsers, ['stdout-last-line']); + assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Output']); + assert.equal(table.rows.length, 2); + assert.ok(table.rows.some((row) => row.Host === 'Last Host A' && row.Output === 'final host A')); + assert.ok(table.rows.some((row) => row.Host === 'Last Host B' && row.Output === 'final host B')); + assert.equal(table.rows.some((row) => row.Output === 'starting host A' || row.Output === 'starting host B'), false); +}); + +test('job output datatable carries fields for structured object and array stdout', async () => { + const scriptId = 'scr_job_output_structured_test'; + const jobId = 'job_output_structured_test'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Structured.ps1', 'ConvertTo-Json', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts); + const hostA = createHost({ name: 'Structured Host A', address: 'structured-a.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + const hostB = createHost({ name: 'Structured Host B', address: 'structured-b.local', transport: 'local', osFamily: 'linux', visibility: 'personal' }, owner); + db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, ended_at, created_at) + VALUES (?, NULL, ?, 'completed', ?, ?, ?, ?)`).run(jobId, scriptId, owner, ts, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_a', jobId, hostA.id, ts, ts); + db.prepare(`INSERT INTO job_hosts (id, job_id, host_id, status, exit_code, started_at, ended_at) + VALUES (?, ?, ?, 'completed', 0, ?, ?)`).run('jhost_structured_b', jobId, hostB.id, ts, ts); + addLog(jobId, hostA.id, 'stdout', '{"Name":"SvcA","Status":"Running","Version":1}', 1); + addLog(jobId, hostB.id, 'stdout', '[{"Name":"SvcB","Status":"Running"},{"Name":"SvcC","Status":"Stopped"}]', 2); + + await generateJobOutputs(jobId); + const tableOutput = listJobOutputsForJob(jobId, owner, false).find((output) => output.kind === 'datatable'); + const tableFile = getJobOutputFile(tableOutput.id, owner, false); + const table = JSON.parse(fs.readFileSync(tableFile.filePath, 'utf8')); + + assert.deepEqual(table.parsers, ['json']); + assert.deepEqual(table.columns.map((column) => column.key), ['Host', 'HostStatus', 'Name', 'Status', 'Version']); + assert.equal(table.rows.length, 3); + assert.ok(table.rows.some((row) => row.Host === 'Structured Host A' && row.Name === 'SvcA' && row.Version === '1')); + assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcB' && row.Status === 'Running')); + assert.ok(table.rows.some((row) => row.Host === 'Structured Host B' && row.Name === 'SvcC' && row.Status === 'Stopped')); +}); diff --git a/server/test/jobs.test.mjs b/server/test/jobs.test.mjs index 1e30d86..9dd6344 100644 --- a/server/test/jobs.test.mjs +++ b/server/test/jobs.test.mjs @@ -4,6 +4,10 @@ import assert from 'node:assert/strict'; const { listJobs, getJob } = await load('../models/jobModel.js'); const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.js'); +const { createCredential } = await load('../models/credentialModel.js'); +const { createHost } = await load('../models/hostModel.js'); +const { updateSettings } = await load('../models/settingsModel.js'); +const { buildWrapper, executeScriptTest } = await load('../services/powershellRunner.js'); const owner = makeUser({ email: 'job-owner@test.local' }); const stranger = makeUser({ email: 'job-stranger@test.local' }); @@ -52,6 +56,68 @@ test('admins can see any job', () => { assert.ok(getJob(jobId, admin, true)); }); +test('executeScriptTest creates job host rows before runner starts', async () => { + const scriptOwner = makeUser({ email: 'script-test-owner@test.local' }); + const localScriptId = 'scr_execute_test_1'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Script Execute Test', 'Write-Output test', 'personal', ?, 1, ?, ?)`).run(localScriptId, scriptOwner, ts, ts); + const credential = createCredential({ + name: 'Script Test Credential', + kind: 'username_password', + username: 'TEST\\svc', + secret: 'secret', + visibility: 'personal' + }, scriptOwner); + const host = createHost({ + name: 'Script Test Host', + address: 'script-test.local', + fqdn: 'script-test.local', + osFamily: 'windows', + transport: 'winrm', + credentialId: credential.id, + visibility: 'personal', + tags: [] + }, scriptOwner); + updateSettings({ allow_script_execution: 'false' }); + + const job = executeScriptTest(localScriptId, { hostId: host.id, credentialId: credential.id }, scriptOwner); + await new Promise((resolve) => setTimeout(resolve, 25)); + + const jobHost = db.prepare('SELECT * FROM job_hosts WHERE job_id = ? AND host_id = ?').get(job.id, host.id); + const errorLog = db.prepare('SELECT * FROM job_logs WHERE job_id = ? AND stream = ? AND line LIKE ?').get(job.id, 'stderr', '%Script execution is disabled%'); + assert.equal(jobHost.status, 'failed'); + assert.ok(errorLog); + updateSettings({ allow_script_execution: 'true' }); +}); + +test('PowerShell wrappers fail the host when Invoke-Command writes a terminating error', () => { + const wrapper = buildWrapper('Write-Output "remote smoke"', { + id: 'hst_wrapper_winrm', + name: 'Wrapper WinRM', + address: 'dallas.pucknet.local', + transport: 'winrm' + }); + + assert.match(wrapper, /\$ErrorActionPreference = "Stop"/); + assert.match(wrapper, /Invoke-Command -ComputerName 'dallas\.pucknet\.local'.*-ErrorAction Stop/); + assert.match(wrapper, /catch \{/); + assert.match(wrapper, /exit 1/); + assert.doesNotMatch(wrapper, /\$ErrorActionPreference = "Continue"/); +}); + +test('local PowerShell wrappers propagate native command failures', () => { + const wrapper = buildWrapper('Write-Output "local smoke"', { + id: 'hst_wrapper_local', + name: 'Wrapper Local', + address: 'localhost', + transport: 'local' + }); + + assert.match(wrapper, /\$global:LASTEXITCODE/); + assert.match(wrapper, /exit \$global:LASTEXITCODE/); + assert.match(wrapper, /exit 0/); +}); + test('system jobs list execution and background jobs', () => { const listJobId = 'job_list_system_test_1'; db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at) diff --git a/server/test/runplan-schedules.test.mjs b/server/test/runplan-schedules.test.mjs new file mode 100644 index 0000000..e2238de --- /dev/null +++ b/server/test/runplan-schedules.test.mjs @@ -0,0 +1,89 @@ +import { db, load, makeUser } from './setup.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const { createCredential } = await load('../models/credentialModel.js'); +const { createHost } = await load('../models/hostModel.js'); +const { updateSettings } = await load('../models/settingsModel.js'); +const { + computeNextRun, + createRunPlanSchedule, + listDueRunPlanSchedules, + listRunPlanScheduleRuns, + previewScheduleOccurrences +} = await load('../models/runPlanScheduleModel.js'); +const { dispatchDueRunPlanSchedules } = await load('../services/runPlanScheduleWorker.js'); + +const owner = makeUser({ email: 'schedule-owner@test.local' }); +const ts = new Date().toISOString(); + +function createRunnableRunPlan() { + const scriptId = 'scr_schedule_test'; + db.prepare(`INSERT INTO scripts (id, name, content, visibility, owner_user_id, version, created_at, updated_at) + VALUES (?, 'Scheduled Script.ps1', 'Write-Output scheduled', 'personal', ?, 1, ?, ?)`).run(scriptId, owner, ts, ts); + const credential = createCredential({ + name: 'Schedule Credential', + kind: 'username_password', + username: 'TEST\\svc', + secret: 'secret', + visibility: 'personal' + }, owner); + const host = createHost({ + name: 'Schedule Host', + address: 'schedule.local', + transport: 'local', + osFamily: 'linux', + credentialId: credential.id, + visibility: 'personal' + }, owner); + const runplanId = 'run_schedule_test'; + db.prepare(`INSERT INTO runplans (id, name, script_id, visibility, owner_user_id, parallel, created_at, updated_at) + VALUES (?, 'Scheduled RunPlan', ?, 'personal', ?, 1, ?, ?)`).run(runplanId, scriptId, owner, ts, ts); + db.prepare('INSERT INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)').run(runplanId, host.id); + return runplanId; +} + +test('recurrence preview covers weekly and interval schedules', () => { + const weekly = { + scheduleType: 'weekly', + startAt: '2026-06-01T14:00:00.000Z', + timeOfDay: '09:30', + daysOfWeek: [1, 3] + }; + const preview = previewScheduleOccurrences(weekly, 3, new Date('2026-06-02T00:00:00.000Z')); + assert.equal(preview.length, 3); + assert.ok(preview.every((date) => [1, 3].includes(new Date(date).getDay()))); + + const intervalNext = computeNextRun({ + scheduleType: 'interval', + startAt: '2026-06-01T00:00:00.000Z', + intervalValue: 6, + intervalUnit: 'hours' + }, new Date('2026-06-01T07:00:00.000Z')); + assert.equal(intervalNext, '2026-06-01T12:00:00.000Z'); +}); + +test('due RunPlan schedules dispatch jobs and record scheduler history', async () => { + updateSettings({ allow_script_execution: 'false' }); + const runplanId = createRunnableRunPlan(); + const schedule = createRunPlanSchedule({ + name: 'Every minute schedule', + runplanId, + scheduleType: 'interval', + startAt: '2026-01-01T00:00:00.000Z', + intervalValue: 1, + intervalUnit: 'minutes', + visibility: 'personal' + }, owner); + db.prepare('UPDATE runplan_schedules SET next_run_at = ? WHERE id = ?').run('2026-01-01T00:02:00.000Z', schedule.id); + + assert.ok(listDueRunPlanSchedules('2026-01-01T00:02:00.000Z').some((row) => row.id === schedule.id)); + const summary = await dispatchDueRunPlanSchedules({ nowIso: '2026-01-01T00:02:00.000Z' }); + + assert.equal(summary.dispatched, 1); + const job = db.prepare('SELECT * FROM jobs WHERE runplan_id = ? ORDER BY created_at DESC').get(runplanId); + assert.ok(job); + const history = listRunPlanScheduleRuns(owner, false, 20); + assert.ok(history.some((event) => event.scheduleId === schedule.id && event.jobId === job.id)); + updateSettings({ allow_script_execution: 'true' }); +}); diff --git a/server/test/settings.test.mjs b/server/test/settings.test.mjs index ad33b67..f9f990c 100644 --- a/server/test/settings.test.mjs +++ b/server/test/settings.test.mjs @@ -2,8 +2,9 @@ import { db, load } from './setup.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; +const { seed } = await load('../db.js'); const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.js'); -const { resolvePowerShellExecutable, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js'); +const { resolvePowerShellExecutable, powerShellSignalFailureLines, powerShellSpawnFailureLines } = await load('../services/powershellRunner.js'); test('unpinned settings seed as editable defaults', () => { // No SERVER_FQDN env var is set in the test environment, so server_fqdn is a @@ -37,6 +38,24 @@ test('powershell_bin config is used by the runner at execution time', () => { assert.equal(resolvePowerShellExecutable(), 'powershell.exe'); }); +test('powershell_bin can be edited even when older databases marked it env-sourced', () => { + db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('pwsh', 'env', 'powershell_bin'); + const result = updateSettings({ powershell_bin: 'powershell' }); + + assert.equal(result.powershell_bin.value, 'powershell'); + assert.equal(result.powershell_bin.source, 'db'); + assert.equal(resolvePowerShellExecutable(), 'powershell'); +}); + +test('seed releases older env-sourced powershell_bin rows to the current default', () => { + db.prepare('UPDATE settings SET value = ?, source = ? WHERE key = ?').run('old-pwsh', 'env', 'powershell_bin'); + seed(); + const row = db.prepare('SELECT value, source FROM settings WHERE key = ?').get('powershell_bin'); + + assert.equal(row.value, 'pwsh'); + assert.equal(row.source, 'default'); +}); + test('PowerShell spawn failures include operator guidance', () => { const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh'); assert.ok(lines.some((line) => line.includes('configured PowerShell executable "pwsh"'))); @@ -44,6 +63,13 @@ test('PowerShell spawn failures include operator guidance', () => { assert.ok(lines.some((line) => line.includes('powershell.exe'))); }); +test('PowerShell signal exits include crash diagnostics', () => { + const lines = powerShellSignalFailureLines('SIGABRT', 'pwsh'); + assert.ok(lines.some((line) => line.includes('terminated by signal SIGABRT'))); + assert.ok(lines.some((line) => line.includes('PowerShell/.NET process aborted'))); + assert.ok(lines.some((line) => line.includes('pwsh -NoProfile'))); +}); + test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => { updateSettings({ trusted_origins: 'https://ops.example.com' }); const origins = effectiveTrustedOrigins(); diff --git a/server/test/vcenter.test.mjs b/server/test/vcenter.test.mjs index 18784b9..4cd8d5f 100644 --- a/server/test/vcenter.test.mjs +++ b/server/test/vcenter.test.mjs @@ -15,6 +15,7 @@ const { normalizeBaseUrl, normalizeVCenterVm, previewVCenterHosts, + retrievePropertiesBody, settingsFromVCenterConnection, vmMatchesFilter } = { @@ -155,8 +156,22 @@ test('standalone VMware host connections use the Web Services profile', async () assert.equal(preview.candidates[0].powerState, 'POWERED_ON'); }); +test('standalone VMware RetrievePropertiesEx SOAP closes boolean fields before paths', () => { + const body = retrievePropertiesBody( + { type: 'PropertyCollector', value: 'ha-property-collector' }, + { type: 'ContainerView', value: 'session-vm-view' }, + 5 + ); + + assert.match(body, /false<\/all>name<\/pathSet>/); + assert.match(body, /true<\/skip>false<\/skip><\/selectSet>/); + assert.doesNotMatch(body, /false>false><\/selectSet>/); +}); + test('standalone VMware VM candidates build managed host payloads', async () => { - const { restore } = installStandaloneEsxiSoapStub(); + const { calls, restore } = installStandaloneEsxiSoapStub(); const connection = { id: 'vc_host', name: 'ESXi 01', @@ -187,6 +202,10 @@ test('standalone VMware VM candidates build managed host payloads', async () => assert.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101')); assert.equal(power.checked, true); assert.equal(power.state, 'POWERED_ON'); + const powerBody = calls.filter((call) => call.body.includes('false<\/all>runtime\.powerState<\/pathSet>/); + assert.match(powerBody, /false<\/skip><\/objectSet>/); + assert.doesNotMatch(powerBody, /false> {