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 ALLOW_SCRIPT_EXECUTION=true
# Minutes between dynamic Host Group rule syncs. 0 disables the worker. # Minutes between dynamic Host Group rule syncs. 0 disables the worker.
HOST_GROUP_SYNC_INTERVAL_MINUTES=60 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. # Minutes between automatic catalog upstream-version checks. 0 disables the worker.
CATALOG_CHECK_INTERVAL_MINUTES=0 CATALOG_CHECK_INTERVAL_MINUTES=0
# Minutes between auto-promote soak checks. 0 disables the worker. # 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. - 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. - 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. - 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. - 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. - Aggregate job log viewer with search/filter/pagination.
- System log tab for Winston request/application logs. - 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`. 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 ## Quick Start
```bash ```bash
@@ -157,7 +161,7 @@ Important environment variables:
| `DEFAULT_ADMIN_EMAIL` | First-run admin email. | | `DEFAULT_ADMIN_EMAIL` | First-run admin email. |
| `DEFAULT_ADMIN_PASSWORD` | First-run admin password. | | `DEFAULT_ADMIN_PASSWORD` | First-run admin password. |
| `DEFAULT_ADMIN_NAME` | First-run admin display name. | | `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. | | `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. | | `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. | | `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_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. | | `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. | | `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. | | `CATALOG_CHECK_INTERVAL_MINUTES` | Minutes between catalog upstream-version checks. `0` disables. |
| `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. | | `AUTO_PROMOTE_INTERVAL_MINUTES` | Minutes between auto-promote soak checks. `0` disables. |
| `TOOLS_DIR` | Directory for bundled tools. Defaults to `./tools`. | | `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. | | `PUT` | `/api/runplans/:id` | User | Update RunPlan. |
| `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. | | `DELETE` | `/api/runplans/:id` | User | Delete RunPlan. |
| `POST` | `/api/runplans/:id/execute` | User | Queue/execute a 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` | User | List jobs. |
| `GET` | `/api/jobs/:id` | User | Read job with logs. | | `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. | | `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. | | `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: RunPlan payload:
@@ -1206,6 +1224,42 @@ curl -X POST http://localhost:3000/api/runplans/rp_123/execute \
-d '{}' -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: Linux compatibility preflight:
```bash ```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 execution and Script Test / Run also write the same warning summary into
per-host job logs for Linux targets. 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: Admin System Jobs console:
```bash ```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 \ curl -X POST http://localhost:3000/api/system-jobs/worker%3Ahost-group-sync/run \
-H "Authorization: Bearer $TOKEN" -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 \ curl -X POST http://localhost:3000/api/system-jobs/job_123/cancel \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
@@ -1268,11 +1353,13 @@ Settings update:
## Execution Notes ## 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. - `local` hosts run the script directly in the API container.
- `winrm` hosts use `Invoke-Command -ComputerName`. - `winrm` hosts use `Invoke-Command -ComputerName`.
- `ssh` hosts use `Invoke-Command -HostName`. - `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. - Host credential resolution happens inside backend execution flows.
- POSHManager runtime variables are injected as PowerShell variables and `POSHM_*` environment variables. - 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. - `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> </article>
</section> </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 <SystemJobsView
v-if="view === 'system-jobs'" v-if="view === 'system-jobs'"
:jobs="systemJobs" :jobs="systemJobs"
@@ -645,6 +670,7 @@
:source-label="settingSourceLabel" :source-label="settingSourceLabel"
:is-boolean-setting="isBooleanSetting" :is-boolean-setting="isBooleanSetting"
:normalized-boolean-setting="normalizedBooleanSetting" :normalized-boolean-setting="normalizedBooleanSetting"
:is-locked-setting="isLockedSetting"
@toggle="toggleConfigSection(section.section)" @toggle="toggleConfigSection(section.section)"
@toggle-boolean="toggleBooleanSetting" @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 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import { import {
Activity, AlertTriangle, BarChart3, BookOpenText, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink, Activity, AlertTriangle, BarChart3, BookOpenText, CalendarClock, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink,
FileCode2, FilePlus2, Folder, FolderPlus, Gauge, KeyRound, ListFilter, ListPlus, LockKeyhole, Mail, 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, PackageOpen, Pencil, PieChart, Play, PlayCircle, Plus, Palette, Rocket, RotateCcw, Save, Search, Server, Settings, ShieldCheck, SlidersHorizontal, Sparkles, Trash2, UserPlus,
UserCircle, UsersRound, X UserCircle, UsersRound, X
} from '@lucide/vue'; } from '@lucide/vue';
@@ -996,6 +1022,8 @@ import LoginView from './views/LoginView.vue';
import PsadtView from './views/PsadtView.vue'; import PsadtView from './views/PsadtView.vue';
import AssetsView from './views/AssetsView.vue'; import AssetsView from './views/AssetsView.vue';
import SystemJobsView from './views/SystemJobsView.vue'; import SystemJobsView from './views/SystemJobsView.vue';
import JobOutputView from './views/JobOutputView.vue';
import SchedulingView from './views/SchedulingView.vue';
self.MonacoEnvironment = { self.MonacoEnvironment = {
getWorker() { getWorker() {
@@ -1164,6 +1192,12 @@ const settingMetadata = {
placeholder: '60', placeholder: '60',
section: 'Execution' 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: { vcenter_enabled: {
label: 'Legacy vCenter Default', label: 'Legacy vCenter Default',
description: 'Enable the legacy single-vCenter fallback. Saved VMware connections should use Config / VMware with Credential Vault authentication.', 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 assetFolders = ref([]);
const assets = ref([]); const assets = ref([]);
const runplans = ref([]); const runplans = ref([]);
const runPlanSchedules = ref([]);
const runPlanScheduleHistory = ref([]);
const jobs = ref([]); const jobs = ref([]);
const jobOutputs = ref([]);
const schedulingLoading = ref(false);
const systemJobs = ref([]); const systemJobs = ref([]);
const systemJobActions = ref([]); const systemJobActions = ref([]);
const systemJobsLoading = ref(false); const systemJobsLoading = ref(false);
@@ -1237,6 +1275,8 @@ const installerAnalysis = ref(null);
const psadtValidationResult = ref(null); const psadtValidationResult = ref(null);
const psadtMigrationPlan = ref(null); const psadtMigrationPlan = ref(null);
const selectedJob = ref(null); const selectedJob = ref(null);
const selectedJobOutput = ref(null);
const jobOutputLoading = ref(false);
const systemLogs = ref([]); const systemLogs = ref([]);
const logTab = ref('job'); const logTab = ref('job');
const logFilters = reactive({ search: '', stream: '', page: 1, pageSize: 14 }); const logFilters = reactive({ search: '', stream: '', page: 1, pageSize: 14 });
@@ -1307,16 +1347,53 @@ const scriptLayout = reactive(loadScriptLayout());
const nav = [ const nav = [
{ id: 'dashboard', label: 'Dashboard', icon: Gauge }, { id: 'dashboard', label: 'Dashboard', icon: Gauge },
{ id: 'scripts', label: 'Scripts', icon: BookOpenText }, {
{ id: 'assets', label: 'Assets', icon: PackageOpen }, id: 'nav-scripts',
{ id: 'psadt', label: 'PSADT', icon: Sparkles }, label: 'Scripts',
{ id: 'hosts', label: 'Hosts', icon: Server }, icon: BookOpenText,
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound }, children: [
{ id: 'runplans', label: 'RunPlans', icon: Rocket }, { id: 'scripts', label: 'Script Editor', icon: FileCode2 },
{ id: 'logs', label: 'Logs', icon: Activity }, { id: 'psadt', label: 'PSADT Bench', icon: Sparkles }
{ id: 'system-jobs', label: 'System Jobs', icon: Database }, ]
{ id: 'users', label: 'Users & Groups', icon: UsersRound }, },
{ id: 'admin', label: 'Config', icon: Settings } {
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 = [ const availableThemes = [
@@ -1369,8 +1446,9 @@ const runPlanColumns = [
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' } { 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 navLeaves = computed(() => nav.flatMap((item) => item.children?.length ? item.children : [item]));
const routedViewIds = computed(() => new Set([...nav.map((item) => item.id), 'profile'])); 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 isUnknownView = computed(() => token.value && !routedViewIds.value.has(view.value));
const activeTheme = computed(() => user.value?.theme || 'dashtreme'); const activeTheme = computed(() => user.value?.theme || 'dashtreme');
const isExternalAuth = computed(() => Boolean(user.value?.authProvider && user.value.authProvider !== 'local')); const isExternalAuth = computed(() => Boolean(user.value?.authProvider && user.value.authProvider !== 'local'));
@@ -1718,7 +1796,7 @@ async function refreshAll() {
return; return;
} }
const refreshFailures = []; 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('session', api.get('/api/auth/me'), () => ({ user: user.value }), refreshFailures),
safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures), safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true), safeRefresh('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('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
safeRefresh('vCenter connections', api.get('/api/hosts/import/vcenter/connections'), () => vcenterConnections.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('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); syncUser(meResult.user);
summary.value = boot.summary; summary.value = boot.summary;
settings.value = boot.settings; settings.value = boot.settings;
Object.keys(settingsDraft).forEach((key) => delete settingsDraft[key]); syncSettingsDraft();
Object.entries(settings.value).forEach(([key, row]) => { settingsDraft[key] = { ...row }; });
users.value = userRows; users.value = userRows;
groups.value = groupRows; groups.value = groupRows;
credentials.value = credentialRows; credentials.value = credentialRows;
@@ -1760,7 +1839,15 @@ async function refreshAll() {
graphConnections.value = graphRows; graphConnections.value = graphRows;
vcenterConnections.value = vcenterRows; vcenterConnections.value = vcenterRows;
runplans.value = runplanRows; runplans.value = runplanRows;
runPlanSchedules.value = schedulePayload.schedules || [];
runPlanScheduleHistory.value = schedulePayload.history || [];
jobs.value = jobRows; 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); if (user.value?.role === 'admin') await refreshSystemJobs(true);
else { else {
systemJobs.value = []; systemJobs.value = [];
@@ -2714,6 +2801,76 @@ async function executeRunPlan(id) {
notify('RunPlan queued'); 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) { async function confirmLinuxCompatibility(path, payload = null) {
const result = payload ? await api.post(path, payload) : await api.get(path); const result = payload ? await api.post(path, payload) : await api.get(path);
const warnings = (result.findings || []).filter((finding) => finding.level === 'warning'); const warnings = (result.findings || []).filter((finding) => finding.level === 'warning');
@@ -2735,6 +2892,143 @@ async function openJob(id) {
logFilters.page = 1; 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() { async function loadSystemLogs() {
logTab.value = 'system'; logTab.value = 'system';
logFilters.page = 1; logFilters.page = 1;
@@ -2786,8 +3080,11 @@ async function cancelSystemJob({ id, reason }) {
async function saveSettings() { async function saveSettings() {
const payload = {}; 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); settings.value = await api.put('/api/settings', payload);
syncSettingsDraft();
notify('Settings saved'); notify('Settings saved');
} }
@@ -3135,6 +3432,15 @@ function settingSourceLabel(source) {
return source === 'env' ? 'ENV' : 'DB'; 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) { function configSectionMeta(section) {
const meta = { const meta = {
Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' }, Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' },

View File

@@ -8,16 +8,46 @@
</button> </button>
</div> </div>
<nav class="metismenu"> <nav class="metismenu">
<div class="menu-label">Operations</div> <template v-for="item in nav" :key="item.id">
<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)"> <button
<span class="parent-icon"><component :is="item.icon" :size="18" /></span> v-if="!item.children?.length"
<span class="menu-title">{{ item.label }}</span> :class="['nav-root-button', { active: view === item.id }]"
</button> :title="collapsed ? item.label : undefined"
<div class="menu-label">Administration</div> type="button"
<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)"> @click="$emit('update:view', item.id)"
<span class="parent-icon"><component :is="item.icon" :size="18" /></span> >
<span class="menu-title">{{ item.label }}</span> <span class="parent-icon"><component :is="item.icon" :size="18" /></span>
</button> <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> </nav>
<div class="sidebar-user"> <div class="sidebar-user">
<span class="user-orb">{{ initials }}</span> <span class="user-orb">{{ initials }}</span>
@@ -30,8 +60,8 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue'; import { ChevronDown, PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
const props = defineProps({ const props = defineProps({
nav: { type: Array, required: true }, nav: { type: Array, required: true },
@@ -42,8 +72,34 @@ const props = defineProps({
defineEmits(['update:view', 'toggle-collapse']); defineEmits(['update:view', 'toggle-collapse']);
const accountNav = ['profile', 'users', 'admin']; const collapsedGroups = ref(loadCollapsedGroups());
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase()); 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> </script>

View File

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

View File

@@ -11,35 +11,61 @@
</button> </button>
</div> </div>
<div class="intune-config-grid"> <div class="vmware-config-grid">
<section class="publisher-card"> <section class="publisher-card vmware-summary-card">
<strong>Inventory sources</strong> <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> <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"> <div class="vmware-stat-grid" aria-label="VMware connection summary">
<span><b>{{ connections.length }}</b><small>systems</small></span> <span><b>{{ connections.length }}</b><small>Systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span> <span><b>{{ enabledCount }}</b><small>Enabled</small></span>
<span><b>{{ vcenterCount }}</b><small>vCenter</small></span> <span><b>{{ vcenterCount }}</b><small>vCenter</small></span>
<span><b>{{ standaloneCount }}</b><small>hosts</small></span> <span><b>{{ standaloneCount }}</b><small>ESXi Hosts</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span> <span><b>{{ testedCount }}</b><small>Tested</small></span>
</div> </div>
</section> </section>
<section class="publisher-card graph-config-list"> <section class="publisher-card vmware-system-list">
<strong>VMware systems</strong> <div class="vmware-list-heading">
<div v-for="connection in connections" :key="connection.id" class="check-row"> <strong>VMware systems</strong>
<b>{{ connection.name }}</b> <small>{{ connections.length ? `${connections.length} configured` : 'No systems configured' }}</small>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }}</small> </div>
<span> <div v-for="connection in connections" :key="connection.id" class="vmware-system-row">
<button class="ghost-button compact" type="button" @click="openModal(connection)"> <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 <i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
</button> </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 <i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
</button> </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 <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>Delete
</button> </button>
</span> </div>
</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> <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> </section>
@@ -165,4 +191,272 @@ function save() {
function connectionTypeLabel(connection) { function connectionTypeLabel(connection) {
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory'; 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> </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); 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 { .user-orb {
width: 34px; width: 34px;
height: 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); 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 { .setting-source {
justify-self: end; justify-self: end;
min-width: 54px; min-width: 54px;
@@ -8032,6 +8119,128 @@ input:read-only {
padding: 0 !important; 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"], .form-modal .host-picker input[type="checkbox"],
.modal-form .host-picker input[type="checkbox"], .modal-form .host-picker input[type="checkbox"],
.grid-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", "multer": "^2.2.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"path": "^0.12.7", "path": "^0.12.7",
"pdfkit": "^0.19.1",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"undici": "^8.5.0", "undici": "^8.5.0",
"vite": "^8.1.0", "vite": "^8.1.0",
@@ -214,6 +215,30 @@
"@emnapi/runtime": "^1.7.1" "@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": { "node_modules/@oxc-project/types": {
"version": "0.137.0", "version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
@@ -499,6 +524,15 @@
"text-hex": "1.0.x" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz",
@@ -982,6 +1016,26 @@
"node": "18 || 20 || >=22" "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": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -1067,6 +1121,24 @@
"node": ">=8" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1181,6 +1253,15 @@
"node": ">=20" "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": { "node_modules/color": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -1374,6 +1455,12 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "17.4.2", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1566,6 +1653,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fecha": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
@@ -1612,6 +1705,23 @@
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
"license": "MIT" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1911,6 +2021,12 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -2221,6 +2337,25 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2577,6 +2712,12 @@
"fn.name": "1.x.x" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2606,6 +2747,20 @@
"url": "https://opencollective.com/express" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "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": "^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": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2813,6 +2976,12 @@
"node": ">=8.10.0" "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": { "node_modules/rolldown": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -3184,6 +3353,12 @@
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
"license": "MIT" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3284,7 +3459,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
@@ -3340,6 +3514,32 @@
"node": ">=22.19.0" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

@@ -30,6 +30,7 @@
"multer": "^2.2.0", "multer": "^2.2.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"path": "^0.12.7", "path": "^0.12.7",
"pdfkit": "^0.19.1",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"undici": "^8.5.0", "undici": "^8.5.0",
"vite": "^8.1.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.', '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.', '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.', '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']), ], ['dashboard', 'operations', 'navigation']),
article('ops-scripts', 'Operations', 'Scripts And Variables', 'Create folders, author scripts, use variables, and keep versions.', [ 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.', '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.' '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', [ section('System Jobs', [
'Admins can open System Jobs to see queued/running script executions, controllable background workers, and recent management actions in one place.', '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.' 'Every run-now and cancel action is written to the system job action audit trail with actor, target, status, message, and timestamp.'
]), ]),
section('Logs', [ 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('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', '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('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', 'List job history.'),
apiExample('Get', '/api/jobs/job_123', 'Read one job with host rows and logs.'), 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('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('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.') 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) 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 ( CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL, runplan_id TEXT REFERENCES runplans(id) ON DELETE SET NULL,
@@ -472,6 +498,32 @@ export function migrate() {
created_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS system_job_actions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
job_id TEXT, job_id TEXT,
@@ -531,6 +583,7 @@ export function migrate() {
ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'"); ensureColumn('vcenter_connections', 'target_type', "TEXT NOT NULL DEFAULT 'vcenter'");
ensureColumn('vcenter_connections', 'credential_id', 'TEXT REFERENCES credentials(id) ON DELETE SET NULL'); ensureColumn('vcenter_connections', 'credential_id', 'TEXT REFERENCES credentials(id) ON DELETE SET NULL');
ensureColumn('system_job_actions', 'metadata_json', "TEXT NOT NULL DEFAULT '{}'"); 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 // 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. // read-only connections can never be used to change a tenant by accident.
ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0'); ensureColumn('graph_connections', 'allow_write', 'INTEGER NOT NULL DEFAULT 0');
@@ -601,9 +654,17 @@ export function seed() {
VALUES (?, ?, 'default', ?) VALUES (?, ?, 'default', ?)
ON CONFLICT(key) DO NOTHING 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()) { for (const def of settingDefinitions()) {
if (def.pinned) pinned.run(def.key, def.value, now()); 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() 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({ export const scriptCompatibilitySchema = z.object({
hostId: z.string().optional().default(''), hostId: z.string().optional().default(''),
hostGroupId: z.string().optional().default(''), hostGroupId: z.string().optional().default(''),
@@ -399,3 +417,24 @@ export function normalizeRunPlanPayload(body) {
hostGroupIds: parsed.hostGroupIds || [] 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 { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js'; import { startPromotionWorker } from './services/promotionWorker.js';
import { startHostGroupWorker } from './services/hostGroupWorker.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. // 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 // These defaults are fine for local development but are publicly known, so a
@@ -65,4 +66,5 @@ app.listen(config.port, () => {
startCatalogWorker(); startCatalogWorker();
startPromotionWorker(); startPromotionWorker();
startHostGroupWorker(); 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) { export function camelCustomVariable(row, includeValue = false) {
return { return {
id: row.id, 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) { export function camelSystemJobAction(row) {
return { return {
id: row.id, 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 { db, now } from '../db.js';
import { config } from '../config.js'; import { config } from '../config.js';
const runtimeEditableEnvKeys = new Set(['powershell_bin']);
export function getSetting(key) { export function getSetting(key) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : undefined; return row ? row.value : undefined;
@@ -45,7 +47,7 @@ export function updateSettings(payload) {
for (const [key, value] of entries) { for (const [key, value] of entries) {
// Env-pinned settings are owned by the deployment environment; ignore // Env-pinned settings are owned by the deployment environment; ignore
// attempts to override them so the UI and process never disagree. // 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()); stmt.run(key, String(value ?? ''), now());
} }
return settingsPayload(); return settingsPayload();

View File

@@ -11,10 +11,12 @@ import { graphRoutes } from './graphRoutes.js';
import { helpRoutes } from './helpRoutes.js'; import { helpRoutes } from './helpRoutes.js';
import { hostRoutes } from './hostRoutes.js'; import { hostRoutes } from './hostRoutes.js';
import { jobRoutes } from './jobRoutes.js'; import { jobRoutes } from './jobRoutes.js';
import { jobOutputRoutes } from './jobOutputRoutes.js';
import { logRoutes } from './logRoutes.js'; import { logRoutes } from './logRoutes.js';
import { packagingRoutes } from './packagingRoutes.js'; import { packagingRoutes } from './packagingRoutes.js';
import { psadtRoutes } from './psadtRoutes.js'; import { psadtRoutes } from './psadtRoutes.js';
import { runPlanRoutes } from './runPlanRoutes.js'; import { runPlanRoutes } from './runPlanRoutes.js';
import { runPlanScheduleRoutes } from './runPlanScheduleRoutes.js';
import { settingsRoutes } from './settingsRoutes.js'; import { settingsRoutes } from './settingsRoutes.js';
import { systemJobRoutes } from './systemJobRoutes.js'; import { systemJobRoutes } from './systemJobRoutes.js';
import { userRoutes } from './userRoutes.js'; import { userRoutes } from './userRoutes.js';
@@ -41,6 +43,8 @@ apiRoutes.use('/packaging', packagingRoutes);
apiRoutes.use('/intune/applications', applicationRoutes); apiRoutes.use('/intune/applications', applicationRoutes);
apiRoutes.use('/intune', governanceRoutes); apiRoutes.use('/intune', governanceRoutes);
apiRoutes.use('/runplans', runPlanRoutes); apiRoutes.use('/runplans', runPlanRoutes);
apiRoutes.use('/schedules', runPlanScheduleRoutes);
apiRoutes.use('/jobs', jobRoutes); apiRoutes.use('/jobs', jobRoutes);
apiRoutes.use('/job-output', jobOutputRoutes);
apiRoutes.use('/system-jobs', systemJobRoutes); apiRoutes.use('/system-jobs', systemJobRoutes);
apiRoutes.use('/logs', logRoutes); 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 { path } from '../utils/pathUtils.js';
import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js'; import { analyzePowerShellCompatibility, compatibilityLogLines } from './powershellCompatibilityService.js';
import { normalizeOsFamily } from '../utils/osFamily.js'; import { normalizeOsFamily } from '../utils/osFamily.js';
import { generateJobOutputsSafe } from './jobOutputService.js';
const activeJobs = new Map(); const activeJobs = new Map();
@@ -42,6 +43,19 @@ export function powerShellSpawnFailureLines(error, executable) {
return lines; 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) { export function executeRunPlan(runplanId, userId) {
// Create the durable job record before async execution so the UI can attach to logs immediately. // 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); const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
@@ -88,9 +102,10 @@ export function executeRunPlan(runplanId, userId) {
assets: listExecutableAssets(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 }); logger.error('job runner crashed', { jobId, error });
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId); 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); 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) INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, NULL, ?, 'running', ?, ?, ?) VALUES (?, NULL, ?, 'running', ?, ?, ?)
`).run(jobId, script.id, userId, now(), now()); `).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) INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL) 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}`); 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 }); logger.error('script test runner crashed', { jobId, error });
for (const host of hosts) appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`); 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); 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); 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)) { if (isJobCanceled(jobId)) {
cancelQueuedHosts(jobId); cancelQueuedHosts(jobId);
db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId); db.prepare('UPDATE jobs SET status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ?').run('canceled', now(), jobId);
await generateJobOutputsSafe(jobId);
return; return;
} }
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count; 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); db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
await generateJobOutputsSafe(jobId);
} finally { } finally {
activeJobs.delete(jobId); activeJobs.delete(jobId);
} }
@@ -238,6 +256,7 @@ async function runHost(jobId, script, host, executionContext) {
} }
if (!(await vCenterPowerPreflight(jobId, host))) return; if (!(await vCenterPowerPreflight(jobId, host))) return;
logTransportPreflight(jobId, host);
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`); const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
@@ -284,7 +303,13 @@ async function vCenterPowerPreflight(jobId, host) {
return true; 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. // Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
const runtimeVariables = runtimeVariableValues(host, executionContext); const runtimeVariables = runtimeVariableValues(host, executionContext);
const customVariables = executionContext.customVariables || []; const customVariables = executionContext.customVariables || [];
@@ -296,37 +321,53 @@ function buildWrapper(content, host, executionContext = {}) {
const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]); const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`; const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
const escaped = scriptWithPrelude.replace(/'@/g, "'`@"); 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') { if (host.transport === 'local') {
return [ return [
'$ErrorActionPreference = "Continue"', ...scriptSetup,
`$scriptText = @'`, ...scriptTextBlock,
escaped, ' & $scriptBlock',
"'@", ...scriptExit
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
'& $scriptBlock'
].join('\n'); ].join('\n');
} }
if (host.transport === 'ssh') { if (host.transport === 'ssh') {
return [ return [
'$ErrorActionPreference = "Continue"', ...scriptSetup,
`$scriptText = @'`, ...scriptTextBlock,
escaped, ` Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock -ErrorAction Stop`,
"'@", ...scriptExit
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
].join('\n'); ].join('\n');
} }
return [ return [
'$ErrorActionPreference = "Continue"', ...scriptSetup,
'$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force', ' $securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
'$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)', ' $credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
`$scriptText = @'`, ...scriptTextBlock,
escaped, ` Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock -ErrorAction Stop`,
"'@", ...scriptExit
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
].join('\n'); ].join('\n');
} }
@@ -380,7 +421,9 @@ function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
}); });
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (settled) return; 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)) { if (isJobCanceled(jobId)) {
settle('canceled', 130); settle('canceled', 130);
return; 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 { syncDynamicHostGroups } from '../models/hostGroupModel.js';
import { runCatalogChecks } from './catalogWorker.js'; import { runCatalogChecks } from './catalogWorker.js';
import { runAutoPromotions } from './promotionWorker.js'; import { runAutoPromotions } from './promotionWorker.js';
import { dispatchDueRunPlanSchedules } from './runPlanScheduleWorker.js';
import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js'; import { cancelExecutionJob, appendJobLog, activeExecutionJobIds } from './powershellRunner.js';
const backgroundRuns = new Map(); const backgroundRuns = new Map();
@@ -29,6 +30,13 @@ const BACKGROUND_JOBS = [
category: 'Intune', category: 'Intune',
description: 'Promotes deployment rings when configured soak windows elapse.', description: 'Promotes deployment rings when configured soak windows elapse.',
run: async ({ signal } = {}) => runAutoPromotions({ signal }) 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}>`; 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) { async function connectStandaloneSoap(settings, trace = null) {
pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, { pushTraceInfo(trace, `Using ${WEB_SERVICES_PROFILE_LABEL}`, {
mode: 'web-services', mode: 'web-services',
@@ -826,7 +855,7 @@ async function createVmContainerView(settings, refs, trace = null) {
return extractManagedObject(response, 'returnval', 'ContainerView'); return extractManagedObject(response, 'returnval', 'ContainerView');
} }
function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) { export function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
const paths = [ const paths = [
'name', 'name',
'guest.hostName', 'guest.hostName',
@@ -841,12 +870,10 @@ function retrievePropertiesBody(propertyCollector, viewRef, limit = 100) {
'summary.runtime.powerState' 'summary.runtime.powerState'
]; ];
return `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` + return `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', propertyCollector, 'PropertyCollector')}` + soapRefElement('_this', propertyCollector, 'PropertyCollector') +
`<specSet>` + `<specSet>` +
`<propSet><type>VirtualMachine</type><all>false</all>${paths.map((pathSet) => `<pathSet>${pathSet}</pathSet>`).join('')}</propSet>` + propertySpecXml('VirtualMachine', paths) +
`<objectSet>${soapRefElement('obj', viewRef, 'ContainerView')}<skip>true</skip>` + objectSpecXml(viewRef, { skip: true, selectSet: traversalSpecXml() }) +
`<selectSet xsi:type="TraversalSpec"><name>containerViewTraversal</name><type>ContainerView</type><path>view</path><skip>false</skip></selectSet>` +
`</objectSet>` +
`</specSet>` + `</specSet>` +
`<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` + `<options><maxObjects>${Math.max(1, Math.min(Number(limit) || 100, 500))}</maxObjects></options>` +
`</RetrievePropertiesEx>`; `</RetrievePropertiesEx>`;
@@ -888,9 +915,11 @@ async function retrieveStandaloneVmPowerState(settings, vmId, trace = null) {
const refs = await connectStandaloneSoap(settings, trace); const refs = await connectStandaloneSoap(settings, trace);
const vmRef = { type: 'VirtualMachine', value: vmId }; const vmRef = { type: 'VirtualMachine', value: vmId };
const body = `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` + const body = `<RetrievePropertiesEx xmlns="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
`${soapRefElement('_this', refs.propertyCollector, 'PropertyCollector')}` + soapRefElement('_this', refs.propertyCollector, 'PropertyCollector') +
`<specSet><propSet><type>VirtualMachine</type><all>false><pathSet>runtime.powerState</pathSet></propSet>` + `<specSet>` +
`<objectSet>${soapRefElement('obj', vmRef, 'VirtualMachine')}<skip>false</skip></objectSet></specSet>` + propertySpecXml('VirtualMachine', ['runtime.powerState']) +
objectSpecXml(vmRef, { skip: false }) +
`</specSet>` +
`<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`; `<options><maxObjects>1</maxObjects></options></RetrievePropertiesEx>`;
const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint }); const response = await vSphereSoapFetch(settings, 'RetrievePropertiesEx', body, trace, { cookie: refs.sessionCookie, endpoint: refs.endpoint });
const object = parseVmPropertyObjects(response)[0]; 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_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_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: '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: '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: '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_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_base_url', value: config.vcenter.baseUrl, pinned: envProvided('VCENTER_BASE_URL') },
{ key: 'vcenter_username', value: config.vcenter.username, pinned: envProvided('VCENTER_USERNAME') }, { 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 { listJobs, getJob } = await load('../models/jobModel.js');
const { cancelSystemJob, listSystemJobs, runSystemJob } = await load('../services/systemJobService.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 owner = makeUser({ email: 'job-owner@test.local' });
const stranger = makeUser({ email: 'job-stranger@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)); 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', () => { test('system jobs list execution and background jobs', () => {
const listJobId = 'job_list_system_test_1'; const listJobId = 'job_list_system_test_1';
db.prepare(`INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at) 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 test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const { seed } = await load('../db.js');
const { getSetting, getBooleanSetting, getStringSetting, updateSettings, effectiveTrustedOrigins } = await load('../models/settingsModel.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', () => { test('unpinned settings seed as editable defaults', () => {
// No SERVER_FQDN env var is set in the test environment, so server_fqdn is a // 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'); 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', () => { test('PowerShell spawn failures include operator guidance', () => {
const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh'); const lines = powerShellSpawnFailureLines({ code: 'ENOENT', message: 'spawn pwsh ENOENT' }, 'pwsh');
assert.ok(lines.some((line) => line.includes('configured PowerShell executable "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'))); 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', () => { test('effectiveTrustedOrigins merges admin edits with built-in defaults', () => {
updateSettings({ trusted_origins: 'https://ops.example.com' }); updateSettings({ trusted_origins: 'https://ops.example.com' });
const origins = effectiveTrustedOrigins(); const origins = effectiveTrustedOrigins();

View File

@@ -15,6 +15,7 @@ const {
normalizeBaseUrl, normalizeBaseUrl,
normalizeVCenterVm, normalizeVCenterVm,
previewVCenterHosts, previewVCenterHosts,
retrievePropertiesBody,
settingsFromVCenterConnection, settingsFromVCenterConnection,
vmMatchesFilter vmMatchesFilter
} = { } = {
@@ -155,8 +156,22 @@ test('standalone VMware host connections use the Web Services profile', async ()
assert.equal(preview.candidates[0].powerState, 'POWERED_ON'); 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 () => { test('standalone VMware VM candidates build managed host payloads', async () => {
const { restore } = installStandaloneEsxiSoapStub(); const { calls, restore } = installStandaloneEsxiSoapStub();
const connection = { const connection = {
id: 'vc_host', id: 'vc_host',
name: 'ESXi 01', 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.ok(payload.tags.includes('esxi-vm:vim.VirtualMachine:101'));
assert.equal(power.checked, true); assert.equal(power.checked, true);
assert.equal(power.state, 'POWERED_ON'); 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 () => { test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {