Initial
This commit is contained in:
49
client/src/components/AppSidebar.vue
Normal file
49
client/src/components/AppSidebar.vue
Normal 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>
|
||||
71
client/src/components/AppTopbar.vue
Normal file
71
client/src/components/AppTopbar.vue
Normal 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>
|
||||
16
client/src/components/MetricCard.vue
Normal file
16
client/src/components/MetricCard.vue
Normal 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>
|
||||
15
client/src/components/ParticleBackdrop.vue
Normal file
15
client/src/components/ParticleBackdrop.vue
Normal 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>
|
||||
82
client/src/components/RelationshipGraph.vue
Normal file
82
client/src/components/RelationshipGraph.vue
Normal 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>
|
||||
251
client/src/components/assets/AssetPreviewPanel.vue
Normal file
251
client/src/components/assets/AssetPreviewPanel.vue
Normal 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>
|
||||
151
client/src/components/assets/AssetTree.vue
Normal file
151
client/src/components/assets/AssetTree.vue
Normal 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>
|
||||
138
client/src/components/dashboard/DashboardWidget.vue
Normal file
138
client/src/components/dashboard/DashboardWidget.vue
Normal 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>
|
||||
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal file
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal 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>
|
||||
101
client/src/components/help/HelpCenter.vue
Normal file
101
client/src/components/help/HelpCenter.vue
Normal 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>
|
||||
191
client/src/components/scripts/ScriptExplorer.vue
Normal file
191
client/src/components/scripts/ScriptExplorer.vue
Normal 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>
|
||||
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal file
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal 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>
|
||||
223
client/src/components/scripts/VariableEditorModal.vue
Normal file
223
client/src/components/scripts/VariableEditorModal.vue
Normal 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>
|
||||
57
client/src/components/settings/ConfigSection.vue
Normal file
57
client/src/components/settings/ConfigSection.vue
Normal 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>
|
||||
186
client/src/components/settings/IntuneGraphConfig.vue
Normal file
186
client/src/components/settings/IntuneGraphConfig.vue
Normal 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>
|
||||
42
client/src/components/ui/BaseModal.vue
Normal file
42
client/src/components/ui/BaseModal.vue
Normal 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>
|
||||
100
client/src/components/ui/ResourceTable.vue
Normal file
100
client/src/components/ui/ResourceTable.vue
Normal 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>
|
||||
Reference in New Issue
Block a user