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

2694
client/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

29
client/src/api.js Normal file
View File

@@ -0,0 +1,29 @@
const API_BASE = import.meta.env.VITE_API_BASE || '';
export function createApi(getToken, onUnauthorized) {
async function request(path, options = {}) {
const token = getToken();
const isFormData = options.body instanceof FormData;
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {})
}
});
if (response.status === 401) onUnauthorized?.();
if (response.status === 204) return null;
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`);
return data;
}
return {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
postForm: (path, body) => request(path, { method: 'POST', body }),
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: (path) => request(path, { method: 'DELETE' })
};
}

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>

View File

@@ -0,0 +1,281 @@
let configured = false;
const coreCmdlets = [
'Add-Content', 'Clear-Content', 'Clear-Item', 'Compare-Object', 'ConvertFrom-Json', 'ConvertTo-Json',
'Copy-Item', 'ForEach-Object', 'Format-List', 'Format-Table', 'Get-ChildItem', 'Get-Command',
'Get-Content', 'Get-Date', 'Get-Help', 'Get-Item', 'Get-Module', 'Get-Process', 'Get-Service',
'Get-Variable', 'Import-Module', 'Invoke-Command', 'Join-Path', 'Measure-Object', 'Move-Item',
'New-Item', 'New-Object', 'New-PSSession', 'Out-File', 'Read-Host', 'Receive-Job', 'Remove-Item',
'Remove-PSSession', 'Resolve-Path', 'Restart-Service', 'Select-Object', 'Set-Content', 'Set-Item',
'Set-StrictMode', 'Sort-Object', 'Start-Job', 'Start-Process', 'Start-Service', 'Stop-Job',
'Stop-Process', 'Stop-Service', 'Test-Path', 'Wait-Job', 'Where-Object', 'Write-Error',
'Write-Host', 'Write-Output', 'Write-Verbose', 'Write-Warning'
];
const psPatterns = [
{ label: 'function block', insertText: 'function ${1:Name} {\n param(\n ${2:[string]$Value}\n )\n\n ${0}\n}', detail: 'PowerShell function snippet' },
{ label: 'try/catch', insertText: 'try {\n ${1}\n}\ncatch {\n Write-Error $_\n ${0}\n}', detail: 'PowerShell error handling snippet' },
{ label: 'foreach', insertText: 'foreach ($${1:item} in $${2:items}) {\n ${0}\n}', detail: 'PowerShell foreach loop' },
{ label: 'if/else', insertText: 'if (${1:$condition}) {\n ${2}\n}\nelse {\n ${0}\n}', detail: 'PowerShell conditional' },
{ label: 'param block', insertText: 'param(\n [Parameter(Mandatory = $${1:false})]\n [${2:string}]$${3:Name}\n)\n\n${0}', detail: 'PowerShell param block' }
];
export function configurePowerShellMonaco(monaco, getContext = () => ({})) {
if (!configured) {
configured = true;
monaco.languages.register({ id: 'powershell', extensions: ['.ps1', '.psm1', '.psd1'], aliases: ['PowerShell', 'powershell'] });
monaco.languages.setLanguageConfiguration('powershell', languageConfiguration);
monaco.languages.setMonarchTokensProvider('powershell', tokensProvider);
monaco.editor.defineTheme('posh-vscode-dark', vscodeTheme);
}
monaco.languages.registerCompletionItemProvider('powershell', {
triggerCharacters: ['-', '$', '{', '.', ':'],
provideCompletionItems(model, position) {
const range = completionRange(model, position);
return { suggestions: buildSuggestions(monaco, getContext(), range) };
}
});
monaco.languages.registerHoverProvider('powershell', {
provideHover(model, position) {
const word = model.getWordAtPosition(position)?.word;
if (!word) return null;
const context = getContext();
const fn = (context.psadtCatalog?.functions || []).find((item) => item.name === word);
if (fn) {
return {
contents: [
{ value: `**${fn.name}**` },
{ value: `PSADT ${fn.category || 'function'} command.` }
]
};
}
const variable = allVariables(context.variableCatalog).find((item) => item.name === word || item.syntax === `$${word}`);
if (variable) {
return {
contents: [
{ value: `**$${variable.name}**` },
{ value: variable.description || `${variable.category || 'Variable'} value.` }
]
};
}
return null;
}
});
}
function completionRange(model, position) {
const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
const match = line.match(/(?:\{\{[A-Za-z0-9_:.-]*|\$?[A-Za-z_][\w:.-]*|[A-Za-z]+-[A-Za-z0-9-]*)$/);
const length = match?.[0]?.length || 0;
return {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: Math.max(1, position.column - length),
endColumn: position.column
};
}
function buildSuggestions(monaco, context, range) {
const kinds = monaco.languages.CompletionItemKind;
const inserts = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
const suggestions = [];
for (const cmdlet of coreCmdlets) {
suggestions.push({
label: cmdlet,
kind: kinds.Function,
insertText: `${cmdlet} `,
detail: 'PowerShell cmdlet',
range
});
}
for (const pattern of psPatterns) {
suggestions.push({
label: pattern.label,
kind: kinds.Snippet,
insertText: pattern.insertText,
insertTextRules: inserts,
detail: pattern.detail,
range
});
}
for (const fn of context.psadtCatalog?.functions || []) {
suggestions.push({
label: fn.name,
kind: kinds.Method,
insertText: fn.snippet || `${fn.name} `,
detail: `PSADT ${fn.category || 'function'}`,
documentation: `PSAppDeployToolkit command${fn.category ? ` in ${fn.category}` : ''}.`,
range
});
}
for (const snippet of context.psadtCatalog?.snippets || []) {
suggestions.push({
label: `psadt: ${snippet.title}`,
kind: kinds.Snippet,
insertText: snippet.body,
detail: `PSADT snippet - ${snippet.category}`,
documentation: snippet.functionName || snippet.category,
range
});
}
for (const variable of allVariables(context.variableCatalog)) {
suggestions.push({
label: variable.syntax || `$${variable.name}`,
kind: variable.sensitive ? kinds.Value : kinds.Variable,
insertText: variable.syntax || `$${variable.name}`,
detail: variable.category || variable.source || 'Variable',
documentation: variable.description || '',
range
});
suggestions.push({
label: variable.token || `{{${variable.name}}}`,
kind: kinds.Reference,
insertText: variable.token || `{{${variable.name}}}`,
detail: 'POSHManager token',
documentation: variable.description || '',
range
});
}
for (const asset of context.assets || []) {
suggestions.push({
label: asset.referenceToken,
kind: kinds.File,
insertText: asset.referenceToken,
detail: `Asset - ${asset.kind || 'file'}`,
documentation: asset.packagePath || asset.name,
range
});
}
return suggestions;
}
function allVariables(catalog = {}) {
return [
...(catalog.builtIns || []),
...(catalog.psadt || []),
...(catalog.custom || [])
];
}
const languageConfiguration = {
comments: {
lineComment: '#',
blockComment: ['<#', '#>']
},
brackets: [['{', '}'], ['[', ']'], ['(', ')']],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"', notIn: ['string'] },
{ open: "'", close: "'", notIn: ['string', 'comment'] }
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" }
],
folding: {
markers: {
start: /^#region\b/,
end: /^#endregion\b/
}
}
};
const tokensProvider = {
ignoreCase: true,
tokenizer: {
root: [
[/<#/, 'comment', '@comment'],
[/#.*$/, 'comment'],
[/\$\{?[A-Za-z_][\w:]*\}?/, 'variable'],
[/\{\{[^}]+\}\}/, 'variable.predefined'],
[/\b(function|param|process|begin|end|dynamicparam|class|enum|if|elseif|else|switch|foreach|for|while|do|until|try|catch|finally|throw|return|break|continue|trap|using|workflow|configuration|data)\b/, 'keyword'],
[/\b(true|false|null)\b/, 'constant'],
[/\b[A-Za-z]+-[A-Za-z][\w-]*\b/, 'support.function'],
[/-[A-Za-z][\w-]*/, 'attribute.name'],
[/\[[A-Za-z0-9_.`]+\]/, 'type'],
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/"/, 'string', '@stringDouble'],
[/'/, 'string', '@stringSingle'],
[/\d+(\.\d+)?/, 'number'],
[/[{}()[\]]/, '@brackets'],
[/[|=+\-*/%!<>~?&]+/, 'operator']
],
comment: [
[/[^<#]+/, 'comment'],
[/#>/, 'comment', '@pop'],
[/[<#]/, 'comment']
],
stringDouble: [
[/[^"$`]+/, 'string'],
[/`./, 'string.escape'],
[/\$\{?[A-Za-z_][\w:]*\}?/, 'variable'],
[/"/, 'string', '@pop']
],
stringSingle: [
[/[^']+/, 'string'],
[/'/, 'string', '@pop']
]
}
};
const vscodeTheme = {
base: 'vs-dark',
inherit: true,
rules: [
{ token: '', foreground: 'd4d4d4', background: '1e1e1e' },
{ token: 'comment', foreground: '6a9955', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'c586c0' },
{ token: 'constant', foreground: '569cd6' },
{ token: 'string', foreground: 'ce9178' },
{ token: 'string.escape', foreground: 'd7ba7d' },
{ token: 'number', foreground: 'b5cea8' },
{ token: 'type', foreground: '4ec9b0' },
{ token: 'variable', foreground: '9cdcfe' },
{ token: 'variable.predefined', foreground: 'dcdcaa' },
{ token: 'support.function', foreground: 'dcdcaa' },
{ token: 'attribute.name', foreground: '9cdcfe' },
{ token: 'operator', foreground: 'd4d4d4' }
],
colors: {
'editor.background': '#1e1e1e',
'editor.foreground': '#d4d4d4',
'editorGutter.background': '#1e1e1e',
'editorLineNumber.foreground': '#858585',
'editorLineNumber.activeForeground': '#c6c6c6',
'editorCursor.foreground': '#aeafad',
'editor.selectionBackground': '#264f78',
'editor.inactiveSelectionBackground': '#3a3d41',
'editor.lineHighlightBackground': '#2a2d2e',
'editor.lineHighlightBorder': '#00000000',
'editorIndentGuide.background1': '#404040',
'editorIndentGuide.activeBackground1': '#707070',
'editorWhitespace.foreground': '#404040',
'editorWidget.background': '#252526',
'editorWidget.border': '#454545',
'editorSuggestWidget.background': '#252526',
'editorSuggestWidget.border': '#454545',
'editorSuggestWidget.foreground': '#d4d4d4',
'editorSuggestWidget.highlightForeground': '#18a3ff',
'editorSuggestWidget.selectedBackground': '#04395e',
'editorHoverWidget.background': '#252526',
'editorHoverWidget.border': '#454545',
'scrollbarSlider.background': '#79797966',
'scrollbarSlider.hoverBackground': '#646464b3',
'scrollbarSlider.activeBackground': '#bfbfbf66',
'minimap.background': '#1e1e1e'
}
};

6
client/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import vuetify from './plugins/vuetify';
import './style.css';
createApp(App).use(vuetify).mount('#app');

View File

@@ -0,0 +1,84 @@
import 'vuetify/styles';
import '@mdi/font/css/materialdesignicons.css';
import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
export default createVuetify({
theme: {
defaultTheme: 'poshDark',
themes: {
poshDark: {
dark: true,
colors: {
background: '#050b14',
surface: '#0d1a2a',
'surface-bright': '#16273c',
'surface-variant': '#24344a',
primary: '#58ddff',
secondary: '#9a86ff',
tertiary: '#64e7bd',
success: '#64e7bd',
warning: '#ffbd6a',
error: '#ff7383',
info: '#73a7ff',
outline: '#6f8198',
'on-background': '#eef8ff',
'on-surface': '#eef8ff',
'on-primary': '#041019'
}
}
}
},
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi }
},
defaults: {
global: {
ripple: true
},
VBtn: {
rounded: 'lg',
elevation: 0,
height: 40,
variant: 'flat',
style: 'text-transform:none; letter-spacing:0;'
},
VCard: {
rounded: 'lg',
elevation: 0
},
VDialog: {
scrim: '#020617'
},
VTextField: {
density: 'comfortable',
variant: 'outlined',
hideDetails: 'auto',
color: 'primary'
},
VSelect: {
density: 'comfortable',
variant: 'outlined',
hideDetails: 'auto',
color: 'primary'
},
VTextarea: {
density: 'comfortable',
variant: 'outlined',
hideDetails: 'auto',
color: 'primary'
},
VSwitch: {
density: 'comfortable',
inset: true,
hideDetails: true,
color: 'primary'
},
VDataTable: {
density: 'comfortable',
fixedHeader: true
}
}
});

6914
client/src/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,823 @@
<template>
<section class="asset-library-page">
<div class="page-header compact-header">
<div>
<span class="eyebrow">ASSET LIBRARY</span>
<h2>Deployment assets</h2>
<p>Store installers, support files, icons, configs, detection scripts, and package content for PSADT, Intune, and general PowerShell automation.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="16" />New folder
</button>
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="16" />Upload asset
</button>
</div>
</div>
<div class="asset-stats-strip">
<article v-for="stat in assetStats" :key="stat.label" class="glass-panel asset-stat-card">
<span><component :is="stat.icon" :size="18" /></span>
<div>
<strong>{{ stat.value }}</strong>
<small>{{ stat.label }}</small>
</div>
</article>
</div>
<div class="asset-library-shell">
<aside class="asset-folder-panel glass-panel">
<header>
<div>
<span class="eyebrow">FOLDERS</span>
<strong>Package tree</strong>
</div>
<button title="New folder" type="button" @click="openFolderModal(null)"><FolderPlus :size="15" /></button>
</header>
<div class="asset-folder-summary">
<span><strong>{{ assets.length }}</strong> assets</span>
<span><strong>{{ folders.length }}</strong> folders</span>
</div>
<AssetTree
:folders="folders"
:assets="assets"
:active-folder-id="activeFolderId"
:open-ids="openFolderIds"
@select="activeFolderId = $event"
@toggle="toggleFolder"
@create-child="openFolderModal(null, $event.id)"
@edit="openFolderModal"
@delete="deleteFolder"
/>
<div v-if="!folders.length" class="asset-tree-empty">
<FolderPlus :size="18" />
<strong>No folders yet</strong>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">Create folder</button>
</div>
</aside>
<main class="asset-table-region">
<ResourceTable
:columns="assetColumns"
:rows="pagedAssets"
:total="filteredAssets.length"
:search="assetFilters.search"
:page="assetFilters.page"
:page-count="assetPageCount"
:page-size="assetFilters.pageSize"
:sort="assetFilters.sort"
:direction="assetFilters.direction"
search-placeholder="Search assets, package paths, checksum, folders..."
item-label="assets"
empty-text="No assets match the current folder or filters."
@update:search="assetFilters.search = $event"
@update:page="assetFilters.page = $event"
@update:page-size="assetFilters.pageSize = $event"
@sort="setSort($event)"
>
<template #toolbar-actions>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="14" />Folder
</button>
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="14" />Upload
</button>
</template>
<template #cell-name="{ row }">
<button :class="['asset-row-title', { active: selectedAsset?.id === row.id }]" type="button" @click="selectAsset(row)">
<component :is="assetIcon(row)" :size="16" />
<span>{{ row.name }}</span>
</button>
</template>
<template #cell-kind="{ row }"><span :class="['status-pill', row.kind]">{{ row.kind }}</span></template>
<template #cell-sizeBytes="{ row }">{{ formatBytes(row.sizeBytes) }}</template>
<template #cell-folderName="{ row }">{{ row.folderName || 'Unfiled' }}</template>
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
<template #cell-actions="{ row }">
<div class="table-action-row">
<button class="ghost-button compact" type="button" @click="selectAsset(row); viewAsset(row)"><Eye :size="14" />View</button>
<button class="ghost-button compact" type="button" @click="downloadAsset(row)"><Download :size="14" />Download</button>
<button class="ghost-button compact danger-text" type="button" @click="deleteAsset(row)"><Trash2 :size="14" /></button>
</div>
</template>
<template #empty>
<section class="asset-empty-state">
<div class="asset-empty-orb"><PackageOpen :size="28" /></div>
<div>
<span class="eyebrow">PACKAGE CONTENT STARTS HERE</span>
<h3>{{ activeFolderId ? 'This folder is empty' : 'No deployment assets yet' }}</h3>
<p>Upload installers, scripts, detection files, icons, archives, configs, or PSADT support content. Assets can be linked into scripts, RunPlans, PSADT profiles, and Intune deployment plans.</p>
</div>
<div class="asset-empty-actions">
<button class="primary-action compact" type="button" @click="openUploadModal">
<Upload :size="15" />Upload asset
</button>
<button class="ghost-button compact" type="button" @click="openFolderModal(null)">
<FolderPlus :size="15" />New folder
</button>
</div>
</section>
</template>
</ResourceTable>
</main>
<AssetPreviewPanel
:asset="selectedAsset"
:preview-url="previewUrl"
:text-preview="textPreview"
@view="viewAsset"
@download="downloadAsset"
@insert-reference="insertReference"
@link="openLinkModal"
@refresh="$emit('refresh')"
/>
</div>
<BaseModal :open="folderModalOpen" :title="folderForm.id ? 'Edit folder' : 'New folder'" @close="folderModalOpen = false">
<form class="modal-form" @submit.prevent="saveFolder">
<label>
<span>Name</span>
<input v-model="folderForm.name" required placeholder="Installers" />
</label>
<label>
<span>Description</span>
<textarea v-model="folderForm.description" rows="3" placeholder="Optional package notes" />
</label>
<label>
<span>Parent folder</span>
<select v-model="folderForm.parentId">
<option :value="null">Library root</option>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
</label>
<label>
<span>Visibility</span>
<select v-model="folderForm.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="folderForm.visibility === 'group'">
<span>Group</span>
<select v-model="folderForm.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
<button class="primary-action full-width" type="submit">Save folder</button>
</form>
</BaseModal>
<BaseModal :open="uploadModalOpen" title="Upload asset" @close="uploadModalOpen = false">
<form class="modal-form" @submit.prevent="submitUpload">
<label class="file-drop-zone">
<Upload :size="22" />
<span>{{ uploadFile?.name || 'Choose a package asset' }}</span>
<small>Installers, scripts, icons, configs, archives, or support files</small>
<input type="file" @change="handleFileSelect" />
</label>
<label>
<span>Name</span>
<input v-model="assetForm.name" required placeholder="Contoso VPN Installer" />
</label>
<label>
<span>Description</span>
<textarea v-model="assetForm.description" rows="3" placeholder="How this asset is used by scripts or packages" />
</label>
<label>
<span>Folder</span>
<select v-model="assetForm.folderId">
<option :value="null">Unfiled</option>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.name }}</option>
</select>
</label>
<label>
<span>Package path</span>
<input v-model="assetForm.packagePath" placeholder="Files/installer.exe or SupportFiles/config.json" />
</label>
<div class="form-grid two">
<label>
<span>Visibility</span>
<select v-model="assetForm.visibility">
<option value="personal">Personal</option>
<option value="shared">Shared</option>
<option value="group">Group</option>
</select>
</label>
<label v-if="assetForm.visibility === 'group'">
<span>Group</span>
<select v-model="assetForm.groupId">
<option :value="null">Choose group</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</label>
</div>
<button class="primary-action full-width" :disabled="!uploadFile" type="submit">Upload asset</button>
</form>
</BaseModal>
<BaseModal :open="linkModalOpen" title="Link asset to target" @close="linkModalOpen = false">
<form class="modal-form" @submit.prevent="saveLink">
<label>
<span>Target type</span>
<select v-model="linkForm.targetType">
<option value="script">Script</option>
<option value="runplan">RunPlan</option>
<option value="psadt_profile">PSADT profile</option>
<option value="intune_deployment">Intune deployment</option>
</select>
</label>
<label>
<span>Target</span>
<select v-model="linkForm.targetId" required>
<option value="">Choose target</option>
<option v-for="target in linkTargets" :key="target.id" :value="target.id">{{ target.name }}</option>
</select>
</label>
<label>
<span>Role</span>
<select v-model="linkForm.linkRole">
<option value="reference">Reference</option>
<option value="package-file">Package file</option>
<option value="installer">Installer</option>
<option value="support-file">Support file</option>
<option value="icon">Icon</option>
<option value="detection-script">Detection script</option>
</select>
</label>
<label>
<span>Package path override</span>
<input v-model="linkForm.packagePath" placeholder="Files/setup.exe" />
</label>
<button class="primary-action full-width" type="submit">Create link</button>
</form>
</BaseModal>
</section>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { Archive, Download, Eye, FileCog, FileText, FolderPlus, Image, Link2, Package, PackageOpen, ScrollText, Trash2, Upload } from '@lucide/vue';
import AssetPreviewPanel from '../components/assets/AssetPreviewPanel.vue';
import AssetTree from '../components/assets/AssetTree.vue';
import BaseModal from '../components/ui/BaseModal.vue';
import ResourceTable from '../components/ui/ResourceTable.vue';
const props = defineProps({
folders: { type: Array, default: () => [] },
assets: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
scripts: { type: Array, default: () => [] },
runplans: { type: Array, default: () => [] },
psadtProfiles: { type: Array, default: () => [] },
intuneDeployments: { type: Array, default: () => [] },
token: { type: String, default: '' }
});
const emit = defineEmits(['refresh', 'save-folder', 'delete-folder', 'upload-asset', 'delete-asset', 'link-asset', 'insert-reference']);
const assetColumns = [
{ key: 'name', label: 'Asset' },
{ key: 'kind', label: 'Type' },
{ key: 'sizeBytes', label: 'Size' },
{ key: 'folderName', label: 'Folder' },
{ key: 'packagePath', label: 'Package path' },
{ key: 'actions', label: 'Actions', sortable: false, class: 'table-actions-cell' }
];
const activeFolderId = ref(null);
const selectedAssetId = ref('');
const openFolderIds = ref([]);
const folderModalOpen = ref(false);
const uploadModalOpen = ref(false);
const linkModalOpen = ref(false);
const uploadFile = ref(null);
const previewUrl = ref('');
const textPreview = ref('');
const assetFilters = reactive({ search: '', sort: 'name', direction: 'asc', page: 1, pageSize: 8 });
const folderForm = reactive({ id: null, parentId: null, name: '', description: '', visibility: 'personal', groupId: null });
const assetForm = reactive({ folderId: null, name: '', description: '', packagePath: '', visibility: 'personal', groupId: null });
const linkForm = reactive({ assetId: '', targetType: 'script', targetId: '', linkRole: 'reference', packagePath: '' });
const selectedAsset = computed(() => props.assets.find((asset) => asset.id === selectedAssetId.value) || props.assets[0] || null);
const assetStats = computed(() => [
{ label: 'Stored assets', value: props.assets.length, icon: PackageOpen },
{ label: 'Folder groups', value: props.folders.length, icon: FolderPlus },
{ label: 'Package paths', value: props.assets.filter((asset) => asset.packagePath).length, icon: Link2 },
{ label: 'Installers', value: props.assets.filter((asset) => asset.kind === 'installer').length, icon: Package }
]);
const filteredAssets = computed(() => {
const query = assetFilters.search.trim().toLowerCase();
return props.assets
.filter((asset) => !activeFolderId.value || asset.folderId === activeFolderId.value)
.filter((asset) => {
if (!query) return true;
return [asset.name, asset.originalName, asset.kind, asset.folderName, asset.packagePath, asset.checksum, asset.description]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query);
})
.sort((a, b) => compare(a, b, assetFilters.sort, assetFilters.direction));
});
const assetPageCount = computed(() => Math.max(1, Math.ceil(filteredAssets.value.length / assetFilters.pageSize)));
const pagedAssets = computed(() => {
const page = Math.min(assetFilters.page, assetPageCount.value);
return filteredAssets.value.slice((page - 1) * assetFilters.pageSize, page * assetFilters.pageSize);
});
const linkTargets = computed(() => ({
script: props.scripts,
runplan: props.runplans,
psadt_profile: props.psadtProfiles,
intune_deployment: props.intuneDeployments
}[linkForm.targetType] || []));
watch(() => props.assets, () => {
if (!selectedAssetId.value && props.assets.length) selectedAssetId.value = props.assets[0].id;
}, { immediate: true });
watch(() => [assetFilters.search, assetFilters.sort, assetFilters.direction, assetFilters.pageSize, activeFolderId.value], () => {
assetFilters.page = 1;
});
watch(() => linkForm.targetType, () => {
linkForm.targetId = '';
});
function compare(a, b, key, direction) {
const left = a?.[key] ?? '';
const right = b?.[key] ?? '';
const result = typeof left === 'number' || typeof right === 'number'
? Number(left || 0) - Number(right || 0)
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
return direction === 'desc' ? result * -1 : result;
}
function setSort(key) {
if (assetFilters.sort === key) assetFilters.direction = assetFilters.direction === 'asc' ? 'desc' : 'asc';
else {
assetFilters.sort = key;
assetFilters.direction = 'asc';
}
}
function toggleFolder(folderId) {
openFolderIds.value = openFolderIds.value.includes(folderId)
? openFolderIds.value.filter((id) => id !== folderId)
: [...openFolderIds.value, folderId];
}
function openFolderModal(folder = null, parentId = null) {
Object.assign(folderForm, {
id: folder?.id || null,
parentId: folder?.parentId ?? parentId,
name: folder?.name || '',
description: folder?.description || '',
visibility: folder?.visibility || 'personal',
groupId: folder?.groupId || null
});
folderModalOpen.value = true;
}
function saveFolder() {
emit('save-folder', { ...folderForm });
folderModalOpen.value = false;
}
function deleteFolder(folder) {
emit('delete-folder', folder);
}
function openUploadModal() {
uploadFile.value = null;
Object.assign(assetForm, {
folderId: activeFolderId.value,
name: '',
description: '',
packagePath: '',
visibility: 'personal',
groupId: null
});
uploadModalOpen.value = true;
}
function handleFileSelect(event) {
const [file] = event.target.files || [];
uploadFile.value = file || null;
if (!file) return;
assetForm.name = assetForm.name || file.name;
assetForm.packagePath = assetForm.packagePath || file.name;
}
async function submitUpload() {
if (!uploadFile.value) return;
emit('upload-asset', {
...assetForm,
file: uploadFile.value,
originalName: uploadFile.value.name,
mimeType: uploadFile.value.type || 'application/octet-stream'
});
uploadModalOpen.value = false;
}
function selectAsset(asset) {
selectedAssetId.value = asset.id;
clearPreview();
}
async function fetchAssetBlob(asset, route = 'view') {
const response = await fetch(route === 'download' ? asset.downloadUrl : asset.viewUrl, {
headers: props.token ? { Authorization: `Bearer ${props.token}` } : {}
});
if (!response.ok) throw new Error(`Asset request failed: ${response.status}`);
return response.blob();
}
async function viewAsset(asset) {
clearPreview();
const blob = await fetchAssetBlob(asset, 'view');
if (asset.mimeType?.startsWith('text/') || ['script', 'config', 'text'].includes(asset.kind)) {
textPreview.value = await blob.text();
return;
}
previewUrl.value = URL.createObjectURL(blob);
}
async function downloadAsset(asset) {
const blob = await fetchAssetBlob(asset, 'download');
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = asset.originalName || asset.name;
anchor.click();
URL.revokeObjectURL(url);
}
function deleteAsset(asset) {
emit('delete-asset', asset);
if (selectedAssetId.value === asset.id) selectedAssetId.value = '';
}
function openLinkModal(asset) {
Object.assign(linkForm, {
assetId: asset.id,
targetType: 'script',
targetId: '',
linkRole: asset.kind === 'installer' ? 'installer' : 'reference',
packagePath: asset.packagePath || asset.originalName
});
linkModalOpen.value = true;
}
function saveLink() {
emit('link-asset', { ...linkForm });
linkModalOpen.value = false;
}
function insertReference(asset) {
emit('insert-reference', asset);
}
function clearPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
previewUrl.value = '';
textPreview.value = '';
}
function assetIcon(asset) {
const map = {
archive: Archive,
config: FileCog,
document: FileText,
image: Image,
installer: Package,
script: ScrollText,
text: FileText
};
return map[asset.kind] || PackageOpen;
}
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-library-page {
display: grid;
gap: 12px;
min-width: 0;
}
.asset-library-page :deep(.page-header),
.asset-library-page .page-header {
min-height: 58px;
margin-bottom: 0;
}
.asset-library-page .page-header p {
max-width: 760px;
}
.asset-stats-strip {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 10px;
}
.asset-stat-card {
min-height: 62px;
padding: 11px 12px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 15px;
background:
radial-gradient(circle at 12% 0, rgba(125, 232, 255, .16), transparent 38%),
linear-gradient(145deg, rgba(19, 27, 62, .88), rgba(6, 18, 45, .78));
}
.asset-stat-card > span {
width: 36px;
height: 36px;
flex: 0 0 36px;
display: grid;
place-items: center;
border: 1px solid rgba(125, 232, 255, .2);
border-radius: 12px;
color: #72e8ff;
background: rgba(95, 219, 255, .1);
}
.asset-stat-card strong {
display: block;
color: #fff;
font: 850 21px var(--display-font);
line-height: 1;
}
.asset-stat-card small {
display: block;
margin-top: 5px;
color: rgba(214, 226, 244, .64);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .08em;
}
.asset-library-shell {
display: grid;
grid-template-columns: minmax(220px, 260px) minmax(520px, 1fr) minmax(270px, 320px);
gap: 14px;
align-items: stretch;
min-width: 0;
}
.asset-folder-panel {
min-height: 430px;
padding: 16px;
border-radius: 18px;
background:
radial-gradient(circle at 0 0, rgba(125, 232, 255, .09), transparent 38%),
linear-gradient(145deg, rgba(31, 18, 58, .86), rgba(8, 15, 42, .8));
}
.asset-folder-panel header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.asset-folder-panel header button {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: rgba(255, 255, 255, 0.84);
background: rgba(255, 255, 255, 0.06);
}
.asset-folder-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.asset-folder-summary span {
min-height: 50px;
padding: 10px;
border: 1px solid rgba(125, 232, 255, .11);
border-radius: 13px;
color: rgba(214, 226, 244, .58);
background: rgba(255, 255, 255, .035);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .07em;
}
.asset-folder-summary strong {
display: block;
color: #fff;
font-size: 17px;
line-height: 1.1;
margin-bottom: 4px;
}
.asset-tree-empty {
min-height: 170px;
margin-top: 12px;
padding: 18px;
display: grid;
place-items: center;
align-content: center;
gap: 10px;
border: 1px dashed rgba(125, 232, 255, .18);
border-radius: 16px;
color: rgba(214, 226, 244, .66);
background: rgba(4, 12, 31, .3);
text-align: center;
}
.asset-tree-empty svg {
color: #79eaff;
}
.asset-tree-empty strong {
color: #fff;
font-size: 12px;
}
.asset-table-region {
min-width: 0;
}
.asset-table-region :deep(.resource-table-panel) {
padding: 14px;
border-radius: 18px;
min-height: 430px;
height: 100%;
display: flex;
flex-direction: column;
}
.asset-table-region :deep(.resource-toolbar) {
grid-template-columns: minmax(260px, 1fr) auto auto auto auto;
}
.asset-table-region :deep(.toolbar-count) {
margin-left: 0;
}
.asset-table-region :deep(.resource-empty-shell) {
flex: 1;
min-height: 270px;
}
.asset-table-region :deep(.pagination) {
margin-top: 12px;
}
.asset-row-title {
width: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
color: rgba(255, 255, 255, 0.86);
background: transparent;
text-align: left;
font-weight: 750;
}
.asset-row-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-row-title.active {
color: #8beaff;
}
.table-action-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.asset-empty-state {
width: min(620px, 100%);
display: grid;
justify-items: center;
gap: 14px;
padding: 28px 20px;
text-align: center;
}
.asset-empty-orb {
width: 66px;
height: 66px;
display: grid;
place-items: center;
border: 1px solid rgba(125, 232, 255, .22);
border-radius: 22px;
color: #79eaff;
background:
radial-gradient(circle at 28% 18%, rgba(255, 255, 255, .2), transparent 24%),
linear-gradient(135deg, rgba(88, 221, 255, .22), rgba(154, 134, 255, .18));
box-shadow: 0 18px 42px rgba(88, 221, 255, .11);
}
.asset-empty-state h3 {
margin: 5px 0 8px;
color: #fff;
font: 800 22px var(--display-font);
letter-spacing: -.03em;
}
.asset-empty-state p {
max-width: 560px;
margin: 0;
color: rgba(214, 226, 244, .72);
font-size: 12px;
line-height: 1.6;
}
.asset-empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 9px;
}
.file-drop-zone {
min-height: 128px;
display: grid;
place-items: center;
gap: 6px;
padding: 18px;
border: 1px dashed rgba(125, 232, 255, 0.35);
border-radius: 16px;
color: rgba(255, 255, 255, 0.82);
background: rgba(95, 219, 255, 0.07);
text-align: center;
cursor: pointer;
}
.file-drop-zone small {
color: rgba(255, 255, 255, 0.48);
}
.file-drop-zone input {
display: none;
}
.form-grid.two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1420px) {
.asset-stats-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.asset-library-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
}
.asset-library-shell :deep(.asset-preview) {
grid-column: 1 / -1;
}
}
@media (max-width: 860px) {
.asset-stats-strip {
grid-template-columns: 1fr;
}
.asset-library-shell {
grid-template-columns: 1fr;
}
.asset-folder-panel {
min-height: auto;
}
.asset-table-region :deep(.resource-toolbar) {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<section class="dashboard-workspace">
<div class="page-header compact-header">
<div>
<span class="eyebrow">COMMAND SURFACE</span>
<h2>Good {{ dayPart }}, {{ firstName }}.</h2>
<p>Arrange, resize, and collapse the operational cards that matter to your workflow.</p>
</div>
<div class="page-actions">
<button class="ghost-button compact" type="button" @click="$emit('toggle-edit')">
<SlidersHorizontal :size="16" />{{ dashboardEditMode ? 'Done' : 'Customize' }}
</button>
<button class="primary-action compact" type="button" @click="$emit('open-library')">
<ListPlus :size="16" />Widget library
</button>
<button v-if="dashboardEditMode" class="ghost-button compact" type="button" @click="$emit('reset-layout')">
<RotateCcw :size="14" />Reset
</button>
</div>
</div>
<div class="dashboard-command-strip glass-panel">
<div>
<LayoutDashboard :size="18" />
<span>{{ visibleDashboardWidgets.length }} active cards</span>
<small>{{ hiddenDashboardWidgets.length }} available in library</small>
</div>
<strong :class="['dashboard-mode-chip', { active: dashboardEditMode }]">{{ dashboardEditMode ? 'DRAG / RESIZE ENABLED' : 'VIEW MODE' }}</strong>
</div>
<div class="widget-grid">
<DashboardWidget
v-for="widget in visibleDashboardWidgets"
:key="widget.id"
:widget="widget"
:editing="dashboardEditMode"
:dragging="draggedDashboardWidgetId === widget.id"
:execution-trend="executionTrend"
:jobs="jobs"
:health-gauge="healthGauge"
:job-status-stats="jobStatusStats"
:dashboard-settings-preview="dashboardSettingsPreview"
:host-transport-stats="hostTransportStats"
:script-visibility-stats="scriptVisibilityStats"
:run-plan-target-stats="runPlanTargetStats"
:setting-label="settingLabel"
@drag-start="$emit('drag-start', $event)"
@drop-on="$emit('drop-on', $event)"
@toggle-collapse="$emit('toggle-collapse', $event)"
@resize="(...args) => $emit('resize', ...args)"
@set-visible="(...args) => $emit('set-visible', ...args)"
@open-job="$emit('open-job', $event)"
@navigate="$emit('navigate', $event)"
@quick-new-script="$emit('quick-new-script')"
/>
</div>
<WidgetLibraryModal
:open="dashboardWidgetLibraryOpen"
:widgets="hiddenDashboardWidgets"
@close="$emit('close-library')"
@add="$emit('set-visible', $event, true)"
/>
</section>
</template>
<script setup>
import { LayoutDashboard, ListPlus, RotateCcw, SlidersHorizontal } from '@lucide/vue';
import DashboardWidget from '../components/dashboard/DashboardWidget.vue';
import WidgetLibraryModal from '../components/dashboard/WidgetLibraryModal.vue';
defineProps({
dayPart: { type: String, required: true },
firstName: { type: String, required: true },
dashboardEditMode: Boolean,
dashboardWidgetLibraryOpen: Boolean,
draggedDashboardWidgetId: { type: String, default: '' },
visibleDashboardWidgets: { type: Array, required: true },
hiddenDashboardWidgets: { type: Array, required: true },
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([
'toggle-edit',
'open-library',
'close-library',
'reset-layout',
'drag-start',
'drop-on',
'toggle-collapse',
'resize',
'set-visible',
'open-job',
'navigate',
'quick-new-script'
]);
</script>

View File

@@ -0,0 +1,20 @@
<template>
<section class="error-view glass-panel">
<span class="eyebrow">{{ eyebrow }}</span>
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<button class="primary-action compact" type="button" @click="$emit('go-dashboard')">
Return to dashboard
</button>
</section>
</template>
<script setup>
defineProps({
eyebrow: { type: String, default: 'VIEW ERROR' },
title: { type: String, default: 'This view is not available' },
message: { type: String, default: 'The requested workspace could not be loaded.' }
});
defineEmits(['go-dashboard']);
</script>

View File

@@ -0,0 +1,80 @@
<template>
<section class="auth-cover">
<div class="auth-cover-art">
<img src="/generated/poshmanager-auth-cover.png" alt="" />
<div class="auth-cover-overlay">
<div class="auth-brand-lockup">
<div class="brand-mark">PS</div>
<div>
<strong>POSHManager</strong>
<span>REMOTE SCRIPT OPERATIONS</span>
</div>
</div>
<div class="auth-story">
<span class="eyebrow">POWERSHELL, ORCHESTRATED</span>
<h1>Execute with confidence across every system.</h1>
<p>Build scripts, target trusted hosts, launch RunPlans, and inspect every byte of output from one API-first operations console.</p>
</div>
<div class="trust-row">
<span><ShieldCheck :size="16" /> Encrypted credentials</span>
<span><Sparkles :size="16" /> Full execution logs</span>
</div>
</div>
</div>
<div class="auth-form-panel">
<div class="auth-form-wrap glass-panel">
<span class="eyebrow">WELCOME BACK</span>
<h2>Sign in to POSHManager</h2>
<p>Use your operator account to continue.</p>
<form @submit.prevent="$emit('login')">
<label>
<span>Email address</span>
<input v-model="loginForm.email" type="email" autocomplete="username" required />
</label>
<label>
<span>Password</span>
<div class="password-input">
<input v-model="loginForm.password" :type="showPassword ? 'text' : 'password'" autocomplete="current-password" required />
<button type="button" @click="$emit('update:showPassword', !showPassword)">
<component :is="showPassword ? EyeOff : Eye" :size="17" />
</button>
</div>
</label>
<div class="auth-options">
<label class="checkbox"><input type="checkbox" checked /><span>Remember this device</span></label>
<a href="#">Forgot password?</a>
</div>
<small v-if="error" class="form-error">{{ error }}</small>
<button class="primary-action auth-submit" type="submit">
<LockKeyhole :size="17" />
Sign in securely
<ArrowRight :size="17" />
</button>
</form>
<template v-if="entraEnabled">
<div class="auth-divider"><span>or</span></div>
<a class="entra-button" href="/api/auth/entra/login">
<Building2 :size="17" />
Sign in with Microsoft
</a>
</template>
<div class="demo-note"><strong>LOCAL DEFAULT</strong><span>admin@posh.local / change-me-now</span></div>
</div>
<footer>Protected by encrypted sessions · API-first by design</footer>
</div>
</section>
</template>
<script setup>
import { ArrowRight, Building2, Eye, EyeOff, LockKeyhole, ShieldCheck, Sparkles } from '@lucide/vue';
defineProps({
loginForm: { type: Object, required: true },
error: { type: String, default: '' },
showPassword: Boolean,
entraEnabled: Boolean
});
defineEmits(['login', 'update:showPassword']);
</script>

File diff suppressed because it is too large Load Diff