This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<template>
<aside class="sidebar" :class="{ collapsed }">
<div class="sidebar-header">
<div class="brand-mark">PS</div>
<h4 class="logo-text">POSHManager</h4>
<button class="sidebar-collapse-button" type="button" :title="collapsed ? 'Expand navigation' : 'Collapse navigation'" @click="$emit('toggle-collapse')">
<component :is="collapsed ? PanelLeftOpen : PanelLeftClose" :size="17" />
</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>
</nav>
<div class="sidebar-user">
<span class="user-orb">{{ initials }}</span>
<div>
<strong>{{ user?.displayName || 'Operator' }}</strong>
<small>{{ user?.role || 'session' }}</small>
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
const props = defineProps({
nav: { type: Array, required: true },
user: { type: Object, default: null },
view: { type: String, required: true },
collapsed: Boolean
});
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 initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
</script>

View File

@@ -0,0 +1,71 @@
<template>
<header class="topbar">
<div class="topbar-title">
<span class="eyebrow">PowerShell Operations Platform</span>
<h2>{{ activeTitle }}</h2>
</div>
<div class="topbar-search">
<Search :size="17" />
<span>Search scripts, hosts, runplans</span>
</div>
<div class="topbar-actions">
<button class="ghost-button topbar-action-button" type="button" aria-label="Open help library" @click="$emit('open-help')">
<CircleHelp :size="17" />Help
</button>
<button class="ghost-button topbar-action-button" type="button" aria-label="Refresh current view" @click="$emit('refresh')">
<RefreshCcw :size="17" />Refresh
</button>
<div class="top-profile-menu">
<button class="profile-trigger topbar-profile-trigger" type="button" aria-label="Open profile menu" @click="profileOpen = !profileOpen">
<span class="avatar top-avatar">
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
<template v-else>{{ initials }}</template>
</span>
</button>
<div v-if="profileOpen" class="profile-menu-card glass-panel">
<div class="profile-menu-head">
<span class="avatar large">
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
<template v-else>{{ initials }}</template>
</span>
<div>
<strong>{{ user?.displayName || 'Operator' }}</strong>
<small>{{ user?.email }}</small>
</div>
</div>
<button class="profile-menu-action" type="button" @click="openProfile">
<UserCircle :size="16" /> My profile
</button>
<button class="profile-menu-action danger" type="button" @click="logout">
<LogOut :size="16" /> Sign out
</button>
</div>
</div>
</div>
</header>
</template>
<script setup>
import { computed, ref } from 'vue';
import { CircleHelp, LogOut, RefreshCcw, Search, UserCircle } from '@lucide/vue';
const props = defineProps({
activeTitle: { type: String, required: true },
user: { type: Object, default: null }
});
const emit = defineEmits(['refresh', 'logout', 'open-profile', 'open-help']);
const profileOpen = ref(false);
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
function openProfile() {
profileOpen.value = false;
emit('open-profile');
}
function logout() {
profileOpen.value = false;
emit('logout');
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<article :class="['metric-card', `metric-card-${index % 4}`]">
<span class="metric-icon"><component :is="card.icon" :size="24" /></span>
<div>
<strong>{{ card.value }}</strong>
<span>{{ card.label }}</span>
</div>
</article>
</template>
<script setup>
defineProps({
card: { type: Object, required: true },
index: { type: Number, default: 0 }
});
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="particle-field" aria-hidden="true">
<span v-for="n in 54" :key="n" :style="particleStyle(n)"></span>
</div>
</template>
<script setup>
function particleStyle(index) {
const left = (index * 37) % 100;
const top = (index * 53) % 100;
const delay = (index % 13) * -0.65;
const scale = 0.65 + ((index % 5) * 0.16);
return { left: `${left}%`, top: `${top}%`, animationDelay: `${delay}s`, transform: `scale(${scale})` };
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div class="rel-graph">
<div class="rel-col rel-supersedes">
<span class="rel-col-label">Supersedes</span>
<div v-for="rel in supersedence" :key="`s-${rel.targetAppId}`" class="rel-node rel-old">
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
<small>{{ rel.mode === 'replace' ? 'replace' : 'update' }}</small>
</div>
<p v-if="!supersedence.length" class="rel-empty">none</p>
</div>
<div class="rel-arrow"></div>
<div class="rel-center">
<div class="rel-node rel-current">
<strong>{{ deployment.name }}</strong>
<small>{{ deployment.graphAppId ? 'published' : 'unpublished' }}</small>
</div>
</div>
<div class="rel-arrow"></div>
<div class="rel-col rel-depends">
<span class="rel-col-label">Depends on</span>
<div v-for="rel in dependencies" :key="`d-${rel.targetAppId}`" class="rel-node rel-dep">
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
<small>{{ rel.mode === 'autoInstall' ? 'auto-install' : 'detect' }}</small>
</div>
<p v-if="!dependencies.length" class="rel-empty">none</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
deployment: { type: Object, required: true }
});
const relationships = computed(() => props.deployment.relationships || []);
const supersedence = computed(() => relationships.value.filter((r) => r.kind === 'supersedence'));
const dependencies = computed(() => relationships.value.filter((r) => r.kind === 'dependency'));
</script>
<style scoped>
.rel-graph {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.6rem 0;
}
.rel-col,
.rel-center {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 120px;
}
.rel-col-label {
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #8a93a6);
}
.rel-node {
display: flex;
flex-direction: column;
padding: 0.45rem 0.6rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.rel-node strong { font-size: 0.82rem; }
.rel-node small { font-size: 0.68rem; color: var(--text-muted, #8a93a6); }
.rel-current { border-color: rgba(120, 170, 255, 0.5); background: rgba(120, 170, 255, 0.12); }
.rel-old { border-color: rgba(255, 196, 120, 0.4); }
.rel-dep { border-color: rgba(140, 220, 170, 0.4); }
.rel-arrow { color: var(--text-muted, #8a93a6); font-size: 1.1rem; }
.rel-empty { font-size: 0.72rem; color: var(--text-muted, #8a93a6); margin: 0; }
</style>

View File

@@ -0,0 +1,251 @@
<template>
<aside class="asset-preview glass-panel">
<header>
<div>
<span class="eyebrow">ASSET DETAILS</span>
<h3>{{ asset?.name || 'No asset selected' }}</h3>
</div>
<button v-if="asset" class="ghost-button compact" type="button" @click="$emit('refresh')">
<RotateCcw :size="14" />Refresh
</button>
</header>
<div v-if="asset" class="asset-detail-stack">
<div class="asset-kind-card">
<component :is="kindIcon" :size="26" />
<div>
<strong>{{ asset.kind }}</strong>
<small>{{ formatBytes(asset.sizeBytes) }} - {{ asset.mimeType }}</small>
</div>
</div>
<div class="asset-preview-frame">
<img v-if="previewUrl && asset.mimeType?.startsWith('image/')" :src="previewUrl" :alt="asset.name" />
<iframe v-else-if="previewUrl && asset.mimeType === 'application/pdf'" :src="previewUrl" title="Asset preview" />
<pre v-else-if="textPreview">{{ textPreview }}</pre>
<div v-else class="asset-preview-empty">
<FileText :size="24" />
<span>Select View to load a preview for images, PDFs, and text-based assets.</span>
</div>
</div>
<dl class="asset-meta-grid">
<div><dt>Reference</dt><dd><code>{{ asset.referenceToken }}</code></dd></div>
<div><dt>Package path</dt><dd>{{ asset.packagePath || asset.originalName }}</dd></div>
<div><dt>Folder</dt><dd>{{ asset.folderName || 'Unfiled' }}</dd></div>
<div><dt>Checksum</dt><dd><code>{{ asset.checksum?.slice(0, 18) }}...</code></dd></div>
</dl>
<div class="asset-action-grid">
<button class="primary-action compact" type="button" @click="$emit('view', asset)">
<Eye :size="15" />View
</button>
<button class="ghost-button compact" type="button" @click="$emit('download', asset)">
<Download :size="15" />Download
</button>
<button class="ghost-button compact" type="button" @click="$emit('insert-reference', asset)">
<Link2 :size="15" />Insert token
</button>
<button class="ghost-button compact" type="button" @click="$emit('link', asset)">
<Network :size="15" />Link target
</button>
</div>
</div>
<div v-else class="asset-inspector-empty">
<div><Package :size="24" /></div>
<strong>No asset selected</strong>
<p>Pick an asset to preview content, copy its token, download it, or link it into a package workflow.</p>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { Archive, Download, Eye, FileCog, FileText, Image, Link2, Network, Package, RotateCcw, ScrollText } from '@lucide/vue';
const props = defineProps({
asset: { type: Object, default: null },
previewUrl: { type: String, default: '' },
textPreview: { type: String, default: '' }
});
defineEmits(['view', 'download', 'insert-reference', 'link', 'refresh']);
const kindIcon = computed(() => {
const map = {
archive: Archive,
config: FileCog,
document: FileText,
image: Image,
installer: Package,
script: ScrollText,
text: FileText
};
return map[props.asset?.kind] || FileText;
});
function formatBytes(bytes = 0) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
}
</script>
<style scoped>
.asset-preview {
min-width: 290px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 430px;
height: 100%;
padding: 16px;
border-radius: 18px;
background:
radial-gradient(circle at 0 0, rgba(125, 232, 255, .08), transparent 34%),
linear-gradient(145deg, rgba(20, 25, 58, .88), rgba(7, 17, 42, .82));
}
.asset-preview header,
.asset-kind-card,
.asset-action-grid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.asset-preview h3 {
margin: 2px 0 0;
font-size: 1.05rem;
line-height: 1.2;
}
.asset-inspector-empty {
flex: 1;
min-height: 300px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
padding: 22px;
border: 1px dashed rgba(125, 232, 255, .18);
border-radius: 16px;
color: rgba(214, 226, 244, .68);
background: rgba(3, 10, 26, .34);
text-align: center;
}
.asset-inspector-empty > div {
width: 54px;
height: 54px;
display: grid;
place-items: center;
border: 1px solid rgba(125, 232, 255, .2);
border-radius: 18px;
color: #79eaff;
background: rgba(95, 219, 255, .09);
}
.asset-inspector-empty strong {
color: #fff;
font: 780 17px var(--display-font);
}
.asset-inspector-empty p {
max-width: 260px;
margin: 0;
font-size: 11px;
line-height: 1.55;
}
.asset-detail-stack {
display: grid;
gap: 14px;
}
.asset-kind-card {
justify-content: flex-start;
padding: 12px;
border: 1px solid rgba(95, 219, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}
.asset-kind-card small {
display: block;
color: rgba(255, 255, 255, 0.54);
font-size: 0.75rem;
margin-top: 2px;
}
.asset-preview-frame {
min-height: 190px;
max-height: 320px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(4, 10, 26, 0.56);
}
.asset-preview-frame img,
.asset-preview-frame iframe {
display: block;
width: 100%;
min-height: 260px;
border: 0;
object-fit: contain;
}
.asset-preview-frame pre {
margin: 0;
padding: 14px;
color: #b9ffca;
font: 0.78rem/1.55 "JetBrains Mono", "Fira Code", Consolas, monospace;
white-space: pre-wrap;
}
.asset-preview-empty {
min-height: 190px;
display: grid;
place-items: center;
gap: 8px;
padding: 22px;
color: rgba(255, 255, 255, 0.54);
text-align: center;
}
.asset-meta-grid {
display: grid;
gap: 8px;
margin: 0;
}
.asset-meta-grid div {
padding: 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.045);
}
.asset-meta-grid dt {
color: rgba(125, 232, 255, 0.82);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.asset-meta-grid dd {
margin: 4px 0 0;
overflow-wrap: anywhere;
color: rgba(255, 255, 255, 0.8);
font-size: 0.78rem;
}
.asset-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div class="asset-tree">
<button v-if="!nested" :class="['asset-tree-root', { active: activeFolderId === null }]" type="button" @click="$emit('select', null)">
<PackageOpen :size="16" />
<span>All assets</span>
<small>{{ assetCount }}</small>
</button>
<div v-for="folder in childFolders" :key="folder.id" class="asset-tree-node">
<button :class="['asset-tree-folder', { active: activeFolderId === folder.id }]" type="button" @click="$emit('select', folder.id)">
<ChevronRight :size="13" :class="{ open: openIds.includes(folder.id) }" @click.stop="toggle(folder.id)" />
<Folder :size="15" />
<span>{{ folder.name }}</span>
<small>{{ folderAssetCount(folder.id) }}</small>
</button>
<div class="asset-tree-actions">
<button title="Add child folder" type="button" @click="$emit('create-child', folder)"><FolderPlus :size="13" /></button>
<button title="Edit folder" type="button" @click="$emit('edit', folder)"><Pencil :size="13" /></button>
<button title="Delete folder" type="button" @click="$emit('delete', folder)"><Trash2 :size="13" /></button>
</div>
<AssetTree
v-if="openIds.includes(folder.id)"
:folders="folders"
:assets="assets"
:parent-id="folder.id"
:active-folder-id="activeFolderId"
:open-ids="openIds"
nested
@select="$emit('select', $event)"
@toggle="$emit('toggle', $event)"
@create-child="$emit('create-child', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ChevronRight, Folder, FolderPlus, PackageOpen, Pencil, Trash2 } from '@lucide/vue';
defineOptions({ name: 'AssetTree' });
const props = defineProps({
folders: { type: Array, default: () => [] },
assets: { type: Array, default: () => [] },
parentId: { type: String, default: null },
activeFolderId: { type: String, default: null },
openIds: { type: Array, default: () => [] },
nested: { type: Boolean, default: false }
});
const emit = defineEmits(['select', 'toggle', 'create-child', 'edit', 'delete']);
const childFolders = computed(() => props.folders.filter((folder) => (folder.parentId || null) === props.parentId));
const assetCount = computed(() => props.assets.length);
function folderAssetCount(folderId) {
return props.assets.filter((asset) => asset.folderId === folderId).length;
}
function toggle(folderId) {
emit('toggle', folderId);
}
</script>
<style scoped>
.asset-tree {
display: grid;
gap: 6px;
}
.asset-tree .asset-tree {
margin-left: 18px;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.09);
}
.asset-tree-root,
.asset-tree-folder {
width: 100%;
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 10px;
color: rgba(255, 255, 255, 0.78);
background: transparent;
text-align: left;
}
.asset-tree-root {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.asset-tree-root.active,
.asset-tree-folder.active {
border-color: rgba(95, 219, 255, 0.34);
background: linear-gradient(135deg, rgba(58, 178, 255, 0.2), rgba(143, 93, 255, 0.18));
color: #f6fbff;
}
.asset-tree-folder span,
.asset-tree-root span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-tree-folder small,
.asset-tree-root small {
color: rgba(255, 255, 255, 0.45);
font-size: 0.7rem;
}
.asset-tree-node {
position: relative;
}
.asset-tree-actions {
display: flex;
gap: 3px;
justify-content: flex-end;
margin: -3px 4px 5px 30px;
opacity: 0;
transition: opacity 0.16s ease;
}
.asset-tree-node:hover > .asset-tree-actions,
.asset-tree-node:focus-within > .asset-tree-actions {
opacity: 1;
}
.asset-tree-actions button {
width: 25px;
height: 25px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.76);
background: rgba(255, 255, 255, 0.05);
}
.open {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<article
:class="['dashboard-widget glass-panel', `widget-${widget.id}`, { editing, collapsed: widget.collapsed, dragging }]"
:style="{ gridColumn: `span ${widget.w}`, gridRow: `span ${widget.collapsed ? 1 : widget.h}` }"
:draggable="editing"
@dragstart="$emit('drag-start', widget.id)"
@dragover.prevent
@drop="$emit('drop-on', widget.id)"
>
<div class="widget-toolbar">
<button class="widget-drag" :disabled="!editing" title="Drag widget"><GripVertical :size="15" /></button>
<div>
<span class="eyebrow">{{ widget.category }}</span>
<h3>{{ widget.title }}</h3>
</div>
<div class="widget-actions">
<button title="Collapse card" type="button" @click="$emit('toggle-collapse', widget.id)">
<component :is="widget.collapsed ? ChevronDown : ChevronUp" :size="13" />
</button>
<template v-if="editing">
<button title="Narrower" type="button" @click="$emit('resize', widget.id, 'w', -1)"><Minimize2 :size="13" /></button>
<button title="Wider" type="button" @click="$emit('resize', widget.id, 'w', 1)"><Maximize2 :size="13" /></button>
<button title="Shorter" type="button" @click="$emit('resize', widget.id, 'h', -1)">-</button>
<button title="Taller" type="button" @click="$emit('resize', widget.id, 'h', 1)">+</button>
<button title="Remove widget" type="button" @click="$emit('set-visible', widget.id, false)"><Trash2 :size="13" /></button>
</template>
</div>
</div>
<template v-if="!widget.collapsed">
<div v-if="widget.metric" class="stat-card dashboard-stat-widget" :class="`tone-${widget.tone}`">
<header><span>{{ widget.label }}</span><div><component :is="widget.icon" :size="19" /></div></header>
<strong>{{ widget.value }}</strong>
<footer><CheckCircle2 :size="14" /> {{ widget.meta }}</footer>
</div>
<!-- Widget body routing is centralized here so new dashboard widgets have one extension point. -->
<div v-else-if="widget.id === 'execution-trend'" class="chart-widget">
<div class="chart-bars">
<span
v-for="point in executionTrend"
:key="`${point.label}-${point.value}`"
:class="point.status"
:style="{ height: `${Math.max(point.value, 12)}%` }"
:title="`${point.label}: ${point.status}`"
></span>
</div>
<div class="chart-legend">
<span><i class="completed"></i>Completed</span>
<span><i class="running"></i>Running</span>
<span><i class="failed"></i>Failed</span>
</div>
<p v-if="!jobs.length" class="widget-empty">No execution trend yet. RunPlans will chart here after jobs start.</p>
</div>
<div v-else-if="widget.id === 'operations-health'" class="health-widget">
<div class="health-ring" :style="{ '--value': `${healthGauge * 3.6}deg` }">
<strong>{{ healthGauge }}%</strong>
<span>success</span>
</div>
<div class="health-stats">
<span v-for="row in jobStatusStats" :key="row.status">
<strong>{{ row.count }}</strong>
<small>{{ row.status }}</small>
</span>
</div>
</div>
<div v-else-if="widget.id === 'recent-jobs'" class="job-strip widget-scroll">
<button v-for="job in jobs.slice(0, 6)" :key="job.id" @click="$emit('open-job', job.id)">
<span :class="['status-dot', job.status]"></span>
<strong>{{ job.runplanName || job.id }}</strong>
<small>{{ job.status }}</small>
</button>
<p v-if="!jobs.length" class="widget-empty">No execution jobs have been created yet.</p>
</div>
<div v-else-if="widget.id === 'configuration'" class="settings-list widget-scroll">
<div v-for="row in dashboardSettingsPreview" :key="row.key">
<span>{{ settingLabel(row.key) }}</span>
<strong>{{ row.value }}</strong>
</div>
</div>
<div v-else-if="widget.id === 'quick-actions'" class="widget-action-stack">
<button class="primary-action compact" type="button" @click="$emit('quick-new-script')"><FilePlus2 :size="15" />New script</button>
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'hosts')"><Server :size="15" />Manage hosts</button>
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'runplans')"><Rocket :size="15" />Compose RunPlan</button>
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'logs')"><Activity :size="15" />Open logs</button>
</div>
<div v-else-if="widget.id === 'host-mix'" class="widget-metric-stack">
<span v-for="row in hostTransportStats" :key="row.transport"><strong>{{ row.count }}</strong><small>{{ row.transport }}</small></span>
</div>
<div v-else-if="widget.id === 'script-visibility'" class="widget-metric-stack">
<span v-for="row in scriptVisibilityStats" :key="row.visibility"><strong>{{ row.count }}</strong><small>{{ row.visibility }}</small></span>
</div>
<div v-else-if="widget.id === 'runplan-targets'" class="widget-scroll jobs-watch">
<div v-for="row in runPlanTargetStats" :key="row.name"><span>{{ row.name }}</span><small>{{ row.count }} hosts</small></div>
<p v-if="!runPlanTargetStats.length" class="widget-empty">No RunPlans have targets yet.</p>
</div>
<p v-else class="widget-empty">Widget content has not been implemented yet.</p>
</template>
</article>
</template>
<script setup>
import {
Activity, CheckCircle2, ChevronDown, ChevronUp, FilePlus2, GripVertical,
Maximize2, Minimize2, Rocket, Server, Trash2
} from '@lucide/vue';
defineProps({
widget: { type: Object, required: true },
editing: Boolean,
dragging: Boolean,
executionTrend: { type: Array, required: true },
jobs: { type: Array, required: true },
healthGauge: { type: Number, required: true },
jobStatusStats: { type: Array, required: true },
dashboardSettingsPreview: { type: Array, required: true },
hostTransportStats: { type: Array, required: true },
scriptVisibilityStats: { type: Array, required: true },
runPlanTargetStats: { type: Array, required: true },
settingLabel: { type: Function, required: true }
});
defineEmits([
'drag-start',
'drop-on',
'toggle-collapse',
'resize',
'set-visible',
'open-job',
'navigate',
'quick-new-script'
]);
</script>

View File

@@ -0,0 +1,34 @@
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
<section class="widget-library glass-panel">
<header>
<div>
<span class="eyebrow">WIDGET LIBRARY</span>
<h3>Add dashboard intelligence</h3>
<p>Add hidden cards back to the dashboard. Drag and resize them after they are on the canvas.</p>
</div>
<button class="modal-close" type="button" @click="$emit('close')">x</button>
</header>
<div class="widget-library-grid">
<!-- Hidden widgets are restored through the parent layout state, keeping this modal reusable. -->
<article v-for="widget in widgets" :key="widget.id">
<span><component :is="widget.icon" :size="20" /></span>
<div><strong>{{ widget.title }}</strong><small>{{ widget.category }} - {{ widget.description }}</small></div>
<button class="primary-action compact" type="button" @click="$emit('add', widget.id)">Add</button>
</article>
<p v-if="!widgets.length" class="widget-empty">All dashboard widgets are already visible.</p>
</div>
</section>
</div>
</Teleport>
</template>
<script setup>
defineProps({
open: Boolean,
widgets: { type: Array, required: true }
});
defineEmits(['close', 'add']);
</script>

View File

@@ -0,0 +1,101 @@
<template>
<Teleport to="body">
<section v-if="open" class="help-center glass-panel" :style="{ left: `${position.x}px`, top: `${position.y}px` }">
<header class="help-header" @pointerdown="startDrag">
<div>
<span class="eyebrow">ONLINE HELP</span>
<h3>POSHManager Help Library</h3>
</div>
<button class="modal-close" type="button" @click="$emit('close')">x</button>
</header>
<div class="help-toolbar">
<label>
<Search :size="15" />
<input :value="query" placeholder="Search operations, API, PSADT, cmdlets..." @input="$emit('search', $event.target.value, activeCategory)" />
</label>
<select v-model="activeCategory" @change="$emit('search', query, activeCategory)">
<option value="">All categories</option>
<option v-for="category in categories" :key="category" :value="category">{{ category }}</option>
</select>
</div>
<div class="help-body">
<aside class="help-results">
<button
v-for="article in articles"
:key="article.id"
:class="{ active: article.id === selectedArticle?.id }"
type="button"
@click="$emit('select', article.id)"
>
<span>{{ article.category }}</span>
<strong>{{ article.title }}</strong>
<small>{{ article.summary }}</small>
</button>
<p v-if="!articles.length" class="widget-empty">No help articles match the current search.</p>
</aside>
<main class="help-article">
<div v-if="loading" class="widget-empty">Loading help...</div>
<template v-else-if="selectedArticle">
<span class="eyebrow">{{ selectedArticle.category }}</span>
<h4>{{ selectedArticle.title }}</h4>
<p>{{ selectedArticle.summary }}</p>
<article v-for="part in selectedArticle.sections" :key="part.heading">
<h5>{{ part.heading }}</h5>
<p v-for="line in part.body" :key="line">{{ line }}</p>
<pre v-if="part.code"><code>{{ part.code }}</code></pre>
</article>
<div class="help-tags">
<span v-for="tag in selectedArticle.tags" :key="tag">{{ tag }}</span>
</div>
</template>
<div v-else class="widget-empty">Choose an article to start.</div>
</main>
</div>
</section>
</Teleport>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { Search } from '@lucide/vue';
defineProps({
open: { type: Boolean, default: false },
articles: { type: Array, default: () => [] },
categories: { type: Array, default: () => [] },
selectedArticle: { type: Object, default: null },
query: { type: String, default: '' },
loading: { type: Boolean, default: false }
});
defineEmits(['close', 'search', 'select']);
const activeCategory = ref('');
const position = reactive({ x: 300, y: 88 });
const drag = reactive({ active: false, offsetX: 0, offsetY: 0 });
function startDrag(event) {
if (event.target.closest('button')) return;
drag.active = true;
drag.offsetX = event.clientX - position.x;
drag.offsetY = event.clientY - position.y;
window.addEventListener('pointermove', moveDrag);
window.addEventListener('pointerup', stopDrag, { once: true });
}
function moveDrag(event) {
if (!drag.active) return;
const width = Math.min(window.innerWidth - 24, 980);
const height = Math.min(window.innerHeight - 24, 760);
position.x = Math.max(12, Math.min(window.innerWidth - width - 12, event.clientX - drag.offsetX));
position.y = Math.max(12, Math.min(window.innerHeight - height - 12, event.clientY - drag.offsetY));
}
function stopDrag() {
drag.active = false;
window.removeEventListener('pointermove', moveDrag);
}
</script>

View File

@@ -0,0 +1,191 @@
<template>
<aside class="script-template-tree script-explorer" @contextmenu.prevent="emitContext($event, null, 'root')">
<header class="script-explorer-titlebar">
<strong>Explorer</strong>
<div>
<button title="New folder" type="button" @click="$emit('create-folder', null)">
<FolderPlus :size="15" />
</button>
<button title="Explorer actions" type="button" @click.stop="emitContext($event, null, 'root')">
<MoreHorizontal :size="15" />
</button>
<button title="Close Script Library" type="button" @click="$emit('close')">
<X :size="14" />
</button>
</div>
</header>
<label class="tree-search script-explorer-search">
<Search :size="14" />
<input v-model="query" placeholder="Filter scripts..." />
</label>
<div class="script-tree-scroll script-explorer-scroll" role="tree" aria-label="Script library explorer">
<button
class="explorer-row explorer-root"
type="button"
:aria-expanded="rootExpanded"
@click="rootExpanded = !rootExpanded"
@contextmenu.stop.prevent="emitContext($event, null, 'root')"
>
<ChevronRight :size="14" :class="{ open: rootExpanded }" />
<span>POSHManager</span>
<small>{{ scripts.length }}</small>
</button>
<template v-if="rootExpanded">
<button
v-for="row in visibleRows"
:key="row.key"
:class="[
'explorer-row',
`depth-${Math.min(row.depth, 6)}`,
row.type === 'folder' ? 'explorer-folder' : 'explorer-file',
{ active: row.type === 'script' && selectedScriptId === row.item.id }
]"
type="button"
role="treeitem"
:aria-expanded="row.type === 'folder' ? isFolderExpanded(row.item.id) : undefined"
:style="{ '--depth': row.depth }"
@click="row.type === 'folder' ? toggleFolder(row.item.id) : $emit('select-script', row.item)"
@contextmenu.stop.prevent="emitContext($event, row.item, row.type)"
>
<ChevronRight v-if="row.type === 'folder'" :size="14" :class="{ open: isFolderExpanded(row.item.id) }" />
<span v-else class="explorer-spacer"></span>
<Folder v-if="row.type === 'folder'" :size="15" />
<FileCode2 v-else :size="14" />
<span class="explorer-name">{{ row.item.name }}</span>
<small v-if="row.type === 'script'">PS1</small>
<small v-else>{{ folderScriptCount(row.item.id) || '' }}</small>
</button>
</template>
<div v-if="rootExpanded && !visibleRows.length" class="explorer-empty">
<strong>{{ query ? 'No matches' : 'No scripts yet' }}</strong>
<span>{{ query ? 'Try a different script or folder name.' : 'Create a folder or script to start building the library.' }}</span>
</div>
</div>
</aside>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { ChevronRight, FileCode2, Folder, FolderPlus, MoreHorizontal, Search, X } from '@lucide/vue';
const props = defineProps({
folders: { type: Array, default: () => [] },
scripts: { type: Array, default: () => [] },
selectedScriptId: { type: [String, Number], default: null },
activeFolderId: { type: [String, Number], default: null }
});
const emit = defineEmits(['select-script', 'select-folder', 'create-folder', 'close', 'context-menu']);
const query = ref('');
const rootExpanded = ref(true);
const expandedFolderIds = ref([]);
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
const sortedFolders = computed(() => [...props.folders].sort((a, b) => a.name.localeCompare(b.name)));
const sortedScripts = computed(() => [...props.scripts].sort((a, b) => a.name.localeCompare(b.name)));
const childFoldersByParent = computed(() => groupByParent(sortedFolders.value));
const childScriptsByParent = computed(() => groupByParent(sortedScripts.value, 'folderId'));
const visibleRows = computed(() => {
const rows = [];
appendFolderRows(null, 1, rows);
appendScriptRows(null, 1, rows);
return rows;
});
watch(() => props.folders.map((folder) => folder.id).join('|'), () => {
if (!expandedFolderIds.value.length) {
expandedFolderIds.value = props.folders.map((folder) => folder.id);
return;
}
const validIds = new Set(props.folders.map((folder) => folder.id));
expandedFolderIds.value = expandedFolderIds.value.filter((id) => validIds.has(id));
}, { immediate: true });
watch(() => props.activeFolderId, (folderId) => {
if (!folderId) return;
expandAncestors(folderId);
});
function groupByParent(items, key = 'parentId') {
return items.reduce((map, item) => {
const parentId = item[key] || null;
if (!map.has(parentId)) map.set(parentId, []);
map.get(parentId).push(item);
return map;
}, new Map());
}
function appendFolderRows(parentId, depth, rows) {
for (const folder of childFoldersByParent.value.get(parentId) || []) {
if (!shouldShowFolder(folder)) continue;
rows.push({ key: `folder-${folder.id}`, type: 'folder', item: folder, depth });
if (isFolderExpanded(folder.id) || normalizedQuery.value) {
appendFolderRows(folder.id, depth + 1, rows);
appendScriptRows(folder.id, depth + 1, rows);
}
}
}
function appendScriptRows(parentId, depth, rows) {
for (const script of childScriptsByParent.value.get(parentId) || []) {
if (!matchesSearch(script)) continue;
rows.push({ key: `script-${script.id}`, type: 'script', item: script, depth });
}
}
function shouldShowFolder(folder) {
if (!normalizedQuery.value) return true;
if (matchesSearch(folder)) return true;
return hasMatchingDescendant(folder.id);
}
function hasMatchingDescendant(folderId) {
const childScripts = childScriptsByParent.value.get(folderId) || [];
if (childScripts.some(matchesSearch)) return true;
return (childFoldersByParent.value.get(folderId) || []).some((folder) => matchesSearch(folder) || hasMatchingDescendant(folder.id));
}
function matchesSearch(item) {
const term = normalizedQuery.value;
if (!term) return true;
return [item.name, item.description, item.visibility].filter(Boolean).join(' ').toLowerCase().includes(term);
}
function isFolderExpanded(folderId) {
return expandedFolderIds.value.includes(folderId);
}
function toggleFolder(folderId) {
emit('select-folder', folderId);
expandedFolderIds.value = isFolderExpanded(folderId)
? expandedFolderIds.value.filter((id) => id !== folderId)
: [...expandedFolderIds.value, folderId];
}
function folderScriptCount(folderId) {
return props.scripts.filter((script) => script.folderId === folderId).length;
}
function expandAncestors(folderId) {
const folderById = new Map(props.folders.map((folder) => [folder.id, folder]));
const next = new Set(expandedFolderIds.value);
let cursor = folderById.get(folderId);
while (cursor) {
next.add(cursor.id);
cursor = cursor.parentId ? folderById.get(cursor.parentId) : null;
}
expandedFolderIds.value = [...next];
}
function emitContext(event, item, type) {
emit('context-menu', { event, item, type });
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<BaseModal
:open="open"
title="Test / Run script"
eyebrow="SCRIPT EXPLORER"
:description="script ? `Run ${script.name} against one selected host with one selected vault credential.` : 'Run the selected script against one host.'"
wide
@close="$emit('close')"
>
<form class="modal-form script-test-form" @submit.prevent="execute">
<div class="script-test-grid">
<label>
<span>Target host</span>
<select v-model="hostId" required>
<option value="" disabled>Choose host</option>
<option v-for="host in hosts" :key="host.id" :value="host.id">
{{ host.name }} - {{ host.address }} ({{ host.transport }})
</option>
</select>
</label>
<label>
<span>Vault credential</span>
<select v-model="credentialId" required>
<option value="" disabled>Choose credential</option>
<option v-for="credential in credentials" :key="credential.id" :value="credential.id">
{{ credential.name }} - {{ credential.kind }}{{ credential.username ? ` / ${credential.username}` : '' }}
</option>
</select>
</label>
</div>
<div class="script-test-context">
<span><Server :size="16" />{{ selectedHostSummary }}</span>
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
</div>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
<button class="primary-action" type="submit" :disabled="running || !hostId || !credentialId">
<Play :size="16" />{{ running ? 'Executing...' : 'Execute test' }}
</button>
</div>
<section v-if="job" class="script-test-output">
<header>
<div>
<span class="eyebrow">TERMINAL OUTPUT</span>
<strong>{{ job.status || 'queued' }}</strong>
</div>
<div class="script-test-output-meta">
<small>{{ terminalLineCount }} lines</small>
<small>{{ selectedHost?.name || 'host' }}</small>
</div>
</header>
<textarea readonly spellcheck="false" :value="terminalOutput"></textarea>
</section>
</form>
</BaseModal>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { KeyRound, Play, Server } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
open: Boolean,
script: { type: Object, default: null },
hosts: { type: Array, default: () => [] },
credentials: { type: Array, default: () => [] },
job: { type: Object, default: null },
running: Boolean
});
const emit = defineEmits(['close', 'execute']);
const hostId = ref('');
const credentialId = ref('');
const selectedHost = computed(() => props.hosts.find((host) => host.id === hostId.value));
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.value));
const selectedHostSummary = computed(() => selectedHost.value
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
: 'No host selected');
const selectedCredentialSummary = computed(() => selectedCredential.value
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
: 'No credential selected');
const terminalRows = computed(() => {
if (!props.job) return [];
const lines = [
`POSHManager script test job ${props.job.id}`,
`Script: ${props.job.scriptName || props.script?.name || 'selected script'}`,
`Host: ${selectedHostSummary.value}`,
`Credential: ${selectedCredentialSummary.value}`,
`Status: ${props.job.status || 'queued'}`,
''
];
for (const log of props.job.logs || []) {
lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`);
}
return lines;
});
const terminalOutput = computed(() => terminalRows.value.join('\n'));
const terminalLineCount = computed(() => terminalRows.value.length);
watch(() => props.open, (open) => {
if (!open) return;
hostId.value = props.hosts[0]?.id || '';
credentialId.value = selectedHost.value?.credentialId || props.credentials[0]?.id || '';
}, { immediate: true });
watch(hostId, () => {
if (selectedHost.value?.credentialId && props.credentials.some((credential) => credential.id === selectedHost.value.credentialId)) {
credentialId.value = selectedHost.value.credentialId;
}
});
function execute() {
emit('execute', { hostId: hostId.value, credentialId: credentialId.value });
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<BaseModal
:open="open"
title="Variable Studio"
eyebrow="PSADT + POSHMANAGER"
description="Browse built-in PSADT session variables, insert POSHManager runtime values, and maintain custom variables for deployment scripts."
wide
@close="$emit('close')"
>
<div class="variable-studio">
<div class="variable-toolbar">
<div class="tabs variable-tabs">
<button :class="{ active: tab === 'catalog' }" type="button" @click="tab = 'catalog'"><BookOpenText :size="15" />Catalog</button>
<button :class="{ active: tab === 'custom' }" type="button" @click="tab = 'custom'"><SlidersHorizontal :size="15" />Custom</button>
</div>
<label class="search-control">
<Search :size="15" />
<input v-model="query" placeholder="Search variables..." />
</label>
</div>
<div v-if="tab === 'catalog'" class="variable-catalog-layout">
<aside class="variable-category-list">
<button
v-for="category in categories"
:key="category"
:class="{ active: activeCategory === category }"
type="button"
@click="activeCategory = category"
>
<span>{{ category }}</span>
<strong>{{ categoryCount(category) }}</strong>
</button>
</aside>
<div class="variable-list">
<article v-for="variable in filteredCatalog" :key="variable.id" class="variable-row">
<div>
<strong>{{ variable.syntax }}</strong>
<span>{{ variable.category }}</span>
<p>{{ variable.description }}</p>
</div>
<div class="variable-actions">
<button class="ghost-button compact" type="button" @click="$emit('insert', variable.token)">Token</button>
<button class="primary-action compact" type="button" @click="$emit('insert', variable.syntax)"><Plus :size="14" />Insert</button>
</div>
</article>
<p v-if="!filteredCatalog.length" class="widget-empty">No catalog variables match the current filter.</p>
</div>
</div>
<div v-else class="custom-variable-layout">
<section class="custom-variable-list">
<header>
<div>
<strong>Custom variables</strong>
<span>{{ catalog.custom?.length || 0 }} saved values</span>
</div>
<button class="primary-action compact" type="button" @click="resetForm"><Plus :size="14" />New variable</button>
</header>
<article v-for="variable in filteredCustom" :key="variable.id" :class="{ active: form.id === variable.id }" @click="editCustom(variable)">
<div>
<strong>{{ variable.syntax }}</strong>
<span>{{ variable.category }} · {{ variable.visibility }}</span>
<p>{{ variable.description || 'No description' }}</p>
</div>
<button class="ghost-button compact" type="button" @click.stop="$emit('insert', variable.syntax)">Insert</button>
</article>
<p v-if="!filteredCustom.length" class="widget-empty">No custom variables match the current filter.</p>
</section>
<form class="custom-variable-form" @submit.prevent="saveCustom">
<div class="form-grid">
<label><span>Name</span><input v-model="form.name" required placeholder="CompanyName" /></label>
<label><span>Category</span><input v-model="form.category" placeholder="Deployment" /></label>
<label>
<span>Value type</span>
<select v-model="form.valueType">
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="json">JSON</option>
</select>
</label>
<label>
<span>Visibility</span>
<select v-model="form.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="form.visibility === 'group'">
<span>Group</span>
<select v-model="form.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
<label class="toggle modal-toggle">
<input v-model="form.sensitive" type="checkbox" />
<span><strong>Sensitive value</strong><small>Hide the value in API/UI reads after save.</small></span>
</label>
<label class="full"><span>Description</span><textarea v-model="form.description" rows="3" placeholder="Used by install/uninstall scripts" /></label>
<label class="full">
<span>{{ form.id && form.sensitive ? 'Replace value' : 'Value' }}</span>
<textarea v-model="form.value" rows="4" :placeholder="form.sensitive && form.id ? 'Leave blank to keep current encrypted value' : 'Contoso'" />
</label>
</div>
<div class="custom-variable-preview">
<code>${{ form.name || 'VariableName' }}</code>
<code>{{ previewToken }}</code>
</div>
<div class="form-actions modal-actions">
<button v-if="form.id" class="ghost-button danger-text" type="button" @click="$emit('delete-custom', form.id)"><Trash2 :size="15" />Delete</button>
<button class="ghost-button" type="button" @click="resetForm">Reset</button>
<button class="primary-action" type="submit"><Save :size="16" />Save variable</button>
</div>
</form>
</div>
</div>
</BaseModal>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { BookOpenText, Plus, Save, Search, SlidersHorizontal, Trash2 } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
open: Boolean,
catalog: { type: Object, default: () => ({ builtIns: [], psadt: [], custom: [] }) },
groups: { type: Array, default: () => [] }
});
const tab = ref('catalog');
const query = ref('');
const activeCategory = ref('All');
const form = reactive(emptyForm());
const catalogRows = computed(() => [
...(props.catalog.builtIns || []),
...(props.catalog.psadt || [])
]);
const categories = computed(() => ['All', ...new Set(catalogRows.value.map((variable) => variable.category))]);
const filteredCatalog = computed(() => filterVariables(
catalogRows.value.filter((variable) => activeCategory.value === 'All' || variable.category === activeCategory.value)
));
const filteredCustom = computed(() => filterVariables(props.catalog.custom || []));
const previewToken = computed(() => `{{${form.name || 'VariableName'}}}`);
watch(() => props.open, (open) => {
if (open) {
tab.value = 'catalog';
query.value = '';
}
});
watch(categories, (next) => {
if (!next.includes(activeCategory.value)) activeCategory.value = 'All';
});
function filterVariables(rows) {
const needle = query.value.trim().toLowerCase();
if (!needle) return rows;
return rows.filter((variable) => [
variable.name,
variable.syntax,
variable.token,
variable.category,
variable.description,
variable.visibility
].filter(Boolean).join(' ').toLowerCase().includes(needle));
}
function categoryCount(category) {
return category === 'All'
? catalogRows.value.length
: catalogRows.value.filter((variable) => variable.category === category).length;
}
function editCustom(variable) {
Object.assign(form, {
id: variable.id,
name: variable.name,
description: variable.description || '',
category: variable.category || 'Custom',
value: variable.sensitive ? '' : variable.value || '',
valueType: variable.valueType || 'string',
sensitive: Boolean(variable.sensitive),
visibility: variable.visibility || 'personal',
groupId: variable.groupId || null
});
}
function resetForm() {
Object.assign(form, emptyForm());
}
function saveCustom() {
const payload = { ...form };
if (payload.id && payload.sensitive && !payload.value) delete payload.value;
emit('save-custom', payload);
}
function emptyForm() {
return {
id: null,
name: '',
description: '',
category: 'Custom',
value: '',
valueType: 'string',
sensitive: false,
visibility: 'personal',
groupId: null
};
}
const emit = defineEmits(['close', 'insert', 'save-custom', 'delete-custom']);
</script>

View File

@@ -0,0 +1,57 @@
<template>
<section class="settings-section">
<button class="settings-section-toggle" type="button" :aria-expanded="!collapsed" @click="$emit('toggle')">
<span><component :is="icon" :size="18" /></span>
<div>
<h3>{{ section.section }}</h3>
<p>{{ description }}</p>
</div>
<span class="panel-toggle" :title="collapsed ? 'Expand section' : 'Collapse section'">
<component :is="collapsed ? ChevronDown : ChevronUp" :size="14" />
</span>
</button>
<div v-if="!collapsed" class="settings-section-body">
<div v-for="{ row, key } in section.rows" :key="key" class="setting-row">
<div class="setting-copy">
<span>{{ labelFor(key) }}</span>
<small>{{ descriptionFor(key) }}</small>
<code>{{ key }}</code>
</div>
<div class="setting-control">
<button
v-if="isBooleanSetting(row.value)"
type="button"
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
:aria-pressed="normalizedBooleanSetting(row.value)"
@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" class="settings-input" :placeholder="placeholderFor(key)" />
</div>
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
</div>
</div>
</section>
</template>
<script setup>
import { ChevronDown, ChevronUp } from '@lucide/vue';
defineProps({
section: { type: Object, required: true },
icon: { type: [Object, Function], required: true },
description: { type: String, required: true },
collapsed: Boolean,
labelFor: { type: Function, required: true },
descriptionFor: { type: Function, required: true },
placeholderFor: { type: Function, required: true },
sourceLabel: { type: Function, required: true },
isBooleanSetting: { type: Function, required: true },
normalizedBooleanSetting: { type: Function, required: true }
});
defineEmits(['toggle', 'toggle-boolean']);
</script>

View File

@@ -0,0 +1,186 @@
<template>
<article class="glass-panel form-panel intune-config-panel">
<div class="section-title">
<div>
<span class="eyebrow">CONFIG / INTUNE</span>
<h3>Microsoft Graph Intune</h3>
<p>Configure Graph environments used for Intune app lookup, deployment linking, and install-status sync.</p>
</div>
<button class="primary-action compact" type="button" @click="openModal()">
<CloudCog :size="15" />Add environment
</button>
</div>
<div class="intune-config-grid">
<section class="publisher-card">
<strong>Environment routing</strong>
<p>Graph credentials live here under Config / Intune. PSADT publishing screens consume these environments for tracking and sync.</p>
<div class="config-stat-row">
<span><b>{{ graphConnections.length }}</b><small>environments</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>Graph environments</strong>
<div v-for="connection in graphConnections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b>
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
</span>
</div>
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>
</section>
</div>
<BaseModal
:open="modalOpen"
:title="form.id ? 'Update Graph environment' : 'New Graph environment'"
eyebrow="CONFIG / INTUNE"
description="Store the Entra app registration used for Microsoft Graph Intune access. Client secrets are encrypted by the API."
wide
@close="modalOpen = false"
>
<form class="form-grid" @submit.prevent="save">
<label><span>Name</span><input v-model="form.name" required placeholder="Production Intune" /></label>
<label><span>Cloud</span><select v-model="form.cloud" @change="applyCloudDefaults"><option value="global">Global</option><option value="gcc">GCC</option><option value="gcchigh">GCC High</option><option value="dod">DoD</option><option value="china">China</option><option value="custom">Custom</option></select></label>
<label><span>Tenant ID</span><input v-model="form.tenantId" required placeholder="tenant-guid-or-domain" /></label>
<label><span>Client ID</span><input v-model="form.clientId" required placeholder="app-registration-client-id" /></label>
<label class="full"><span>Client secret</span><input v-model="form.clientSecret" :required="!form.id" type="password" placeholder="Stored encrypted; leave blank to keep existing secret" /></label>
<label><span>Graph base URL</span><input v-model="form.graphBaseUrl" required /></label>
<label><span>Authority host</span><input v-model="form.authorityHost" required /></label>
<label><span>Default API version</span><select v-model="form.defaultApiVersion"><option value="v1.0">v1.0</option><option value="beta">beta</option></select></label>
<label><span>Visibility</span><select v-model="form.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>Allow this environment to be used by Intune tracking.</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.allowWrite" type="checkbox" /><span><strong>Allow tenant write-back</strong><small>Permit publishing/updating Win32 apps in this tenant. Admin only; leave off for read-only tracking.</small></span></label>
<label class="toggle modal-toggle"><input v-model="form.requireApproval" type="checkbox" /><span><strong>Require approval</strong><small>Tenant-changing actions create a change request that an admin must approve before they run.</small></span></label>
<template v-if="isAdmin">
<label class="full"><span>Authorized publishers</span><select v-model="form.publisherIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Empty = any user with a write-enabled connection may publish.</small></label>
<label class="full"><span>Authorized approvers</span><select v-model="form.approverIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Named approvers may approve change requests in addition to admins.</small></label>
</template>
<div class="form-actions modal-actions full">
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
<button class="primary-action" type="submit"><Save :size="17" />Save environment</button>
</div>
</form>
</BaseModal>
</article>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { CloudCog, Save } from '@lucide/vue';
import BaseModal from '../ui/BaseModal.vue';
const props = defineProps({
graphConnections: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
users: { type: Array, default: () => [] },
isAdmin: { type: Boolean, default: false }
});
const emit = defineEmits(['save', 'delete', 'test']);
const modalOpen = ref(false);
const form = reactive(defaultForm());
const enabledCount = computed(() => props.graphConnections.filter((connection) => connection.enabled).length);
const testedCount = computed(() => props.graphConnections.filter((connection) => connection.lastTestStatus).length);
function defaultForm() {
return {
id: null,
name: '',
tenantId: '',
clientId: '',
clientSecret: '',
cloud: 'global',
graphBaseUrl: 'https://graph.microsoft.com',
authorityHost: 'https://login.microsoftonline.com',
defaultApiVersion: 'v1.0',
enabled: true,
allowWrite: false,
requireApproval: false,
publisherIds: [],
approverIds: [],
visibility: 'personal',
groupId: ''
};
}
function openModal(connection = null) {
Object.assign(form, defaultForm(), connection || {});
form.clientSecret = '';
form.groupId = connection?.groupId || '';
modalOpen.value = true;
}
function save() {
emit('save', { ...form, groupId: form.groupId || null });
modalOpen.value = false;
}
function applyCloudDefaults() {
const defaults = {
global: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
gcc: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
gcchigh: ['https://graph.microsoft.us', 'https://login.microsoftonline.us'],
dod: ['https://dod-graph.microsoft.us', 'https://login.microsoftonline.us'],
china: ['https://microsoftgraph.chinacloudapi.cn', 'https://login.chinacloudapi.cn']
};
const next = defaults[form.cloud];
if (!next) return;
form.graphBaseUrl = next[0];
form.authorityHost = next[1];
}
</script>
<style scoped>
.intune-config-panel {
margin-bottom: 18px;
}
.intune-config-grid {
display: grid;
grid-template-columns: minmax(260px, 0.65fr) minmax(0, 1.35fr);
gap: 14px;
}
.config-stat-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.config-stat-row span {
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.055);
}
.config-stat-row b,
.config-stat-row small {
display: block;
}
.config-stat-row small {
color: rgba(255, 255, 255, 0.55);
font-size: 0.72rem;
}
.graph-config-list {
min-height: 180px;
}
@media (max-width: 900px) {
.intune-config-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<Teleport to="body">
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
<section
:class="['form-modal glass-panel relative isolate shadow-2xl', { 'compact-modal': compact, 'wide-form-modal': wide }]"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
>
<header>
<div>
<span v-if="eyebrow" class="eyebrow">{{ eyebrow }}</span>
<h3 :id="titleId">{{ title }}</h3>
<p v-if="description">{{ description }}</p>
</div>
<button class="modal-close" type="button" aria-label="Close dialog" @click="$emit('close')">
<X :size="16" />
</button>
</header>
<slot />
</section>
</div>
</Teleport>
</template>
<script setup>
import { computed } from 'vue';
import { X } from '@lucide/vue';
const props = defineProps({
open: Boolean,
title: { type: String, required: true },
eyebrow: { type: String, default: '' },
description: { type: String, default: '' },
compact: Boolean,
wide: Boolean
});
defineEmits(['close']);
const titleId = computed(() => `modal-title-${props.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'dialog'}`);
</script>

View File

@@ -0,0 +1,100 @@
<template>
<article class="glass-panel resource-table-panel">
<div class="resource-toolbar">
<slot name="toolbar-leading" />
<label class="search-control">
<Search :size="15" />
<input :value="search" :placeholder="searchPlaceholder" @input="$emit('update:search', $event.target.value)" />
<button v-if="search" class="search-clear" type="button" aria-label="Clear search" @click="$emit('update:search', '')">
<X :size="13" />
</button>
</label>
<label class="table-density-select">
<span>Rows</span>
<select :value="pageSize" @change="$emit('update:pageSize', Number($event.target.value))">
<option :value="8">8</option>
<option :value="12">12</option>
<option :value="20">20</option>
</select>
</label>
<span class="toolbar-count">{{ total }} {{ itemLabel }}</span>
<slot name="toolbar-actions" />
</div>
<div v-if="search || sort" class="resource-filter-strip">
<span v-if="search">Search: <strong>{{ search }}</strong></span>
<span v-if="sort">Sort: <strong>{{ activeSortLabel }}</strong> {{ direction }}</span>
</div>
<div v-if="rows.length" class="data-table-wrap">
<table class="data-table resource-table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
<button v-if="column.sortable !== false" type="button" :aria-sort="sortAria(column.key)" @click="$emit('sort', column.key)">
{{ column.label }}
<component :is="sortIcon(column.key)" :size="13" />
</button>
<template v-else>{{ column.label }}</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="column in columns" :key="column.key" :class="column.class">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ row[column.key] || '-' }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="resource-empty-shell">
<slot name="empty">
<div class="widget-empty">{{ emptyText }}</div>
</slot>
</div>
<div class="pagination">
<button class="ghost-button compact" :disabled="page <= 1" aria-label="Previous page" @click="$emit('update:page', page - 1)">
<ChevronLeft :size="15" />Previous
</button>
<span>Page {{ Math.min(page, pageCount) }} of {{ pageCount }}</span>
<button class="ghost-button compact" :disabled="page >= pageCount" aria-label="Next page" @click="$emit('update:page', page + 1)">
Next<ChevronRight :size="15" />
</button>
</div>
</article>
</template>
<script setup>
import { computed } from 'vue';
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue';
const props = defineProps({
columns: { type: Array, required: true },
rows: { type: Array, required: true },
total: { type: Number, required: true },
search: { type: String, default: '' },
page: { type: Number, default: 1 },
pageCount: { type: Number, default: 1 },
pageSize: { type: Number, default: 8 },
sort: { type: String, default: '' },
direction: { type: String, default: 'asc' },
searchPlaceholder: { type: String, default: 'Search...' },
itemLabel: { type: String, default: 'items' },
emptyText: { type: String, default: 'No rows match the current filters.' }
});
defineEmits(['update:search', 'update:page', 'update:pageSize', 'sort']);
const activeSortLabel = computed(() => props.columns.find((column) => column.key === props.sort)?.label || props.sort);
function sortIcon(key) {
if (props.sort !== key) return ChevronsUpDown;
return props.direction === 'asc' ? ArrowUpAZ : ArrowDownAZ;
}
function sortAria(key) {
if (props.sort !== key) return 'none';
return props.direction === 'asc' ? 'ascending' : 'descending';
}
</script>