Initial
This commit is contained in:
@@ -597,6 +597,16 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<SystemJobsView
|
||||
v-if="view === 'system-jobs'"
|
||||
:jobs="systemJobs"
|
||||
:actions="systemJobActions"
|
||||
:loading="systemJobsLoading"
|
||||
@refresh="refreshSystemJobs"
|
||||
@run="runSystemJob"
|
||||
@cancel="cancelSystemJob"
|
||||
/>
|
||||
|
||||
<section v-if="view === 'admin'" class="admin-page">
|
||||
<IntuneGraphConfig
|
||||
:graph-connections="graphConnections"
|
||||
@@ -984,6 +994,7 @@ import ErrorView from './views/ErrorView.vue';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
import PsadtView from './views/PsadtView.vue';
|
||||
import AssetsView from './views/AssetsView.vue';
|
||||
import SystemJobsView from './views/SystemJobsView.vue';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker() {
|
||||
@@ -1205,6 +1216,9 @@ const assetFolders = ref([]);
|
||||
const assets = ref([]);
|
||||
const runplans = ref([]);
|
||||
const jobs = ref([]);
|
||||
const systemJobs = ref([]);
|
||||
const systemJobActions = ref([]);
|
||||
const systemJobsLoading = ref(false);
|
||||
const variableCatalog = ref({ builtIns: [], psadt: [], custom: [] });
|
||||
const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure: [], invokeParameters: [], functions: [], admxPolicies: [], launchCommands: [], snippets: [] });
|
||||
const psadtProfiles = ref([]);
|
||||
@@ -1299,6 +1313,7 @@ const nav = [
|
||||
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound },
|
||||
{ id: 'runplans', label: 'RunPlans', icon: Rocket },
|
||||
{ id: 'logs', label: 'Logs', icon: Activity },
|
||||
{ id: 'system-jobs', label: 'System Jobs', icon: Database },
|
||||
{ id: 'users', label: 'Users & Groups', icon: UsersRound },
|
||||
{ id: 'admin', label: 'Config', icon: Settings }
|
||||
];
|
||||
@@ -1745,6 +1760,11 @@ async function refreshAll() {
|
||||
vcenterConnections.value = vcenterRows;
|
||||
runplans.value = runplanRows;
|
||||
jobs.value = jobRows;
|
||||
if (user.value?.role === 'admin') await refreshSystemJobs(true);
|
||||
else {
|
||||
systemJobs.value = [];
|
||||
systemJobActions.value = [];
|
||||
}
|
||||
if (selectedScript.value) {
|
||||
const refreshedSelection = scriptRows.find((script) => script.id === selectedScript.value.id);
|
||||
if (refreshedSelection) selectScript(refreshedSelection);
|
||||
@@ -2532,7 +2552,7 @@ async function previewVCenterImport(payload) {
|
||||
vcenterImportError.value = '';
|
||||
vcenterTraceEntries.value = [{
|
||||
info: true,
|
||||
message: 'vCenter preview request is running.',
|
||||
message: 'VMware host preview request is running.',
|
||||
details: {
|
||||
filter: payload.filter,
|
||||
limit: payload.limit,
|
||||
@@ -2550,7 +2570,7 @@ async function previewVCenterImport(payload) {
|
||||
details: {
|
||||
route: '/api/hosts/import/vcenter/preview',
|
||||
elapsedMs: 8000,
|
||||
note: 'If this remains here, the backend is still waiting on vCenter DNS, TCP, TLS, authentication, or VM inventory response.'
|
||||
note: 'If this remains here, the backend is still waiting on VMware DNS, TCP, TLS, authentication, or inventory response.'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -2564,7 +2584,7 @@ async function previewVCenterImport(payload) {
|
||||
details: {
|
||||
route: '/api/hosts/import/vcenter/preview',
|
||||
elapsedMs: 18000,
|
||||
note: 'The backend vCenter request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.'
|
||||
note: 'The backend VMware request timeout should return a traceable timeout shortly unless the API process is blocked before dispatch.'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -2581,11 +2601,14 @@ async function previewVCenterImport(payload) {
|
||||
}];
|
||||
vcenterPreviewRows.value = result.candidates || [];
|
||||
vcenterTraceOpen.value = true;
|
||||
notify(`Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
|
||||
const standalone = result.diagnostics?.standaloneHost;
|
||||
notify(standalone
|
||||
? `Prepared ${result.count || 0} standalone ESXi host candidate(s)`
|
||||
: `Found ${result.count || 0} vCenter host candidate(s) from ${result.diagnostics?.totalFromVCenter ?? 'unknown'} VM(s)`);
|
||||
} catch (err) {
|
||||
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
|
||||
info: true,
|
||||
message: 'Preview failed before a vCenter HTTP trace could be captured.',
|
||||
message: 'Preview failed before a VMware API trace could be captured.',
|
||||
details: { error: err.message, status: err.status || null }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2602,7 +2625,7 @@ async function importVCenterHosts(payload) {
|
||||
vcenterImportError.value = '';
|
||||
vcenterTraceEntries.value = [{
|
||||
info: true,
|
||||
message: 'vCenter import request is running.',
|
||||
message: 'VMware host import request is running.',
|
||||
details: { vmIds: payload.vmIds, startedAt: new Date().toISOString() }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2619,7 +2642,7 @@ async function importVCenterHosts(payload) {
|
||||
} catch (err) {
|
||||
vcenterTraceEntries.value = err.payload?.trace?.length ? err.payload.trace : [{
|
||||
info: true,
|
||||
message: 'Import failed before a vCenter HTTP trace could be captured.',
|
||||
message: 'Import failed before a VMware API trace could be captured.',
|
||||
details: { error: err.message, status: err.status || null }
|
||||
}];
|
||||
vcenterTraceOpen.value = true;
|
||||
@@ -2717,6 +2740,49 @@ async function loadSystemLogs() {
|
||||
systemLogs.value = await api.get('/api/logs/system?limit=300');
|
||||
}
|
||||
|
||||
async function refreshSystemJobs(quiet = false) {
|
||||
if (user.value?.role !== 'admin') return;
|
||||
systemJobsLoading.value = true;
|
||||
try {
|
||||
const result = await api.get('/api/system-jobs');
|
||||
systemJobs.value = result.jobs || [];
|
||||
systemJobActions.value = result.actions || [];
|
||||
} catch (err) {
|
||||
if (!quiet) notify(err.message, 'error');
|
||||
} finally {
|
||||
systemJobsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSystemJob(jobId) {
|
||||
systemJobsLoading.value = true;
|
||||
try {
|
||||
const result = await api.post(`/api/system-jobs/${encodeURIComponent(jobId)}/run`, {});
|
||||
notify(result.action?.message || 'System job completed');
|
||||
await refreshSystemJobs(true);
|
||||
} catch (err) {
|
||||
notify(err.message, 'error');
|
||||
await refreshSystemJobs(true);
|
||||
} finally {
|
||||
systemJobsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSystemJob({ id, reason }) {
|
||||
systemJobsLoading.value = true;
|
||||
try {
|
||||
const result = await api.post(`/api/system-jobs/${encodeURIComponent(id)}/cancel`, { reason });
|
||||
notify(result.action?.message || 'Cancel requested');
|
||||
jobs.value = await api.get('/api/jobs');
|
||||
await refreshSystemJobs(true);
|
||||
} catch (err) {
|
||||
notify(err.message, 'error');
|
||||
await refreshSystemJobs(true);
|
||||
} finally {
|
||||
systemJobsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const payload = {};
|
||||
Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Import hosts from vCenter"
|
||||
eyebrow="VMWARE VCENTER"
|
||||
description="Search vCenter virtual machines, preview discovered identity data, and create managed hosts with one shared vault credential."
|
||||
title="Import VMware hosts"
|
||||
eyebrow="VMWARE"
|
||||
description="Import virtual machines from vCenter or standalone ESXi inventory into the Host Library."
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
@@ -12,17 +12,17 @@
|
||||
<div class="section-heading">
|
||||
<span>1</span>
|
||||
<div>
|
||||
<h4>Search filter</h4>
|
||||
<p>Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.</p>
|
||||
<h4>{{ isStandaloneHost ? 'ESXi VM inventory' : 'Search filter' }}</h4>
|
||||
<p>{{ isStandaloneHost ? 'Use the vSphere Web Services API on the selected ESXi host to enumerate guest VMs before writing hosts.' : 'Use vCenter VM names, guest hostnames, FQDNs, or IPs to narrow the import set before writing hosts.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vcenter-filter-grid">
|
||||
<label class="filter-value">
|
||||
<span>vCenter system</span>
|
||||
<span>VMware connection</span>
|
||||
<select v-model="draft.connectionId">
|
||||
<option :value="null">Configured default</option>
|
||||
<option v-for="connection in importConnections" :key="connection.id" :value="connection.id">
|
||||
{{ connection.name }} - {{ connection.baseUrl }}
|
||||
{{ connection.name }} - {{ connection.targetType === 'host' ? 'Standalone ESXi' : 'vCenter' }} - {{ connection.baseUrl }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -52,14 +52,19 @@
|
||||
<span>Preview limit</span>
|
||||
<input v-model.number="draft.limit" type="number" min="1" max="500" />
|
||||
</label>
|
||||
<div v-if="isStandaloneHost" class="standalone-host-callout">
|
||||
<strong>{{ selectedConnection?.name || 'Selected ESXi host' }}</strong>
|
||||
<span>{{ selectedConnection?.baseUrl || 'Choose a standalone ESXi connection above.' }}</span>
|
||||
<small>Standalone ESXi inventory is queried through the /sdk Web Services endpoint.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-actions">
|
||||
<button class="ghost-button" type="button" :disabled="loading" @click="emitPreview">
|
||||
<i class="mdi mdi-magnify" aria-hidden="true"></i>Preview matches
|
||||
<i class="mdi mdi-magnify" aria-hidden="true"></i>{{ isStandaloneHost ? 'Preview VMs' : 'Preview matches' }}
|
||||
</button>
|
||||
<span v-if="status" class="vcenter-status">
|
||||
<span :class="['status-dot', activeStatus.configured ? 'success' : 'warning']"></span>
|
||||
{{ activeStatus.configured ? `Using ${activeStatus.name || 'vCenter'} at ${activeStatus.baseUrl}` : 'vCenter settings incomplete' }}
|
||||
{{ activeStatus.configured ? `Using ${activeStatus.name || activeStatus.typeLabel || 'VMware'} at ${activeStatus.baseUrl}` : 'VMware settings incomplete' }}
|
||||
</span>
|
||||
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
|
||||
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>View API trace
|
||||
@@ -123,11 +128,11 @@
|
||||
<span>3</span>
|
||||
<div>
|
||||
<h4>Preview and select</h4>
|
||||
<p>Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.</p>
|
||||
<p>{{ isStandaloneHost ? 'Guest FQDN/IP data comes from VMware Tools through ESXi. Missing values are imported with the VM name as the address fallback.' : 'Guest FQDN/IP data depends on VMware Tools. Missing values are imported with the VM name as the address fallback.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="error" class="inline-error">{{ error }}</div>
|
||||
<div v-if="diagnostics" class="vcenter-diagnostics">
|
||||
<div v-if="diagnostics && !diagnostics.standaloneHost" class="vcenter-diagnostics">
|
||||
<strong>Preview diagnostics</strong>
|
||||
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by vCenter</span>
|
||||
<span>{{ diagnostics.summaryMatched }} VM(s) matched before guest enrichment</span>
|
||||
@@ -137,6 +142,15 @@
|
||||
</button>
|
||||
<small v-if="diagnostics.sampleNames?.length">Sample inventory names: {{ diagnostics.sampleNames.join(', ') }}</small>
|
||||
</div>
|
||||
<div v-else-if="diagnostics?.standaloneHost" class="vcenter-diagnostics">
|
||||
<strong>Standalone ESXi preview</strong>
|
||||
<span>{{ diagnostics.totalFromVCenter }} VM(s) returned by ESXi Web Services inventory</span>
|
||||
<span>{{ diagnostics.summaryMatched }} VM(s) matched the current filter</span>
|
||||
<span>{{ diagnostics.scanned }} VM(s) scanned for guest FQDN/IP details</span>
|
||||
<button v-if="traceCount" class="ghost-button compact" type="button" @click="$emit('open-trace')">
|
||||
<i class="mdi mdi-file-search-outline" aria-hidden="true"></i>Trace
|
||||
</button>
|
||||
</div>
|
||||
<div class="preview-toolbar">
|
||||
<button class="ghost-button compact" type="button" :disabled="!previewRows.length" @click="toggleAll">
|
||||
<i class="mdi mdi-checkbox-marked-outline" aria-hidden="true"></i>{{ allSelected ? 'Clear selection' : 'Select all' }}
|
||||
@@ -219,13 +233,16 @@ const draft = reactive({
|
||||
});
|
||||
const selectedIds = ref([]);
|
||||
|
||||
const importConnections = computed(() => props.connections.filter((connection) => connection.targetType !== 'host'));
|
||||
const importConnections = computed(() => props.connections);
|
||||
const selectedConnection = computed(() => props.connections.find((item) => item.id === draft.connectionId) || null);
|
||||
const isStandaloneHost = computed(() => selectedConnection.value?.targetType === 'host');
|
||||
const allSelected = computed(() => props.previewRows.length > 0 && selectedIds.value.length === props.previewRows.length);
|
||||
const activeStatus = computed(() => {
|
||||
if (draft.connectionId) {
|
||||
const connection = importConnections.value.find((item) => item.id === draft.connectionId);
|
||||
const connection = selectedConnection.value;
|
||||
return {
|
||||
name: connection?.name || 'Selected vCenter',
|
||||
name: connection?.name || 'Selected VMware connection',
|
||||
typeLabel: connection?.targetType === 'host' ? 'ESXi host' : 'vCenter',
|
||||
configured: Boolean(connection?.baseUrl && connection?.username),
|
||||
baseUrl: connection?.baseUrl || ''
|
||||
};
|
||||
|
||||
@@ -4645,6 +4645,95 @@ body {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.system-jobs-page {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.system-job-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.system-job-metrics article {
|
||||
min-height: 96px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(156, 199, 232, .14);
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 0, rgba(88, 221, 255, .13), transparent 42%),
|
||||
linear-gradient(145deg, rgba(15, 31, 58, .76), rgba(5, 12, 28, .72));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .045);
|
||||
}
|
||||
|
||||
.system-job-metrics strong {
|
||||
color: #fff;
|
||||
font: 800 28px/1 var(--display-font);
|
||||
}
|
||||
|
||||
.system-job-metrics small {
|
||||
color: rgba(200, 218, 238, .68);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.system-job-filter {
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.system-jobs-table .resource-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(132px, 160px)) minmax(260px, 1fr) 92px auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.system-jobs-table .system-job-filter,
|
||||
.system-jobs-table .table-density-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.system-jobs-table .system-job-filter select,
|
||||
.system-jobs-table .table-density-select select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.system-jobs-table .search-control {
|
||||
width: min(100%, 360px);
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.system-jobs-table .toolbar-count {
|
||||
justify-self: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-jobs-table .resource-table td:first-child strong,
|
||||
.system-jobs-table .resource-table td:first-child small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.system-jobs-table .resource-table td:first-child small {
|
||||
margin-top: 3px;
|
||||
color: rgba(202, 216, 238, .58);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.system-job-audit {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-job-audit .section-title {
|
||||
padding: 16px 16px 14px;
|
||||
}
|
||||
|
||||
.system-job-audit .data-table-wrap {
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.resource-table-panel {
|
||||
padding: 15px;
|
||||
border-radius: 20px;
|
||||
@@ -5128,6 +5217,23 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.system-job-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.system-jobs-table .resource-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.system-jobs-table .search-control {
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.system-jobs-table .toolbar-count {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.summary-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -5250,9 +5356,18 @@ body {
|
||||
.status-pill.stdout,
|
||||
.status-pill.info,
|
||||
.status-pill.completed,
|
||||
.status-pill.success,
|
||||
.status-pill.local { color: var(--mint); background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .2); }
|
||||
.status-pill.warn,
|
||||
.status-pill.running { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
|
||||
.status-pill.running,
|
||||
.status-pill.canceling,
|
||||
.status-pill.started { color: var(--amber); background: rgba(255, 189, 106, .1); border-color: rgba(255, 189, 106, .2); }
|
||||
.status-pill.canceled,
|
||||
.status-pill.skipped { color: rgba(226, 237, 250, .78); background: rgba(156, 199, 232, .08); border-color: rgba(156, 199, 232, .18); }
|
||||
.status-pill.idle,
|
||||
.status-pill.background,
|
||||
.status-pill.execution,
|
||||
.status-pill.neutral { color: #8be0ff; background: rgba(88, 221, 255, .09); border-color: rgba(88, 221, 255, .2); }
|
||||
|
||||
.status-pill.windows { color: #8be0ff; background: rgba(88, 221, 255, .1); border-color: rgba(88, 221, 255, .24); }
|
||||
.status-pill.linux { color: #64e7bd; background: rgba(100, 231, 189, .1); border-color: rgba(100, 231, 189, .24); }
|
||||
@@ -7499,6 +7614,43 @@ input:read-only {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.standalone-host-callout {
|
||||
min-height: 58px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(88, 221, 255, .18);
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
background:
|
||||
radial-gradient(circle at 10% 0, rgba(88, 221, 255, .16), transparent 42%),
|
||||
linear-gradient(145deg, rgba(9, 24, 45, .82), rgba(4, 11, 25, .72));
|
||||
}
|
||||
|
||||
.standalone-host-callout strong,
|
||||
.standalone-host-callout span,
|
||||
.standalone-host-callout small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.standalone-host-callout strong {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.standalone-host-callout span {
|
||||
color: rgba(218, 236, 255, .78);
|
||||
font: 700 11px "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.standalone-host-callout small {
|
||||
color: rgba(180, 199, 222, .64);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wizard-actions,
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
|
||||
241
client/src/views/SystemJobsView.vue
Normal file
241
client/src/views/SystemJobsView.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<section class="resource-page system-jobs-page">
|
||||
<div class="page-header compact-header">
|
||||
<div>
|
||||
<span class="eyebrow">OPERATIONS CONTROL</span>
|
||||
<h2>System Jobs</h2>
|
||||
<p>Monitor background workers and cancel stuck script execution jobs from one audited control surface.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="ghost-button compact" type="button" @click="$emit('refresh')">
|
||||
<i class="mdi mdi-refresh" aria-hidden="true"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-job-metrics">
|
||||
<article>
|
||||
<span class="eyebrow">RUNNING</span>
|
||||
<strong>{{ runningCount }}</strong>
|
||||
<small>active job(s)</small>
|
||||
</article>
|
||||
<article>
|
||||
<span class="eyebrow">CANCELLABLE</span>
|
||||
<strong>{{ cancellableCount }}</strong>
|
||||
<small>script execution job(s)</small>
|
||||
</article>
|
||||
<article>
|
||||
<span class="eyebrow">BACKGROUND</span>
|
||||
<strong>{{ backgroundCount }}</strong>
|
||||
<small>managed worker(s)</small>
|
||||
</article>
|
||||
<article>
|
||||
<span class="eyebrow">AUDIT</span>
|
||||
<strong>{{ actions.length }}</strong>
|
||||
<small>recent action(s)</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ResourceTable
|
||||
class="system-jobs-table"
|
||||
:columns="jobColumns"
|
||||
:rows="pagedJobs"
|
||||
:total="filteredJobs.length"
|
||||
:search="filters.search"
|
||||
:page="filters.page"
|
||||
:page-count="pageCount"
|
||||
:page-size="filters.pageSize"
|
||||
:sort="filters.sort"
|
||||
:direction="filters.direction"
|
||||
search-placeholder="Search job, worker, category, status..."
|
||||
item-label="jobs"
|
||||
empty-text="No system jobs match the current filters."
|
||||
@update:search="filters.search = $event"
|
||||
@update:page="filters.page = $event"
|
||||
@update:pageSize="filters.pageSize = $event"
|
||||
@sort="setSort"
|
||||
>
|
||||
<template #toolbar-leading>
|
||||
<label class="table-density-select system-job-filter">
|
||||
<span>Type</span>
|
||||
<select v-model="filters.type">
|
||||
<option value="">All</option>
|
||||
<option value="execution">Execution</option>
|
||||
<option value="background">Background</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="table-density-select system-job-filter">
|
||||
<span>Status</span>
|
||||
<select v-model="filters.status">
|
||||
<option value="">Any</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<template #cell-name="{ row }">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<small>{{ row.description }}</small>
|
||||
</template>
|
||||
<template #cell-type="{ row }"><span :class="['status-pill', row.type]">{{ row.type }}</span></template>
|
||||
<template #cell-status="{ row }"><span :class="['status-pill', row.status]">{{ row.status }}</span></template>
|
||||
<template #cell-startedAt="{ row }">{{ shortDate(row.startedAt || row.createdAt || row.metadata?.lastRunAt) || '-' }}</template>
|
||||
<template #cell-hosts="{ row }">
|
||||
<span v-if="row.type === 'execution'">{{ row.runningHosts || 0 }} running / {{ row.hostCount || 0 }} total</span>
|
||||
<span v-else>{{ row.metadata?.lastSummary ? summaryText(row.metadata.lastSummary) : 'worker task' }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<button v-if="row.canRunNow" class="ghost-button compact" type="button" :disabled="loading" @click="$emit('run', row.id)">
|
||||
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>Run now
|
||||
</button>
|
||||
<button v-if="row.canCancel" class="ghost-button compact danger-text" type="button" :disabled="loading" @click="openCancel(row)">
|
||||
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel
|
||||
</button>
|
||||
<span v-if="!row.canRunNow && !row.canCancel" class="status-pill neutral">view only</span>
|
||||
</div>
|
||||
</template>
|
||||
</ResourceTable>
|
||||
|
||||
<article class="glass-panel system-job-audit">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<span class="eyebrow">AUDIT TRAIL</span>
|
||||
<h3>Job management actions</h3>
|
||||
<p>Every run-now and cancel operation is recorded with actor, status, message, and metadata.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table resource-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Action</th><th>Target</th><th>Status</th><th>Actor</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="action in actions.slice(0, 40)" :key="action.id">
|
||||
<td>{{ shortDate(action.createdAt) }}</td>
|
||||
<td><strong>{{ action.action }}</strong></td>
|
||||
<td><code>{{ action.targetId }}</code></td>
|
||||
<td><span :class="['status-pill', action.status]">{{ action.status }}</span></td>
|
||||
<td>{{ action.actorName || action.actorUserId || '-' }}</td>
|
||||
<td>{{ action.message || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!actions.length" class="widget-empty">No job management actions have been recorded yet.</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<BaseModal
|
||||
:open="Boolean(cancelTarget)"
|
||||
title="Cancel execution job"
|
||||
eyebrow="JOB MANAGEMENT"
|
||||
:description="cancelTarget ? `Cancel ${cancelTarget.name}. Running PowerShell processes will be signaled and queued hosts will be marked canceled.` : ''"
|
||||
@close="cancelTarget = null"
|
||||
>
|
||||
<form class="form-grid" @submit.prevent="submitCancel">
|
||||
<label class="full">
|
||||
<span>Reason</span>
|
||||
<textarea v-model="cancelReason" rows="3" placeholder="Stuck remoting session, bad target selection, operator requested stop..."></textarea>
|
||||
</label>
|
||||
<div class="form-actions modal-actions full">
|
||||
<button class="ghost-button" type="button" @click="cancelTarget = null">
|
||||
<i class="mdi mdi-close" aria-hidden="true"></i>Close
|
||||
</button>
|
||||
<button class="primary-action danger-action" type="submit">
|
||||
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import BaseModal from '../components/ui/BaseModal.vue';
|
||||
import ResourceTable from '../components/ui/ResourceTable.vue';
|
||||
|
||||
const props = defineProps({
|
||||
jobs: { type: Array, default: () => [] },
|
||||
actions: { type: Array, default: () => [] },
|
||||
loading: Boolean
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh', 'run', 'cancel']);
|
||||
|
||||
const filters = reactive({ search: '', type: '', status: '', page: 1, pageSize: 8, sort: 'status', direction: 'asc' });
|
||||
const cancelTarget = ref(null);
|
||||
const cancelReason = ref('');
|
||||
const jobColumns = [
|
||||
{ key: 'name', label: 'Job / Worker' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'startedAt', label: 'Started / Last Run' },
|
||||
{ key: 'hosts', label: 'Scope', sortable: false },
|
||||
{ key: 'actions', label: 'Actions', sortable: false }
|
||||
];
|
||||
|
||||
const filteredJobs = computed(() => {
|
||||
const term = filters.search.trim().toLowerCase();
|
||||
return props.jobs.filter((job) => {
|
||||
if (filters.type && job.type !== filters.type) return false;
|
||||
if (filters.status && job.status !== filters.status) return false;
|
||||
if (!term) return true;
|
||||
return [job.name, job.description, job.category, job.status, job.id]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(term));
|
||||
}).sort((a, b) => {
|
||||
const left = sortableValue(a, filters.sort);
|
||||
const right = sortableValue(b, filters.sort);
|
||||
const result = left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return filters.direction === 'asc' ? result : -result;
|
||||
});
|
||||
});
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(filteredJobs.value.length / filters.pageSize)));
|
||||
const pagedJobs = computed(() => {
|
||||
const start = (Math.min(filters.page, pageCount.value) - 1) * filters.pageSize;
|
||||
return filteredJobs.value.slice(start, start + filters.pageSize);
|
||||
});
|
||||
const runningCount = computed(() => props.jobs.filter((job) => ['queued', 'running'].includes(job.status)).length);
|
||||
const cancellableCount = computed(() => props.jobs.filter((job) => job.canCancel).length);
|
||||
const backgroundCount = computed(() => props.jobs.filter((job) => job.type === 'background').length);
|
||||
|
||||
watch(() => [filters.search, filters.type, filters.status, filters.pageSize], () => { filters.page = 1; });
|
||||
|
||||
function setSort(key) {
|
||||
if (filters.sort === key) filters.direction = filters.direction === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
filters.sort = key;
|
||||
filters.direction = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
function openCancel(row) {
|
||||
cancelTarget.value = row;
|
||||
cancelReason.value = '';
|
||||
}
|
||||
|
||||
function submitCancel() {
|
||||
if (!cancelTarget.value) return;
|
||||
emit('cancel', { id: cancelTarget.value.id, reason: cancelReason.value.trim() });
|
||||
cancelTarget.value = null;
|
||||
}
|
||||
|
||||
function sortableValue(row, key) {
|
||||
if (key === 'startedAt') return row.startedAt || row.createdAt || row.metadata?.lastRunAt || '';
|
||||
return String(row[key] || '');
|
||||
}
|
||||
|
||||
function shortDate(value) {
|
||||
if (!value) return '';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function summaryText(summary = {}) {
|
||||
return Object.entries(summary).map(([key, value]) => `${key}: ${value}`).join(', ') || 'completed';
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user