This commit is contained in:
2026-06-25 18:02:16 -05:00
parent 402bf6782a
commit da16e5ff56
17 changed files with 1475 additions and 69 deletions

View File

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

View File

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

View File

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

View 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>