This commit is contained in:
2026-06-25 20:57:09 -05:00
parent 552b86c323
commit e47d09d7d6
35 changed files with 4507 additions and 101 deletions

View File

@@ -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.

View File

@@ -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/<jobId>/` 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.

View File

@@ -597,6 +597,31 @@
</article>
</section>
<JobOutputView
v-if="view === 'job-output'"
:outputs="jobOutputs"
:jobs="jobs"
:selected-output="selectedJobOutput"
:loading="jobOutputLoading"
@refresh="refreshJobOutputs"
@generate="generateJobOutputs"
@view="viewJobOutput"
@download="downloadJobOutput"
/>
<SchedulingView
v-if="view === 'scheduling'"
:schedules="runPlanSchedules"
:history="runPlanScheduleHistory"
:runplans="runplans"
:loading="schedulingLoading"
@refresh="refreshSchedules"
@save="saveSchedule"
@delete="deleteSchedule"
@toggle="toggleSchedule"
@run-now="runScheduleNow"
/>
<SystemJobsView
v-if="view === 'system-jobs'"
:jobs="systemJobs"
@@ -645,6 +670,7 @@
:source-label="settingSourceLabel"
:is-boolean-setting="isBooleanSetting"
:normalized-boolean-setting="normalizedBooleanSetting"
:is-locked-setting="isLockedSetting"
@toggle="toggleConfigSection(section.section)"
@toggle-boolean="toggleBooleanSetting"
/>
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function previewDocument(title, body) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapePreviewHtml(title)}</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
padding: 28px;
color: #e8f1ff;
background: #070b18;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
h1 { margin: 0 0 6px; font-size: 22px; }
.meta { margin-bottom: 22px; color: #8ba3c7; font-size: 13px; }
.panel {
border: 1px solid rgba(139, 224, 255, .18);
border-radius: 16px;
background: rgba(12, 20, 39, .86);
overflow: auto;
box-shadow: 0 18px 60px rgba(0, 0, 0, .32);
}
pre {
margin: 0;
padding: 18px;
color: #b8ffcb;
font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, .08); text-align: left; vertical-align: top; }
th { position: sticky; top: 0; color: #91e8ff; background: #0b1325; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
td { color: #d7e4f8; }
</style>
</head>
<body>
<h1>${escapePreviewHtml(title)}</h1>
<div class="meta">Opened from POSHManager Job Output</div>
<div class="panel">${body}</div>
</body>
</html>`;
}
function tablePreviewHtml(table = {}) {
const columns = table.columns || [];
const rows = table.rows || [];
const head = `<thead><tr>${columns.map((column) => `<th>${escapePreviewHtml(column.label || column.key)}</th>`).join('')}</tr></thead>`;
const body = `<tbody>${rows.map((row) => `<tr>${columns.map((column) => `<td>${escapePreviewHtml(row[column.key] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>`;
return `<table>${head}${body}</table>`;
}
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)
: `<pre>${escapePreviewHtml(payload.text || '')}</pre>`;
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.' },

View File

@@ -8,16 +8,46 @@
</button>
</div>
<nav class="metismenu">
<div class="menu-label">Operations</div>
<button v-for="item in primaryNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<div class="menu-label">Administration</div>
<button v-for="item in adminNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<template v-for="item in nav" :key="item.id">
<button
v-if="!item.children?.length"
:class="['nav-root-button', { active: view === item.id }]"
:title="collapsed ? item.label : undefined"
type="button"
@click="$emit('update:view', item.id)"
>
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<div v-else :class="['nav-group', { open: isGroupOpen(item.id), active: groupContainsView(item, view) }]">
<button
class="nav-group-toggle"
:aria-expanded="isGroupOpen(item.id)"
:title="collapsed ? item.label : undefined"
type="button"
@click="toggleGroup(item.id)"
>
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
<ChevronDown class="group-chevron" :size="15" />
</button>
<div v-if="isGroupOpen(item.id)" class="nav-submenu">
<button
v-for="child in item.children"
:key="child.id"
:class="['nav-child-button', { active: view === child.id }]"
:title="collapsed ? child.label : undefined"
type="button"
@click="$emit('update:view', child.id)"
>
<span class="child-rail" aria-hidden="true"></span>
<span class="parent-icon child-icon"><component :is="child.icon" :size="16" /></span>
<span class="menu-title">{{ child.label }}</span>
</button>
</div>
</div>
</template>
</nav>
<div class="sidebar-user">
<span class="user-orb">{{ initials }}</span>
@@ -30,8 +60,8 @@
</template>
<script setup>
import { computed } from 'vue';
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
const props = defineProps({
nav: { type: Array, required: true },
@@ -42,8 +72,34 @@ const props = defineProps({
defineEmits(['update:view', 'toggle-collapse']);
const accountNav = ['profile', 'users', 'admin'];
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
const collapsedGroups = ref(loadCollapsedGroups());
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
watch(collapsedGroups, (value) => {
localStorage.setItem('poshmanager.navGroupsCollapsed', JSON.stringify([...value]));
}, { deep: true });
function loadCollapsedGroups() {
try {
const parsed = JSON.parse(localStorage.getItem('poshmanager.navGroupsCollapsed') || '[]');
return new Set(Array.isArray(parsed) ? parsed : []);
} catch {
return new Set();
}
}
function isGroupOpen(groupId) {
return !collapsedGroups.value.has(groupId);
}
function toggleGroup(groupId) {
const next = new Set(collapsedGroups.value);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
collapsedGroups.value = next;
}
function groupContainsView(group, view) {
return group.children?.some((child) => child.id === view);
}
</script>

View File

@@ -23,13 +23,14 @@
type="button"
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
:aria-pressed="normalizedBooleanSetting(row.value)"
:disabled="isLockedSetting(key, row)"
@click="$emit('toggle-boolean', row)"
>
<i></i>
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
</button>
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" />
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)"></textarea>
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)" />
</div>
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
</div>
@@ -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']);

View File

@@ -11,35 +11,61 @@
</button>
</div>
<div class="intune-config-grid">
<section class="publisher-card">
<strong>Inventory sources</strong>
<div class="vmware-config-grid">
<section class="publisher-card vmware-summary-card">
<div class="vmware-card-heading">
<span class="vmware-card-icon"><i class="mdi mdi-view-grid-outline" aria-hidden="true"></i></span>
<div>
<strong>Inventory sources</strong>
<small>Credential-backed VMware inventory and power-state checks.</small>
</div>
</div>
<p>vCenter and standalone ESXi entries use Credential Vault entries for VMware API authentication, inventory import, and power-state checks.</p>
<div class="config-stat-row">
<span><b>{{ connections.length }}</b><small>systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
<div class="vmware-stat-grid" aria-label="VMware connection summary">
<span><b>{{ connections.length }}</b><small>Systems</small></span>
<span><b>{{ enabledCount }}</b><small>Enabled</small></span>
<span><b>{{ vcenterCount }}</b><small>vCenter</small></span>
<span><b>{{ standaloneCount }}</b><small>hosts</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
<span><b>{{ standaloneCount }}</b><small>ESXi Hosts</small></span>
<span><b>{{ testedCount }}</b><small>Tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>VMware systems</strong>
<div v-for="connection in connections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">
<section class="publisher-card vmware-system-list">
<div class="vmware-list-heading">
<strong>VMware systems</strong>
<small>{{ connections.length ? `${connections.length} configured` : 'No systems configured' }}</small>
</div>
<div v-for="connection in connections" :key="connection.id" class="vmware-system-row">
<div class="vmware-system-lead" aria-hidden="true">
<i :class="connection.targetType === 'host' ? 'mdi mdi-server' : 'mdi mdi-server-network'"></i>
</div>
<div class="vmware-system-main">
<div class="vmware-system-title">
<b>{{ connection.name }}</b>
<span :class="['status-pill', connection.enabled ? 'success' : 'skipped']">{{ connection.enabled ? 'Enabled' : 'Disabled' }}</span>
<span :class="['status-pill', connectionStatusTone(connection)]">{{ connection.lastTestStatus || 'Untested' }}</span>
</div>
<div class="vmware-system-meta">
<span><i class="mdi mdi-source-branch" aria-hidden="true"></i>{{ connectionTypeLabel(connection) }}</span>
<span><i class="mdi mdi-link-variant" aria-hidden="true"></i>{{ connection.baseUrl }}</span>
<span><i class="mdi mdi-key-variant" aria-hidden="true"></i>{{ credentialLabel(connection) }}</span>
<span v-if="connection.lastTestAt"><i class="mdi mdi-clock-outline" aria-hidden="true"></i>{{ formatDate(connection.lastTestAt) }}</span>
</div>
<small v-if="connection.lastTestMessage" class="vmware-test-message">{{ connection.lastTestMessage }}</small>
</div>
<div class="vmware-system-actions">
<button class="ghost-button icon-text compact" type="button" :aria-label="`Edit ${connection.name}`" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">
<button class="ghost-button icon-text compact" type="button" :aria-label="`Test ${connection.name}`" @click="$emit('test', connection.id)">
<i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">
<button class="ghost-button icon-text compact danger-text" type="button" :aria-label="`Delete ${connection.name}`" @click="$emit('delete', connection.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>Delete
</button>
</span>
</div>
</div>
<p v-if="!connections.length" class="widget-empty">No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.</p>
</section>
@@ -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;
}
}
</script>
<style scoped>
.vmware-config-grid {
display: grid;
grid-template-columns: minmax(280px, .72fr) minmax(0, 1.28fr);
gap: 16px;
align-items: stretch;
}
.vmware-summary-card,
.vmware-system-list {
min-width: 0;
}
.vmware-card-heading,
.vmware-list-heading,
.vmware-system-title,
.vmware-system-meta,
.vmware-system-actions {
display: flex;
align-items: center;
}
.vmware-card-heading {
gap: 12px;
margin-bottom: 10px;
}
.vmware-card-heading strong,
.vmware-list-heading strong {
display: block;
color: rgba(247, 250, 255, .96);
}
.vmware-card-heading small,
.vmware-list-heading small {
color: rgba(213, 225, 246, .62);
font-size: .76rem;
}
.vmware-card-icon,
.vmware-system-lead {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgba(88, 221, 255, .2);
background:
linear-gradient(135deg, rgba(88, 221, 255, .16), rgba(154, 107, 255, .1)),
rgba(10, 16, 34, .72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 14px 30px rgba(0, 0, 0, .18);
color: #8be0ff;
}
.vmware-card-icon {
width: 42px;
height: 42px;
border-radius: 14px;
}
.vmware-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.vmware-stat-grid span {
min-width: 0;
padding: 13px 14px;
border: 1px solid rgba(255, 255, 255, .095);
border-radius: 14px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .08), rgba(154, 107, 255, .055)),
rgba(255, 255, 255, .045);
}
.vmware-stat-grid b,
.vmware-stat-grid small {
display: block;
}
.vmware-stat-grid b {
color: #f7faff;
font-size: 1.35rem;
line-height: 1.1;
}
.vmware-stat-grid small {
margin-top: 4px;
color: rgba(213, 225, 246, .58);
font-size: .68rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.vmware-list-heading {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.vmware-system-list {
display: grid;
gap: 10px;
align-content: start;
}
.vmware-system-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
min-width: 0;
padding: 14px;
border: 1px solid rgba(255, 255, 255, .095);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(11, 21, 43, .88), rgba(26, 24, 58, .74)),
rgba(4, 9, 22, .42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
}
.vmware-system-lead {
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 1.1rem;
}
.vmware-system-main {
min-width: 0;
}
.vmware-system-title {
flex-wrap: wrap;
gap: 8px;
}
.vmware-system-title b {
min-width: 0;
max-width: min(34rem, 100%);
overflow: hidden;
color: #f7faff;
font-size: .96rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-system-meta {
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 8px;
color: rgba(213, 225, 246, .68);
font-size: .76rem;
}
.vmware-system-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.vmware-system-meta i {
flex: 0 0 auto;
color: #8be0ff;
opacity: .82;
}
.vmware-system-meta span:nth-child(2) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-test-message {
display: block;
margin-top: 8px;
overflow: hidden;
color: rgba(255, 196, 204, .72);
font-size: .73rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-system-actions {
justify-content: flex-end;
gap: 8px;
}
.vmware-system-actions .ghost-button {
min-height: 40px;
white-space: nowrap;
}
@media (max-width: 1180px) {
.vmware-config-grid {
grid-template-columns: 1fr;
}
.vmware-stat-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.intune-config-panel :deep(.section-title) {
align-items: stretch;
}
.intune-config-panel :deep(.section-title .primary-action) {
width: 100%;
justify-content: center;
}
.vmware-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vmware-system-row {
grid-template-columns: 40px minmax(0, 1fr);
}
.vmware-system-actions {
grid-column: 1 / -1;
justify-content: stretch;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vmware-system-actions .ghost-button {
justify-content: center;
}
}
@media (max-width: 460px) {
.vmware-stat-grid,
.vmware-system-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -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"],

View File

@@ -0,0 +1,526 @@
<template>
<section class="job-output-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">EXECUTION ARTIFACTS</span>
<h2>Job Output</h2>
<p>Browse generated stdout PDFs, parsed datatables, and terminal transcripts stored in FileLocker.</p>
</div>
<div class="page-actions">
<label class="job-output-build-select">
<span>Build from job</span>
<select v-model="generateJobId">
<option value="">Choose completed job</option>
<option v-for="job in generatableJobs" :key="job.id" :value="job.id">
{{ job.runplanName || job.scriptName || job.id }} - {{ job.status }}
</option>
</select>
</label>
<button class="ghost-button compact" type="button" @click="$emit('refresh')">
<i class="mdi mdi-refresh" aria-hidden="true"></i>Refresh
</button>
<button class="primary-action compact" type="button" :disabled="!generateJobId || loading" @click="$emit('generate', generateJobId)">
<i class="mdi mdi-file-sync-outline" aria-hidden="true"></i>Build outputs
</button>
</div>
</div>
<div class="job-output-metrics">
<article><strong>{{ groupedOutputs.length }}</strong><small>Jobs with output</small></article>
<article><strong>{{ outputs.length }}</strong><small>Artifacts</small></article>
<article><strong>{{ pdfCount }}</strong><small>PDF reports</small></article>
<article><strong>{{ tableCount }}</strong><small>Datatables</small></article>
</div>
<div class="job-output-shell">
<ResourceTable
class="job-output-list"
:columns="columns"
:rows="pagedGroups"
:total="filteredGroups.length"
:search="filters.search"
:page="filters.page"
:page-count="pageCount"
:page-size="filters.pageSize"
:sort="filters.sort"
:direction="filters.direction"
search-placeholder="Search job, script, runplan..."
item-label="jobs"
empty-text="No generated job outputs yet. Build outputs from a completed job."
@update:search="filters.search = $event"
@update:page="filters.page = $event"
@update:page-size="filters.pageSize = $event"
@sort="setSort"
>
<template #toolbar-leading>
<label class="table-density-select">
<span>Artifact</span>
<select v-model="filters.kind">
<option value="">All</option>
<option value="stdout-pdf">PDF</option>
<option value="datatable">Datatable</option>
<option value="terminal">Terminal</option>
</select>
</label>
</template>
<template #cell-name="{ row }">
<button :class="['job-output-title', { active: selectedOutput?.jobId === row.jobId }]" type="button" @click="viewGroup(row)">
<i :class="kindIcon(selectedArtifact(row)?.kind)" aria-hidden="true"></i>
<span>
<strong>{{ row.title }}</strong>
<small>{{ row.subtitle }} · {{ row.artifacts.length }} artifact{{ row.artifacts.length === 1 ? '' : 's' }}</small>
</span>
</button>
</template>
<template #cell-kind="{ row }">
<label class="job-output-artifact-select">
<span :class="['status-dot', kindTone(selectedArtifact(row)?.kind)]" aria-hidden="true"></span>
<select :value="selectedKind(row)" @change="changeGroupKind(row, $event.target.value)">
<option v-for="artifact in row.artifacts" :key="artifact.id" :value="artifact.kind">
{{ kindLabel(artifact.kind) }} · {{ formatBytes(artifact.sizeBytes) }}
</option>
</select>
</label>
</template>
<template #cell-jobStatus="{ row }"><span :class="['status-pill', row.jobStatus]">{{ row.jobStatus }}</span></template>
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
<template #cell-actions="{ row }">
<div class="job-output-row-actions">
<button class="ghost-button compact" type="button" @click="viewGroup(row)">
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>View
</button>
<button class="ghost-button compact" type="button" @click="downloadGroup(row)">
<i class="mdi mdi-download-outline" aria-hidden="true"></i>Download
</button>
</div>
</template>
</ResourceTable>
</div>
</section>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import ResourceTable from '../components/ui/ResourceTable.vue';
const props = defineProps({
outputs: { type: Array, default: () => [] },
jobs: { type: Array, default: () => [] },
selectedOutput: { type: Object, default: null },
loading: Boolean
});
const emit = defineEmits(['refresh', 'generate', 'view', 'download']);
const generateJobId = ref('');
const selectedKindByJob = reactive({});
const filters = reactive({ search: '', kind: '', page: 1, pageSize: 8, sort: 'latestAt', direction: 'desc' });
const columns = [
{ key: 'name', label: 'Job' },
{ key: 'kind', label: 'Artifact' },
{ key: 'jobStatus', label: 'Status' },
{ key: 'latestAt', label: 'Generated' },
{ key: 'actions', label: 'Actions', sortable: false }
];
const artifactOrder = { 'stdout-pdf': 0, datatable: 1, terminal: 2 };
const generatableJobs = computed(() => props.jobs.filter((job) => ['completed', 'failed', 'canceled'].includes(job.status)));
const pdfCount = computed(() => props.outputs.filter((output) => output.kind === 'stdout-pdf').length);
const tableCount = computed(() => props.outputs.filter((output) => output.kind === 'datatable').length);
const groupedOutputs = computed(() => {
const groups = new Map();
for (const output of props.outputs) {
const jobId = output.jobId || output.id;
if (!groups.has(jobId)) {
groups.set(jobId, {
id: jobId,
jobId,
title: output.runplanName || output.scriptName || jobId,
subtitle: output.runplanName && output.scriptName ? output.scriptName : jobId,
scriptName: output.scriptName || '',
runplanName: output.runplanName || '',
jobStatus: output.jobStatus || '',
latestAt: output.updatedAt || output.createdAt || '',
createdAt: output.createdAt || '',
artifacts: []
});
}
const group = groups.get(jobId);
group.artifacts.push(output);
if (String(output.updatedAt || output.createdAt || '') > String(group.latestAt || '')) group.latestAt = output.updatedAt || output.createdAt;
if (!group.jobStatus && output.jobStatus) group.jobStatus = output.jobStatus;
}
return [...groups.values()].map((group) => ({
...group,
artifacts: [...group.artifacts].sort((a, b) => (artifactOrder[a.kind] ?? 99) - (artifactOrder[b.kind] ?? 99))
}));
});
const filteredGroups = computed(() => {
const term = filters.search.trim().toLowerCase();
return groupedOutputs.value.filter((group) => {
if (filters.kind && !group.artifacts.some((artifact) => artifact.kind === filters.kind)) return false;
if (!term) return true;
return [
group.title,
group.subtitle,
group.jobStatus,
group.runplanName,
group.scriptName,
group.jobId,
...group.artifacts.flatMap((artifact) => [artifact.name, artifact.kind])
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(term));
}).sort((a, b) => {
const result = sortableValue(a, filters.sort).localeCompare(sortableValue(b, filters.sort), undefined, { numeric: true, sensitivity: 'base' });
return filters.direction === 'asc' ? result : -result;
});
});
const pageCount = computed(() => Math.max(1, Math.ceil(filteredGroups.value.length / filters.pageSize)));
const pagedGroups = computed(() => {
const page = Math.min(filters.page, pageCount.value);
return filteredGroups.value.slice((page - 1) * filters.pageSize, page * filters.pageSize);
});
watch(() => [filters.search, filters.kind, filters.pageSize], () => { filters.page = 1; });
watch(() => props.selectedOutput?.id, () => {
if (props.selectedOutput?.jobId && props.selectedOutput?.kind) selectedKindByJob[props.selectedOutput.jobId] = props.selectedOutput.kind;
}, { immediate: true });
watch(groupedOutputs, (groups) => {
const validJobIds = new Set(groups.map((group) => group.jobId));
for (const jobId of Object.keys(selectedKindByJob)) {
if (!validJobIds.has(jobId)) delete selectedKindByJob[jobId];
}
}, { immediate: true });
function setSort(key) {
if (filters.sort === key) filters.direction = filters.direction === 'asc' ? 'desc' : 'asc';
else {
filters.sort = key;
filters.direction = 'asc';
}
}
function sortableValue(row, key) {
if (key === 'kind') return kindLabel(selectedArtifact(row)?.kind || '');
if (key === 'name') return row.title || '';
if (key === 'updatedAt') return row.latestAt || '';
return String(row[key] ?? '').toLowerCase();
}
function selectedKind(row) {
const available = row.artifacts.map((artifact) => artifact.kind);
if (available.includes(selectedKindByJob[row.jobId])) return selectedKindByJob[row.jobId];
if (filters.kind && available.includes(filters.kind)) return filters.kind;
return row.artifacts[0]?.kind || '';
}
function selectedArtifact(row) {
const kind = selectedKind(row);
return row.artifacts.find((artifact) => artifact.kind === kind) || row.artifacts[0] || null;
}
function changeGroupKind(row, kind) {
selectedKindByJob[row.jobId] = kind;
}
function viewGroup(row) {
const artifact = selectedArtifact(row);
if (artifact) emit('view', artifact);
}
function downloadGroup(row) {
const artifact = selectedArtifact(row);
if (artifact) emit('download', artifact);
}
function kindLabel(kind) {
return {
'stdout-pdf': 'STDOUT PDF',
datatable: 'Datatable',
terminal: 'Terminal transcript'
}[kind] || kind;
}
function kindTone(kind) {
return {
'stdout-pdf': 'info',
datatable: 'success',
terminal: 'neutral'
}[kind] || 'neutral';
}
function kindIcon(kind) {
return {
'stdout-pdf': 'mdi mdi-file-pdf-box',
datatable: 'mdi mdi-table-large',
terminal: 'mdi mdi-console'
}[kind] || 'mdi mdi-file-outline';
}
function shortDate(value) {
if (!value) return '-';
return new Date(value).toLocaleString();
}
function formatBytes(bytes = 0) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = Number(bytes);
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size.toFixed(index ? 1 : 0)} ${units[index]}`;
}
</script>
<style scoped>
.job-output-page {
display: grid;
gap: 18px;
}
.job-output-build-select {
min-width: min(320px, 100%);
}
.job-output-build-select span {
display: block;
margin-bottom: 6px;
color: rgba(213, 225, 246, .68);
font-size: .68rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.job-output-build-select select {
min-height: 42px;
border-radius: 999px;
}
.job-output-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.job-output-metrics article {
padding: 16px;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .09), rgba(154, 107, 255, .07)),
rgba(10, 16, 34, .62);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .06);
}
.job-output-metrics strong,
.job-output-metrics small {
display: block;
}
.job-output-metrics strong {
color: #f7faff;
font-size: 1.55rem;
line-height: 1;
}
.job-output-metrics small {
margin-top: 8px;
color: rgba(213, 225, 246, .62);
font-size: .7rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.job-output-shell {
display: block;
}
.job-output-list {
min-width: 0;
}
.job-output-list :deep(.resource-toolbar) {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(260px, 1fr) minmax(96px, 120px);
gap: 12px;
align-items: end;
}
.job-output-list :deep(.search-control) {
min-width: 0;
}
.job-output-list :deep(.toolbar-count) {
grid-column: 1 / -1;
justify-self: end;
white-space: nowrap;
}
.job-output-list :deep(.data-table-wrap) {
border-radius: 16px;
}
.job-output-list :deep(.data-table) {
width: 100%;
min-width: 0;
max-width: 100%;
table-layout: fixed;
}
.job-output-list :deep(.data-table th),
.job-output-list :deep(.data-table td) {
padding-inline: 9px;
box-sizing: border-box;
}
.job-output-list :deep(th:nth-child(1)),
.job-output-list :deep(td:nth-child(1)) {
width: 34%;
}
.job-output-list :deep(th:nth-child(2)),
.job-output-list :deep(td:nth-child(2)) {
width: 24%;
}
.job-output-list :deep(th:nth-child(3)),
.job-output-list :deep(td:nth-child(3)) {
width: 12%;
}
.job-output-list :deep(th:nth-child(4)),
.job-output-list :deep(td:nth-child(4)) {
width: 16%;
}
.job-output-list :deep(th:last-child),
.job-output-list :deep(td:last-child) {
width: 14%;
}
.job-output-title {
display: inline-flex;
align-items: center;
gap: 10px;
width: 100%;
min-width: 0;
color: inherit;
text-align: left;
}
.job-output-title i {
color: #8be0ff;
font-size: 1.25rem;
}
.job-output-title span {
display: grid;
min-width: 0;
}
.job-output-title strong,
.job-output-title small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-output-title small {
color: rgba(213, 225, 246, .62);
}
.job-output-artifact-select {
position: relative;
display: inline-flex;
align-items: center;
width: min(100%, 240px);
min-width: 0;
}
.job-output-artifact-select select {
width: 100%;
min-height: 38px;
padding-left: 32px;
padding-right: 32px;
border-radius: 999px;
color: rgba(238, 246, 255, .92);
background:
linear-gradient(135deg, rgba(88, 221, 255, .1), rgba(154, 107, 255, .08)),
rgba(7, 14, 29, .76);
border: 1px solid rgba(139, 224, 255, .2);
font-size: .74rem;
font-weight: 800;
}
.status-dot {
position: absolute;
left: 13px;
z-index: 1;
width: 9px;
height: 9px;
border-radius: 999px;
box-shadow: 0 0 14px currentColor;
}
.status-dot.info {
color: #8be0ff;
background: #8be0ff;
}
.status-dot.success {
color: #64e7bd;
background: #64e7bd;
}
.status-dot.neutral {
color: rgba(213, 225, 246, .8);
background: rgba(213, 225, 246, .8);
}
.job-output-row-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-width: 0;
}
.job-output-row-actions .ghost-button {
justify-content: center;
min-width: 0;
padding-inline: 10px;
}
@media (max-width: 760px) {
.job-output-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.job-output-page :deep(.page-actions) {
align-items: stretch;
}
.job-output-page :deep(.page-actions > *) {
width: 100%;
}
.job-output-list :deep(.resource-toolbar) {
grid-template-columns: 1fr;
}
.job-output-list :deep(.toolbar-count) {
justify-self: start;
}
}
@media (max-width: 460px) {
.job-output-metrics {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

202
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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 });
}
}

View File

@@ -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.')
,

View File

@@ -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);
}
}
}

View File

@@ -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
};
}

View File

@@ -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();
});

View File

@@ -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, {}) };
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 [];
}
}

View File

@@ -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;

View File

@@ -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?.();
}

View File

@@ -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 })
}
];

View File

@@ -784,6 +784,35 @@ function soapRefElement(name, ref, fallbackType) {
return `<${name} type="${xmlEscape(ref?.type || fallbackType)}">${xmlEscape(ref?.value || '')}</${name}>`;
}
function soapBoolElement(name, value) {
return `<${name}>${value ? 'true' : 'false'}</${name}>`;
}
function propertySpecXml(type, paths = []) {
return `<propSet>` +
`<type>${xmlEscape(type)}</type>` +
soapBoolElement('all', false) +
paths.map((pathSet) => `<pathSet>${xmlEscape(pathSet)}</pathSet>`).join('') +
`</propSet>`;
}
function objectSpecXml(ref, { skip = false, selectSet = '' } = {}) {
return `<objectSet>` +
soapRefElement('obj', ref, ref?.type || 'ManagedObjectReference') +
soapBoolElement('skip', skip) +
selectSet +
`</objectSet>`;
}
function traversalSpecXml() {
return `<selectSet xsi:type="TraversalSpec">` +
`<name>containerViewTraversal</name>` +
`<type>ContainerView</type>` +
`<path>view</path>` +
soapBoolElement('skip', false) +
`</selectSet>`;
}
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 `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` +
soapRefElement('_this', propertyCollector, 'PropertyCollector') +
`<specSet>` +
`<propSet><type>VirtualMachine</type><all>false</all>${paths.map((pathSet) => `<pathSet>${pathSet}</pathSet>`).join('')}</propSet>` +
`<objectSet>${soapRefElement('obj', viewRef, 'ContainerView')}<skip>true</skip>` +
`<selectSet xsi:type="TraversalSpec"><name>containerViewTraversal</name><type>ContainerView</type><path>view</path><skip>false</skip></selectSet>` +
`</objectSet>` +
propertySpecXml('VirtualMachine', paths) +
objectSpecXml(viewRef, { skip: true, selectSet: traversalSpecXml() }) +
`</specSet>` +
`<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` +
`</RetrievePropertiesEx>`;
@@ -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 = `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` +
`<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` +
soapRefElement('_this', refs.propertyCollector, 'PropertyCollector') +
`<specSet>` +
propertySpecXml('VirtualMachine', ['runtime.powerState']) +
objectSpecXml(vmRef, { skip: false }) +
`</specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
const object = parseVmPropertyObjects(response)[0];

View File

@@ -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') },

View File

@@ -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'));
});

View File

@@ -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)

View File

@@ -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' });
});

View File

@@ -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();

View File

@@ -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, /<all>false<\/all><pathSet>name<\/pathSet>/);
assert.match(body, /<skip>true<\/skip><selectSet/);
assert.match(body, /<skip>false<\/skip><\/selectSet>/);
assert.doesNotMatch(body, /<all>false><pathSet/);
assert.doesNotMatch(body, /<skip>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('<RetrievePropertiesEx')).at(-1)?.body || '';
assert.match(powerBody, /<all>false<\/all><pathSet>runtime\.powerState<\/pathSet>/);
assert.match(powerBody, /<skip>false<\/skip><\/objectSet>/);
assert.doesNotMatch(powerBody, /<all>false><pathSet/);
});
test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {