Initial
This commit is contained in:
@@ -597,6 +597,31 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<JobOutputView
|
||||
v-if="view === 'job-output'"
|
||||
:outputs="jobOutputs"
|
||||
:jobs="jobs"
|
||||
:selected-output="selectedJobOutput"
|
||||
:loading="jobOutputLoading"
|
||||
@refresh="refreshJobOutputs"
|
||||
@generate="generateJobOutputs"
|
||||
@view="viewJobOutput"
|
||||
@download="downloadJobOutput"
|
||||
/>
|
||||
|
||||
<SchedulingView
|
||||
v-if="view === 'scheduling'"
|
||||
:schedules="runPlanSchedules"
|
||||
:history="runPlanScheduleHistory"
|
||||
:runplans="runplans"
|
||||
:loading="schedulingLoading"
|
||||
@refresh="refreshSchedules"
|
||||
@save="saveSchedule"
|
||||
@delete="deleteSchedule"
|
||||
@toggle="toggleSchedule"
|
||||
@run-now="runScheduleNow"
|
||||
/>
|
||||
|
||||
<SystemJobsView
|
||||
v-if="view === 'system-jobs'"
|
||||
:jobs="systemJobs"
|
||||
@@ -645,6 +670,7 @@
|
||||
:source-label="settingSourceLabel"
|
||||
:is-boolean-setting="isBooleanSetting"
|
||||
:normalized-boolean-setting="normalizedBooleanSetting"
|
||||
:is-locked-setting="isLockedSetting"
|
||||
@toggle="toggleConfigSection(section.section)"
|
||||
@toggle-boolean="toggleBooleanSetting"
|
||||
/>
|
||||
@@ -967,8 +993,8 @@ import 'monaco-editor/esm/vs/editor/contrib/snippet/browser/snippetController2.j
|
||||
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import {
|
||||
Activity, AlertTriangle, BarChart3, BookOpenText, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink,
|
||||
FileCode2, FilePlus2, Folder, FolderPlus, Gauge, KeyRound, ListFilter, ListPlus, LockKeyhole, Mail,
|
||||
Activity, AlertTriangle, BarChart3, BookOpenText, CalendarClock, CheckCircle2, ChevronDown, ChevronRight, ChevronUp, Copy, Database, Download, ExternalLink,
|
||||
FileCode2, FilePlus2, FileText, Folder, FolderPlus, Gauge, KeyRound, ListFilter, ListPlus, LockKeyhole, Mail,
|
||||
PackageOpen, Pencil, PieChart, Play, PlayCircle, Plus, Palette, Rocket, RotateCcw, Save, Search, Server, Settings, ShieldCheck, SlidersHorizontal, Sparkles, Trash2, UserPlus,
|
||||
UserCircle, UsersRound, X
|
||||
} from '@lucide/vue';
|
||||
@@ -996,6 +1022,8 @@ import LoginView from './views/LoginView.vue';
|
||||
import PsadtView from './views/PsadtView.vue';
|
||||
import AssetsView from './views/AssetsView.vue';
|
||||
import SystemJobsView from './views/SystemJobsView.vue';
|
||||
import JobOutputView from './views/JobOutputView.vue';
|
||||
import SchedulingView from './views/SchedulingView.vue';
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker() {
|
||||
@@ -1164,6 +1192,12 @@ const settingMetadata = {
|
||||
placeholder: '60',
|
||||
section: 'Execution'
|
||||
},
|
||||
runplan_schedule_interval_minutes: {
|
||||
label: 'RunPlan Schedule Interval',
|
||||
description: 'Minutes between API worker scans for due automated RunPlan schedules. Set 0 to disable schedule dispatch.',
|
||||
placeholder: '1',
|
||||
section: 'Execution'
|
||||
},
|
||||
vcenter_enabled: {
|
||||
label: 'Legacy vCenter Default',
|
||||
description: 'Enable the legacy single-vCenter fallback. Saved VMware connections should use Config / VMware with Credential Vault authentication.',
|
||||
@@ -1216,7 +1250,11 @@ const scripts = ref([]);
|
||||
const assetFolders = ref([]);
|
||||
const assets = ref([]);
|
||||
const runplans = ref([]);
|
||||
const runPlanSchedules = ref([]);
|
||||
const runPlanScheduleHistory = ref([]);
|
||||
const jobs = ref([]);
|
||||
const jobOutputs = ref([]);
|
||||
const schedulingLoading = ref(false);
|
||||
const systemJobs = ref([]);
|
||||
const systemJobActions = ref([]);
|
||||
const systemJobsLoading = ref(false);
|
||||
@@ -1237,6 +1275,8 @@ const installerAnalysis = ref(null);
|
||||
const psadtValidationResult = ref(null);
|
||||
const psadtMigrationPlan = ref(null);
|
||||
const selectedJob = ref(null);
|
||||
const selectedJobOutput = ref(null);
|
||||
const jobOutputLoading = ref(false);
|
||||
const systemLogs = ref([]);
|
||||
const logTab = ref('job');
|
||||
const logFilters = reactive({ search: '', stream: '', page: 1, pageSize: 14 });
|
||||
@@ -1307,16 +1347,53 @@ const scriptLayout = reactive(loadScriptLayout());
|
||||
|
||||
const nav = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Gauge },
|
||||
{ id: 'scripts', label: 'Scripts', icon: BookOpenText },
|
||||
{ id: 'assets', label: 'Assets', icon: PackageOpen },
|
||||
{ id: 'psadt', label: 'PSADT', icon: Sparkles },
|
||||
{ id: 'hosts', label: 'Hosts', icon: Server },
|
||||
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound },
|
||||
{ id: 'runplans', label: 'RunPlans', icon: Rocket },
|
||||
{ id: 'logs', label: 'Logs', icon: Activity },
|
||||
{ id: 'system-jobs', label: 'System Jobs', icon: Database },
|
||||
{ id: 'users', label: 'Users & Groups', icon: UsersRound },
|
||||
{ id: 'admin', label: 'Config', icon: Settings }
|
||||
{
|
||||
id: 'nav-scripts',
|
||||
label: 'Scripts',
|
||||
icon: BookOpenText,
|
||||
children: [
|
||||
{ id: 'scripts', label: 'Script Editor', icon: FileCode2 },
|
||||
{ id: 'psadt', label: 'PSADT Bench', icon: Sparkles }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'nav-scheduling',
|
||||
label: 'Scheduling',
|
||||
icon: CalendarClock,
|
||||
children: [
|
||||
{ id: 'runplans', label: 'RunPlans', icon: Rocket },
|
||||
{ id: 'job-output', label: 'Job Output', icon: FileText },
|
||||
{ id: 'scheduling', label: 'Schedules', icon: CalendarClock },
|
||||
{ id: 'logs', label: 'Logs', icon: Activity }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'nav-shared-libraries',
|
||||
label: 'Shared Libraries',
|
||||
icon: PackageOpen,
|
||||
children: [
|
||||
{ id: 'assets', label: 'Asset Library', icon: PackageOpen },
|
||||
{ id: 'credentials', label: 'Credential Vault', icon: KeyRound }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'nav-resources',
|
||||
label: 'Resources',
|
||||
icon: Server,
|
||||
children: [
|
||||
{ id: 'hosts', label: 'Hosts', icon: Server }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'nav-administration',
|
||||
label: 'Administration',
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: 'users', label: 'Users & Groups', icon: UsersRound },
|
||||
{ id: 'admin', label: 'Configuration', icon: Settings },
|
||||
{ id: 'system-jobs', label: 'System Jobs', icon: Database }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const availableThemes = [
|
||||
@@ -1369,8 +1446,9 @@ const runPlanColumns = [
|
||||
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' }
|
||||
];
|
||||
|
||||
const activeTitle = computed(() => (view.value === 'profile' ? 'My Profile' : nav.find((item) => item.id === view.value)?.label || 'Dashboard'));
|
||||
const routedViewIds = computed(() => new Set([...nav.map((item) => item.id), 'profile']));
|
||||
const navLeaves = computed(() => nav.flatMap((item) => item.children?.length ? item.children : [item]));
|
||||
const activeTitle = computed(() => (view.value === 'profile' ? 'My Profile' : navLeaves.value.find((item) => item.id === view.value)?.label || 'Dashboard'));
|
||||
const routedViewIds = computed(() => new Set([...navLeaves.value.map((item) => item.id), 'profile']));
|
||||
const isUnknownView = computed(() => token.value && !routedViewIds.value.has(view.value));
|
||||
const activeTheme = computed(() => user.value?.theme || 'dashtreme');
|
||||
const isExternalAuth = computed(() => Boolean(user.value?.authProvider && user.value.authProvider !== 'local'));
|
||||
@@ -1718,7 +1796,7 @@ async function refreshAll() {
|
||||
return;
|
||||
}
|
||||
const refreshFailures = [];
|
||||
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, jobRows] = await Promise.all([
|
||||
const [meResult, boot, userRows, groupRows, credentialRows, hostRows, hostGroupRows, folderRows, scriptRows, assetFolderRows, assetRows, variableRows, psadtCatalogRows, psadtProfileRows, psadtIntuneRows, graphRows, vcenterRows, runplanRows, schedulePayload, jobRows, jobOutputRows] = await Promise.all([
|
||||
safeRefresh('session', api.get('/api/auth/me'), () => ({ user: user.value }), refreshFailures),
|
||||
safeRefresh('bootstrap', api.get('/api/bootstrap'), () => ({ summary: summary.value, settings: settings.value }), refreshFailures),
|
||||
safeRefresh('users', api.get('/api/users'), () => users.value, refreshFailures, true),
|
||||
@@ -1737,13 +1815,14 @@ async function refreshAll() {
|
||||
safeRefresh('Graph connections', api.get('/api/graph/connections'), () => graphConnections.value, refreshFailures),
|
||||
safeRefresh('vCenter connections', api.get('/api/hosts/import/vcenter/connections'), () => vcenterConnections.value, refreshFailures),
|
||||
safeRefresh('RunPlans', api.get('/api/runplans'), () => runplans.value, refreshFailures),
|
||||
safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures)
|
||||
safeRefresh('schedules', api.get('/api/schedules'), () => ({ schedules: runPlanSchedules.value, history: runPlanScheduleHistory.value }), refreshFailures),
|
||||
safeRefresh('jobs', api.get('/api/jobs'), () => jobs.value, refreshFailures),
|
||||
safeRefresh('job outputs', api.get('/api/job-output'), () => jobOutputs.value, refreshFailures)
|
||||
]);
|
||||
syncUser(meResult.user);
|
||||
summary.value = boot.summary;
|
||||
settings.value = boot.settings;
|
||||
Object.keys(settingsDraft).forEach((key) => delete settingsDraft[key]);
|
||||
Object.entries(settings.value).forEach(([key, row]) => { settingsDraft[key] = { ...row }; });
|
||||
syncSettingsDraft();
|
||||
users.value = userRows;
|
||||
groups.value = groupRows;
|
||||
credentials.value = credentialRows;
|
||||
@@ -1760,7 +1839,15 @@ async function refreshAll() {
|
||||
graphConnections.value = graphRows;
|
||||
vcenterConnections.value = vcenterRows;
|
||||
runplans.value = runplanRows;
|
||||
runPlanSchedules.value = schedulePayload.schedules || [];
|
||||
runPlanScheduleHistory.value = schedulePayload.history || [];
|
||||
jobs.value = jobRows;
|
||||
jobOutputs.value = jobOutputRows;
|
||||
if (selectedJobOutput.value) {
|
||||
const refreshedOutput = jobOutputRows.find((output) => output.id === selectedJobOutput.value.id);
|
||||
selectedJobOutput.value = refreshedOutput || null;
|
||||
if (!refreshedOutput) clearSelectedJobOutput();
|
||||
}
|
||||
if (user.value?.role === 'admin') await refreshSystemJobs(true);
|
||||
else {
|
||||
systemJobs.value = [];
|
||||
@@ -2714,6 +2801,76 @@ async function executeRunPlan(id) {
|
||||
notify('RunPlan queued');
|
||||
}
|
||||
|
||||
async function refreshSchedules() {
|
||||
schedulingLoading.value = true;
|
||||
try {
|
||||
const payload = await api.get('/api/schedules');
|
||||
runPlanSchedules.value = payload.schedules || [];
|
||||
runPlanScheduleHistory.value = payload.history || [];
|
||||
if (user.value?.role === 'admin') await refreshSystemJobs(true);
|
||||
notify('Schedules refreshed');
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to refresh schedules', 'error');
|
||||
} finally {
|
||||
schedulingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule(payload) {
|
||||
schedulingLoading.value = true;
|
||||
try {
|
||||
if (payload.id) await api.put(`/api/schedules/${encodeURIComponent(payload.id)}`, payload);
|
||||
else await api.post('/api/schedules', payload);
|
||||
await refreshSchedules();
|
||||
notify('Schedule saved');
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to save schedule', 'error');
|
||||
} finally {
|
||||
schedulingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSchedule(id) {
|
||||
schedulingLoading.value = true;
|
||||
try {
|
||||
await api.delete(`/api/schedules/${encodeURIComponent(id)}`);
|
||||
await refreshSchedules();
|
||||
notify('Schedule deleted');
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to delete schedule', 'error');
|
||||
} finally {
|
||||
schedulingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule({ id, enabled }) {
|
||||
schedulingLoading.value = true;
|
||||
try {
|
||||
await api.post(`/api/schedules/${encodeURIComponent(id)}/toggle`, { enabled });
|
||||
await refreshSchedules();
|
||||
notify(enabled ? 'Schedule enabled' : 'Schedule paused');
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to update schedule', 'error');
|
||||
} finally {
|
||||
schedulingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runScheduleNow(id) {
|
||||
schedulingLoading.value = true;
|
||||
try {
|
||||
const job = await api.post(`/api/schedules/${encodeURIComponent(id)}/run-now`, {});
|
||||
await refreshAll();
|
||||
await openJob(job.id);
|
||||
view.value = 'logs';
|
||||
notify('Scheduled RunPlan queued');
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to run schedule', 'error');
|
||||
} finally {
|
||||
schedulingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLinuxCompatibility(path, payload = null) {
|
||||
const result = payload ? await api.post(path, payload) : await api.get(path);
|
||||
const warnings = (result.findings || []).filter((finding) => finding.level === 'warning');
|
||||
@@ -2735,6 +2892,143 @@ async function openJob(id) {
|
||||
logFilters.page = 1;
|
||||
}
|
||||
|
||||
function clearSelectedJobOutput() {
|
||||
selectedJobOutput.value = null;
|
||||
}
|
||||
|
||||
async function fetchAuthedBlob(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
});
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function refreshJobOutputs() {
|
||||
jobOutputs.value = await api.get('/api/job-output');
|
||||
notify('Job outputs refreshed');
|
||||
}
|
||||
|
||||
async function generateJobOutputs(jobId) {
|
||||
if (!jobId) return;
|
||||
await api.post(`/api/job-output/jobs/${encodeURIComponent(jobId)}/generate`, {});
|
||||
jobOutputs.value = await api.get('/api/job-output');
|
||||
const firstOutput = jobOutputs.value.find((output) => output.jobId === jobId);
|
||||
if (firstOutput) selectedJobOutput.value = firstOutput;
|
||||
notify('Job output artifacts generated');
|
||||
}
|
||||
|
||||
function escapePreviewHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function previewDocument(title, body) {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapePreviewHtml(title)}</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
color: #e8f1ff;
|
||||
background: #070b18;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
h1 { margin: 0 0 6px; font-size: 22px; }
|
||||
.meta { margin-bottom: 22px; color: #8ba3c7; font-size: 13px; }
|
||||
.panel {
|
||||
border: 1px solid rgba(139, 224, 255, .18);
|
||||
border-radius: 16px;
|
||||
background: rgba(12, 20, 39, .86);
|
||||
overflow: auto;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, .32);
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
color: #b8ffcb;
|
||||
font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 10px 12px; border-bottom: 1px solid rgba(255, 255, 255, .08); text-align: left; vertical-align: top; }
|
||||
th { position: sticky; top: 0; color: #91e8ff; background: #0b1325; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
td { color: #d7e4f8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapePreviewHtml(title)}</h1>
|
||||
<div class="meta">Opened from POSHManager Job Output</div>
|
||||
<div class="panel">${body}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function tablePreviewHtml(table = {}) {
|
||||
const columns = table.columns || [];
|
||||
const rows = table.rows || [];
|
||||
const head = `<thead><tr>${columns.map((column) => `<th>${escapePreviewHtml(column.label || column.key)}</th>`).join('')}</tr></thead>`;
|
||||
const body = `<tbody>${rows.map((row) => `<tr>${columns.map((column) => `<td>${escapePreviewHtml(row[column.key] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>`;
|
||||
return `<table>${head}${body}</table>`;
|
||||
}
|
||||
|
||||
function openPreviewBlob(blob, label) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const popup = window.open(url, '_blank');
|
||||
if (!popup) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw new Error('Browser blocked the preview window. Allow popups for POSHManager and try again.');
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
try {
|
||||
popup.document.title = label;
|
||||
} catch {
|
||||
// Blob/PDF viewers may not expose a writable document during navigation.
|
||||
}
|
||||
}
|
||||
|
||||
async function viewJobOutput(output) {
|
||||
selectedJobOutput.value = output;
|
||||
jobOutputLoading.value = true;
|
||||
try {
|
||||
if (output.kind === 'stdout-pdf') {
|
||||
const blob = await fetchAuthedBlob(output.viewUrl);
|
||||
openPreviewBlob(blob, output.name || 'Job STDOUT PDF');
|
||||
} else {
|
||||
const payload = await api.get(`/api/job-output/${encodeURIComponent(output.id)}/content`);
|
||||
const title = output.name || 'Job Output';
|
||||
const body = output.kind === 'datatable'
|
||||
? tablePreviewHtml(payload.table)
|
||||
: `<pre>${escapePreviewHtml(payload.text || '')}</pre>`;
|
||||
openPreviewBlob(new Blob([previewDocument(title, body)], { type: 'text/html' }), title);
|
||||
}
|
||||
} catch (err) {
|
||||
notify(err.message || 'Unable to load job output', 'error');
|
||||
} finally {
|
||||
jobOutputLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadJobOutput(output) {
|
||||
const blob = await fetchAuthedBlob(output.downloadUrl);
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = output.name || 'job-output';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
async function loadSystemLogs() {
|
||||
logTab.value = 'system';
|
||||
logFilters.page = 1;
|
||||
@@ -2786,8 +3080,11 @@ async function cancelSystemJob({ id, reason }) {
|
||||
|
||||
async function saveSettings() {
|
||||
const payload = {};
|
||||
Object.entries(settingsDraft).forEach(([key, row]) => { payload[key] = row.value; });
|
||||
Object.entries(settingsDraft).forEach(([key, row]) => {
|
||||
if (!isLockedSetting(key, row)) payload[key] = row.value;
|
||||
});
|
||||
settings.value = await api.put('/api/settings', payload);
|
||||
syncSettingsDraft();
|
||||
notify('Settings saved');
|
||||
}
|
||||
|
||||
@@ -3135,6 +3432,15 @@ function settingSourceLabel(source) {
|
||||
return source === 'env' ? 'ENV' : 'DB';
|
||||
}
|
||||
|
||||
function isLockedSetting(key, row = settingsDraft[key]) {
|
||||
return row?.source === 'env' && key !== 'powershell_bin';
|
||||
}
|
||||
|
||||
function syncSettingsDraft() {
|
||||
Object.keys(settingsDraft).forEach((key) => delete settingsDraft[key]);
|
||||
Object.entries(settings.value || {}).forEach(([key, row]) => { settingsDraft[key] = { ...row }; });
|
||||
}
|
||||
|
||||
function configSectionMeta(section) {
|
||||
const meta = {
|
||||
Deployment: { icon: Server, description: 'Reverse proxy, trusted origins, and public URLs.' },
|
||||
|
||||
@@ -8,16 +8,46 @@
|
||||
</button>
|
||||
</div>
|
||||
<nav class="metismenu">
|
||||
<div class="menu-label">Operations</div>
|
||||
<button v-for="item in primaryNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
<div class="menu-label">Administration</div>
|
||||
<button v-for="item in adminNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
<template v-for="item in nav" :key="item.id">
|
||||
<button
|
||||
v-if="!item.children?.length"
|
||||
:class="['nav-root-button', { active: view === item.id }]"
|
||||
:title="collapsed ? item.label : undefined"
|
||||
type="button"
|
||||
@click="$emit('update:view', item.id)"
|
||||
>
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
|
||||
<div v-else :class="['nav-group', { open: isGroupOpen(item.id), active: groupContainsView(item, view) }]">
|
||||
<button
|
||||
class="nav-group-toggle"
|
||||
:aria-expanded="isGroupOpen(item.id)"
|
||||
:title="collapsed ? item.label : undefined"
|
||||
type="button"
|
||||
@click="toggleGroup(item.id)"
|
||||
>
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
<ChevronDown class="group-chevron" :size="15" />
|
||||
</button>
|
||||
<div v-if="isGroupOpen(item.id)" class="nav-submenu">
|
||||
<button
|
||||
v-for="child in item.children"
|
||||
:key="child.id"
|
||||
:class="['nav-child-button', { active: view === child.id }]"
|
||||
:title="collapsed ? child.label : undefined"
|
||||
type="button"
|
||||
@click="$emit('update:view', child.id)"
|
||||
>
|
||||
<span class="child-rail" aria-hidden="true"></span>
|
||||
<span class="parent-icon child-icon"><component :is="child.icon" :size="16" /></span>
|
||||
<span class="menu-title">{{ child.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="user-orb">{{ initials }}</span>
|
||||
@@ -30,8 +60,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
nav: { type: Array, required: true },
|
||||
@@ -42,8 +72,34 @@ const props = defineProps({
|
||||
|
||||
defineEmits(['update:view', 'toggle-collapse']);
|
||||
|
||||
const accountNav = ['profile', 'users', 'admin'];
|
||||
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
|
||||
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
|
||||
const collapsedGroups = ref(loadCollapsedGroups());
|
||||
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
|
||||
|
||||
watch(collapsedGroups, (value) => {
|
||||
localStorage.setItem('poshmanager.navGroupsCollapsed', JSON.stringify([...value]));
|
||||
}, { deep: true });
|
||||
|
||||
function loadCollapsedGroups() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem('poshmanager.navGroupsCollapsed') || '[]');
|
||||
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function isGroupOpen(groupId) {
|
||||
return !collapsedGroups.value.has(groupId);
|
||||
}
|
||||
|
||||
function toggleGroup(groupId) {
|
||||
const next = new Set(collapsedGroups.value);
|
||||
if (next.has(groupId)) next.delete(groupId);
|
||||
else next.add(groupId);
|
||||
collapsedGroups.value = next;
|
||||
}
|
||||
|
||||
function groupContainsView(group, view) {
|
||||
return group.children?.some((child) => child.id === view);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
type="button"
|
||||
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
|
||||
:aria-pressed="normalizedBooleanSetting(row.value)"
|
||||
:disabled="isLockedSetting(key, row)"
|
||||
@click="$emit('toggle-boolean', row)"
|
||||
>
|
||||
<i></i>
|
||||
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
|
||||
</button>
|
||||
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
|
||||
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" />
|
||||
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)"></textarea>
|
||||
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)" />
|
||||
</div>
|
||||
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
|
||||
</div>
|
||||
@@ -51,7 +52,8 @@ defineProps({
|
||||
isSecretSetting: { type: Function, default: () => false },
|
||||
sourceLabel: { type: Function, required: true },
|
||||
isBooleanSetting: { type: Function, required: true },
|
||||
normalizedBooleanSetting: { type: Function, required: true }
|
||||
normalizedBooleanSetting: { type: Function, required: true },
|
||||
isLockedSetting: { type: Function, default: () => false }
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'toggle-boolean']);
|
||||
|
||||
@@ -11,35 +11,61 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="intune-config-grid">
|
||||
<section class="publisher-card">
|
||||
<strong>Inventory sources</strong>
|
||||
<div class="vmware-config-grid">
|
||||
<section class="publisher-card vmware-summary-card">
|
||||
<div class="vmware-card-heading">
|
||||
<span class="vmware-card-icon"><i class="mdi mdi-view-grid-outline" aria-hidden="true"></i></span>
|
||||
<div>
|
||||
<strong>Inventory sources</strong>
|
||||
<small>Credential-backed VMware inventory and power-state checks.</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>vCenter and standalone ESXi entries use Credential Vault entries for VMware API authentication, inventory import, and power-state checks.</p>
|
||||
<div class="config-stat-row">
|
||||
<span><b>{{ connections.length }}</b><small>systems</small></span>
|
||||
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
|
||||
<div class="vmware-stat-grid" aria-label="VMware connection summary">
|
||||
<span><b>{{ connections.length }}</b><small>Systems</small></span>
|
||||
<span><b>{{ enabledCount }}</b><small>Enabled</small></span>
|
||||
<span><b>{{ vcenterCount }}</b><small>vCenter</small></span>
|
||||
<span><b>{{ standaloneCount }}</b><small>hosts</small></span>
|
||||
<span><b>{{ testedCount }}</b><small>tested</small></span>
|
||||
<span><b>{{ standaloneCount }}</b><small>ESXi Hosts</small></span>
|
||||
<span><b>{{ testedCount }}</b><small>Tested</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="publisher-card graph-config-list">
|
||||
<strong>VMware systems</strong>
|
||||
<div v-for="connection in connections" :key="connection.id" class="check-row">
|
||||
<b>{{ connection.name }}</b>
|
||||
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }}</small>
|
||||
<span>
|
||||
<button class="ghost-button compact" type="button" @click="openModal(connection)">
|
||||
<section class="publisher-card vmware-system-list">
|
||||
<div class="vmware-list-heading">
|
||||
<strong>VMware systems</strong>
|
||||
<small>{{ connections.length ? `${connections.length} configured` : 'No systems configured' }}</small>
|
||||
</div>
|
||||
<div v-for="connection in connections" :key="connection.id" class="vmware-system-row">
|
||||
<div class="vmware-system-lead" aria-hidden="true">
|
||||
<i :class="connection.targetType === 'host' ? 'mdi mdi-server' : 'mdi mdi-server-network'"></i>
|
||||
</div>
|
||||
|
||||
<div class="vmware-system-main">
|
||||
<div class="vmware-system-title">
|
||||
<b>{{ connection.name }}</b>
|
||||
<span :class="['status-pill', connection.enabled ? 'success' : 'skipped']">{{ connection.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||
<span :class="['status-pill', connectionStatusTone(connection)]">{{ connection.lastTestStatus || 'Untested' }}</span>
|
||||
</div>
|
||||
<div class="vmware-system-meta">
|
||||
<span><i class="mdi mdi-source-branch" aria-hidden="true"></i>{{ connectionTypeLabel(connection) }}</span>
|
||||
<span><i class="mdi mdi-link-variant" aria-hidden="true"></i>{{ connection.baseUrl }}</span>
|
||||
<span><i class="mdi mdi-key-variant" aria-hidden="true"></i>{{ credentialLabel(connection) }}</span>
|
||||
<span v-if="connection.lastTestAt"><i class="mdi mdi-clock-outline" aria-hidden="true"></i>{{ formatDate(connection.lastTestAt) }}</span>
|
||||
</div>
|
||||
<small v-if="connection.lastTestMessage" class="vmware-test-message">{{ connection.lastTestMessage }}</small>
|
||||
</div>
|
||||
|
||||
<div class="vmware-system-actions">
|
||||
<button class="ghost-button icon-text compact" type="button" :aria-label="`Edit ${connection.name}`" @click="openModal(connection)">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">
|
||||
<button class="ghost-button icon-text compact" type="button" :aria-label="`Test ${connection.name}`" @click="$emit('test', connection.id)">
|
||||
<i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
|
||||
</button>
|
||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">
|
||||
<button class="ghost-button icon-text compact danger-text" type="button" :aria-label="`Delete ${connection.name}`" @click="$emit('delete', connection.id)">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>Delete
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!connections.length" class="widget-empty">No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.</p>
|
||||
</section>
|
||||
@@ -165,4 +191,272 @@ function save() {
|
||||
function connectionTypeLabel(connection) {
|
||||
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory';
|
||||
}
|
||||
|
||||
function credentialLabel(connection) {
|
||||
return connection.credentialName || connection.username || 'No vault credential';
|
||||
}
|
||||
|
||||
function connectionStatusTone(connection) {
|
||||
const status = String(connection.lastTestStatus || '').toLowerCase();
|
||||
if (!status) return 'neutral';
|
||||
if (status === 'success' || status === 'ok' || status === 'passed') return 'success';
|
||||
if (status === 'failed' || status === 'error') return 'failed';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vmware-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, .72fr) minmax(0, 1.28fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.vmware-summary-card,
|
||||
.vmware-system-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vmware-card-heading,
|
||||
.vmware-list-heading,
|
||||
.vmware-system-title,
|
||||
.vmware-system-meta,
|
||||
.vmware-system-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vmware-card-heading {
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vmware-card-heading strong,
|
||||
.vmware-list-heading strong {
|
||||
display: block;
|
||||
color: rgba(247, 250, 255, .96);
|
||||
}
|
||||
|
||||
.vmware-card-heading small,
|
||||
.vmware-list-heading small {
|
||||
color: rgba(213, 225, 246, .62);
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.vmware-card-icon,
|
||||
.vmware-system-lead {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(88, 221, 255, .2);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(88, 221, 255, .16), rgba(154, 107, 255, .1)),
|
||||
rgba(10, 16, 34, .72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 14px 30px rgba(0, 0, 0, .18);
|
||||
color: #8be0ff;
|
||||
}
|
||||
|
||||
.vmware-card-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.vmware-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vmware-stat-grid span {
|
||||
min-width: 0;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, .095);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(88, 221, 255, .08), rgba(154, 107, 255, .055)),
|
||||
rgba(255, 255, 255, .045);
|
||||
}
|
||||
|
||||
.vmware-stat-grid b,
|
||||
.vmware-stat-grid small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vmware-stat-grid b {
|
||||
color: #f7faff;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vmware-stat-grid small {
|
||||
margin-top: 4px;
|
||||
color: rgba(213, 225, 246, .58);
|
||||
font-size: .68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vmware-list-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vmware-system-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vmware-system-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, .095);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(11, 21, 43, .88), rgba(26, 24, 58, .74)),
|
||||
rgba(4, 9, 22, .42);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
|
||||
}
|
||||
|
||||
.vmware-system-lead {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.vmware-system-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vmware-system-title {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vmware-system-title b {
|
||||
min-width: 0;
|
||||
max-width: min(34rem, 100%);
|
||||
overflow: hidden;
|
||||
color: #f7faff;
|
||||
font-size: .96rem;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vmware-system-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin-top: 8px;
|
||||
color: rgba(213, 225, 246, .68);
|
||||
font-size: .76rem;
|
||||
}
|
||||
|
||||
.vmware-system-meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vmware-system-meta i {
|
||||
flex: 0 0 auto;
|
||||
color: #8be0ff;
|
||||
opacity: .82;
|
||||
}
|
||||
|
||||
.vmware-system-meta span:nth-child(2) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vmware-test-message {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 196, 204, .72);
|
||||
font-size: .73rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vmware-system-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vmware-system-actions .ghost-button {
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.vmware-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vmware-stat-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.intune-config-panel :deep(.section-title) {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.intune-config-panel :deep(.section-title .primary-action) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vmware-stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.vmware-system-row {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.vmware-system-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.vmware-system-actions .ghost-button {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.vmware-stat-grid,
|
||||
.vmware-system-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -668,6 +668,86 @@ textarea:focus {
|
||||
color: rgba(255, 255, 255, .6);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.nav-group-toggle .group-chevron {
|
||||
margin-left: auto;
|
||||
color: rgba(230, 238, 255, .58);
|
||||
transition: transform .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.nav-group.open .group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-group.active > .nav-group-toggle {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(116, 236, 231, .12), rgba(255, 39, 149, .13));
|
||||
border-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.nav-group.active > .nav-group-toggle .parent-icon {
|
||||
background: linear-gradient(135deg, rgba(116, 236, 231, .35), rgba(255, 91, 152, .28));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-group.active > .nav-group-toggle .group-chevron {
|
||||
color: #8fe8ff;
|
||||
}
|
||||
|
||||
.nav-submenu {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 1px 0 6px 18px;
|
||||
padding-left: 11px;
|
||||
border-left: 1px solid rgba(143, 232, 255, .14);
|
||||
}
|
||||
|
||||
.metismenu .nav-child-button {
|
||||
min-height: 36px;
|
||||
padding-left: 8px;
|
||||
border-radius: 11px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metismenu .nav-child-button .child-icon {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.child-rail {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 232, 255, .34);
|
||||
}
|
||||
|
||||
.nav-child-button.active .child-rail {
|
||||
background: #8fe8ff;
|
||||
box-shadow: 0 0 12px rgba(143, 232, 255, .7);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-group-toggle .group-chevron,
|
||||
.sidebar.collapsed .child-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-submenu {
|
||||
margin: 2px 0 8px;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .metismenu .nav-child-button {
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.user-orb {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -3644,6 +3724,13 @@ textarea:focus {
|
||||
box-shadow: 0 8px 18px rgba(100, 231, 189, .25), inset 0 1px 0 rgba(255, 255, 255, .5);
|
||||
}
|
||||
|
||||
.settings-input:disabled,
|
||||
.switch-control:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .62;
|
||||
filter: saturate(.72);
|
||||
}
|
||||
|
||||
.setting-source {
|
||||
justify-self: end;
|
||||
min-width: 54px;
|
||||
@@ -8032,6 +8119,128 @@ input:read-only {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Grouped left navigation. Kept late in the file so theme/mobile overrides do
|
||||
not flatten the hierarchy back into the old single-level menu. */
|
||||
.metismenu .nav-root-button,
|
||||
.metismenu .nav-group-toggle,
|
||||
.metismenu .nav-child-button {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-group-toggle .group-chevron {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
color: rgba(230, 238, 255, .58);
|
||||
transition: transform .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.nav-group.open .group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-group.active > .nav-group-toggle {
|
||||
color: #fff7ff;
|
||||
background: linear-gradient(90deg, rgba(116, 236, 231, .13), rgba(255, 39, 149, .16));
|
||||
border-color: rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
.nav-submenu {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin: 1px 0 7px 18px;
|
||||
padding-left: 11px;
|
||||
border-left: 1px solid rgba(143, 232, 255, .14);
|
||||
max-width: calc(100% - 18px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.metismenu .nav-child-button {
|
||||
min-height: 36px;
|
||||
padding-left: 8px;
|
||||
border-radius: 11px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metismenu .nav-child-button .child-icon {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.child-rail {
|
||||
flex: 0 0 5px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(143, 232, 255, .34);
|
||||
}
|
||||
|
||||
.nav-child-button.active .child-rail {
|
||||
background: #8fe8ff;
|
||||
box-shadow: 0 0 12px rgba(143, 232, 255, .7);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-group-toggle .group-chevron,
|
||||
.sidebar.collapsed .child-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-submenu {
|
||||
margin: 2px 0 8px;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .metismenu .nav-child-button {
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.metismenu .nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.metismenu .nav-submenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.metismenu .nav-root-button,
|
||||
.metismenu .nav-group-toggle,
|
||||
.metismenu .nav-child-button,
|
||||
.sidebar.collapsed .metismenu .nav-child-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metismenu .nav-group-toggle .group-chevron,
|
||||
.metismenu .child-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-modal .host-picker input[type="checkbox"],
|
||||
.modal-form .host-picker input[type="checkbox"],
|
||||
.grid-form .host-picker input[type="checkbox"],
|
||||
|
||||
526
client/src/views/JobOutputView.vue
Normal file
526
client/src/views/JobOutputView.vue
Normal 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>
|
||||
1146
client/src/views/SchedulingView.vue
Normal file
1146
client/src/views/SchedulingView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user