Initial
This commit is contained in:
2694
client/src/App.vue
Normal file
2694
client/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
29
client/src/api.js
Normal file
29
client/src/api.js
Normal 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' })
|
||||
};
|
||||
}
|
||||
49
client/src/components/AppSidebar.vue
Normal file
49
client/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="brand-mark">PS</div>
|
||||
<h4 class="logo-text">POSHManager</h4>
|
||||
<button class="sidebar-collapse-button" type="button" :title="collapsed ? 'Expand navigation' : 'Collapse navigation'" @click="$emit('toggle-collapse')">
|
||||
<component :is="collapsed ? PanelLeftOpen : PanelLeftClose" :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="metismenu">
|
||||
<div class="menu-label">Operations</div>
|
||||
<button v-for="item in primaryNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
<div class="menu-label">Administration</div>
|
||||
<button v-for="item in adminNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
|
||||
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
|
||||
<span class="menu-title">{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="user-orb">{{ initials }}</span>
|
||||
<div>
|
||||
<strong>{{ user?.displayName || 'Operator' }}</strong>
|
||||
<small>{{ user?.role || 'session' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
nav: { type: Array, required: true },
|
||||
user: { type: Object, default: null },
|
||||
view: { type: String, required: true },
|
||||
collapsed: Boolean
|
||||
});
|
||||
|
||||
defineEmits(['update:view', 'toggle-collapse']);
|
||||
|
||||
const accountNav = ['profile', 'users', 'admin'];
|
||||
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
|
||||
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
|
||||
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
|
||||
</script>
|
||||
71
client/src/components/AppTopbar.vue
Normal file
71
client/src/components/AppTopbar.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">
|
||||
<span class="eyebrow">PowerShell Operations Platform</span>
|
||||
<h2>{{ activeTitle }}</h2>
|
||||
</div>
|
||||
<div class="topbar-search">
|
||||
<Search :size="17" />
|
||||
<span>Search scripts, hosts, runplans</span>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<button class="ghost-button topbar-action-button" type="button" aria-label="Open help library" @click="$emit('open-help')">
|
||||
<CircleHelp :size="17" />Help
|
||||
</button>
|
||||
<button class="ghost-button topbar-action-button" type="button" aria-label="Refresh current view" @click="$emit('refresh')">
|
||||
<RefreshCcw :size="17" />Refresh
|
||||
</button>
|
||||
<div class="top-profile-menu">
|
||||
<button class="profile-trigger topbar-profile-trigger" type="button" aria-label="Open profile menu" @click="profileOpen = !profileOpen">
|
||||
<span class="avatar top-avatar">
|
||||
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="profileOpen" class="profile-menu-card glass-panel">
|
||||
<div class="profile-menu-head">
|
||||
<span class="avatar large">
|
||||
<img v-if="user?.avatarUrl" :src="user.avatarUrl" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
<div>
|
||||
<strong>{{ user?.displayName || 'Operator' }}</strong>
|
||||
<small>{{ user?.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="profile-menu-action" type="button" @click="openProfile">
|
||||
<UserCircle :size="16" /> My profile
|
||||
</button>
|
||||
<button class="profile-menu-action danger" type="button" @click="logout">
|
||||
<LogOut :size="16" /> Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { CircleHelp, LogOut, RefreshCcw, Search, UserCircle } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeTitle: { type: String, required: true },
|
||||
user: { type: Object, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['refresh', 'logout', 'open-profile', 'open-help']);
|
||||
const profileOpen = ref(false);
|
||||
|
||||
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
|
||||
|
||||
function openProfile() {
|
||||
profileOpen.value = false;
|
||||
emit('open-profile');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
profileOpen.value = false;
|
||||
emit('logout');
|
||||
}
|
||||
</script>
|
||||
16
client/src/components/MetricCard.vue
Normal file
16
client/src/components/MetricCard.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<article :class="['metric-card', `metric-card-${index % 4}`]">
|
||||
<span class="metric-icon"><component :is="card.icon" :size="24" /></span>
|
||||
<div>
|
||||
<strong>{{ card.value }}</strong>
|
||||
<span>{{ card.label }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
card: { type: Object, required: true },
|
||||
index: { type: Number, default: 0 }
|
||||
});
|
||||
</script>
|
||||
15
client/src/components/ParticleBackdrop.vue
Normal file
15
client/src/components/ParticleBackdrop.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="particle-field" aria-hidden="true">
|
||||
<span v-for="n in 54" :key="n" :style="particleStyle(n)"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function particleStyle(index) {
|
||||
const left = (index * 37) % 100;
|
||||
const top = (index * 53) % 100;
|
||||
const delay = (index % 13) * -0.65;
|
||||
const scale = 0.65 + ((index % 5) * 0.16);
|
||||
return { left: `${left}%`, top: `${top}%`, animationDelay: `${delay}s`, transform: `scale(${scale})` };
|
||||
}
|
||||
</script>
|
||||
82
client/src/components/RelationshipGraph.vue
Normal file
82
client/src/components/RelationshipGraph.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="rel-graph">
|
||||
<div class="rel-col rel-supersedes">
|
||||
<span class="rel-col-label">Supersedes</span>
|
||||
<div v-for="rel in supersedence" :key="`s-${rel.targetAppId}`" class="rel-node rel-old">
|
||||
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
|
||||
<small>{{ rel.mode === 'replace' ? 'replace' : 'update' }}</small>
|
||||
</div>
|
||||
<p v-if="!supersedence.length" class="rel-empty">none</p>
|
||||
</div>
|
||||
|
||||
<div class="rel-arrow">→</div>
|
||||
|
||||
<div class="rel-center">
|
||||
<div class="rel-node rel-current">
|
||||
<strong>{{ deployment.name }}</strong>
|
||||
<small>{{ deployment.graphAppId ? 'published' : 'unpublished' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rel-arrow">→</div>
|
||||
|
||||
<div class="rel-col rel-depends">
|
||||
<span class="rel-col-label">Depends on</span>
|
||||
<div v-for="rel in dependencies" :key="`d-${rel.targetAppId}`" class="rel-node rel-dep">
|
||||
<strong>{{ rel.targetName || rel.targetAppId }}</strong>
|
||||
<small>{{ rel.mode === 'autoInstall' ? 'auto-install' : 'detect' }}</small>
|
||||
</div>
|
||||
<p v-if="!dependencies.length" class="rel-empty">none</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
deployment: { type: Object, required: true }
|
||||
});
|
||||
|
||||
const relationships = computed(() => props.deployment.relationships || []);
|
||||
const supersedence = computed(() => relationships.value.filter((r) => r.kind === 'supersedence'));
|
||||
const dependencies = computed(() => relationships.value.filter((r) => r.kind === 'dependency'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rel-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.6rem 0;
|
||||
}
|
||||
.rel-col,
|
||||
.rel-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
.rel-col-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted, #8a93a6);
|
||||
}
|
||||
.rel-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.rel-node strong { font-size: 0.82rem; }
|
||||
.rel-node small { font-size: 0.68rem; color: var(--text-muted, #8a93a6); }
|
||||
.rel-current { border-color: rgba(120, 170, 255, 0.5); background: rgba(120, 170, 255, 0.12); }
|
||||
.rel-old { border-color: rgba(255, 196, 120, 0.4); }
|
||||
.rel-dep { border-color: rgba(140, 220, 170, 0.4); }
|
||||
.rel-arrow { color: var(--text-muted, #8a93a6); font-size: 1.1rem; }
|
||||
.rel-empty { font-size: 0.72rem; color: var(--text-muted, #8a93a6); margin: 0; }
|
||||
</style>
|
||||
251
client/src/components/assets/AssetPreviewPanel.vue
Normal file
251
client/src/components/assets/AssetPreviewPanel.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<aside class="asset-preview glass-panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">ASSET DETAILS</span>
|
||||
<h3>{{ asset?.name || 'No asset selected' }}</h3>
|
||||
</div>
|
||||
<button v-if="asset" class="ghost-button compact" type="button" @click="$emit('refresh')">
|
||||
<RotateCcw :size="14" />Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="asset" class="asset-detail-stack">
|
||||
<div class="asset-kind-card">
|
||||
<component :is="kindIcon" :size="26" />
|
||||
<div>
|
||||
<strong>{{ asset.kind }}</strong>
|
||||
<small>{{ formatBytes(asset.sizeBytes) }} - {{ asset.mimeType }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="asset-preview-frame">
|
||||
<img v-if="previewUrl && asset.mimeType?.startsWith('image/')" :src="previewUrl" :alt="asset.name" />
|
||||
<iframe v-else-if="previewUrl && asset.mimeType === 'application/pdf'" :src="previewUrl" title="Asset preview" />
|
||||
<pre v-else-if="textPreview">{{ textPreview }}</pre>
|
||||
<div v-else class="asset-preview-empty">
|
||||
<FileText :size="24" />
|
||||
<span>Select View to load a preview for images, PDFs, and text-based assets.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="asset-meta-grid">
|
||||
<div><dt>Reference</dt><dd><code>{{ asset.referenceToken }}</code></dd></div>
|
||||
<div><dt>Package path</dt><dd>{{ asset.packagePath || asset.originalName }}</dd></div>
|
||||
<div><dt>Folder</dt><dd>{{ asset.folderName || 'Unfiled' }}</dd></div>
|
||||
<div><dt>Checksum</dt><dd><code>{{ asset.checksum?.slice(0, 18) }}...</code></dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="asset-action-grid">
|
||||
<button class="primary-action compact" type="button" @click="$emit('view', asset)">
|
||||
<Eye :size="15" />View
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('download', asset)">
|
||||
<Download :size="15" />Download
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('insert-reference', asset)">
|
||||
<Link2 :size="15" />Insert token
|
||||
</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('link', asset)">
|
||||
<Network :size="15" />Link target
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="asset-inspector-empty">
|
||||
<div><Package :size="24" /></div>
|
||||
<strong>No asset selected</strong>
|
||||
<p>Pick an asset to preview content, copy its token, download it, or link it into a package workflow.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { Archive, Download, Eye, FileCog, FileText, Image, Link2, Network, Package, RotateCcw, ScrollText } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
asset: { type: Object, default: null },
|
||||
previewUrl: { type: String, default: '' },
|
||||
textPreview: { type: String, default: '' }
|
||||
});
|
||||
|
||||
defineEmits(['view', 'download', 'insert-reference', 'link', 'refresh']);
|
||||
|
||||
const kindIcon = computed(() => {
|
||||
const map = {
|
||||
archive: Archive,
|
||||
config: FileCog,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
installer: Package,
|
||||
script: ScrollText,
|
||||
text: FileText
|
||||
};
|
||||
return map[props.asset?.kind] || FileText;
|
||||
});
|
||||
|
||||
function formatBytes(bytes = 0) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-preview {
|
||||
min-width: 290px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 430px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(125, 232, 255, .08), transparent 34%),
|
||||
linear-gradient(145deg, rgba(20, 25, 58, .88), rgba(7, 17, 42, .82));
|
||||
}
|
||||
|
||||
.asset-preview header,
|
||||
.asset-kind-card,
|
||||
.asset-action-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-preview h3 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.asset-inspector-empty {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
padding: 22px;
|
||||
border: 1px dashed rgba(125, 232, 255, .18);
|
||||
border-radius: 16px;
|
||||
color: rgba(214, 226, 244, .68);
|
||||
background: rgba(3, 10, 26, .34);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-inspector-empty > div {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(125, 232, 255, .2);
|
||||
border-radius: 18px;
|
||||
color: #79eaff;
|
||||
background: rgba(95, 219, 255, .09);
|
||||
}
|
||||
|
||||
.asset-inspector-empty strong {
|
||||
color: #fff;
|
||||
font: 780 17px var(--display-font);
|
||||
}
|
||||
|
||||
.asset-inspector-empty p {
|
||||
max-width: 260px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.asset-detail-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.asset-kind-card {
|
||||
justify-content: flex-start;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(95, 219, 255, 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.asset-kind-card small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.asset-preview-frame {
|
||||
min-height: 190px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 14px;
|
||||
background: rgba(4, 10, 26, 0.56);
|
||||
}
|
||||
|
||||
.asset-preview-frame img,
|
||||
.asset-preview-frame iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
border: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.asset-preview-frame pre {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
color: #b9ffca;
|
||||
font: 0.78rem/1.55 "JetBrains Mono", "Fira Code", Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.asset-preview-empty {
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 22px;
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-meta-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.asset-meta-grid div {
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.asset-meta-grid dt {
|
||||
color: rgba(125, 232, 255, 0.82);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-meta-grid dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.asset-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
</style>
|
||||
151
client/src/components/assets/AssetTree.vue
Normal file
151
client/src/components/assets/AssetTree.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="asset-tree">
|
||||
<button v-if="!nested" :class="['asset-tree-root', { active: activeFolderId === null }]" type="button" @click="$emit('select', null)">
|
||||
<PackageOpen :size="16" />
|
||||
<span>All assets</span>
|
||||
<small>{{ assetCount }}</small>
|
||||
</button>
|
||||
<div v-for="folder in childFolders" :key="folder.id" class="asset-tree-node">
|
||||
<button :class="['asset-tree-folder', { active: activeFolderId === folder.id }]" type="button" @click="$emit('select', folder.id)">
|
||||
<ChevronRight :size="13" :class="{ open: openIds.includes(folder.id) }" @click.stop="toggle(folder.id)" />
|
||||
<Folder :size="15" />
|
||||
<span>{{ folder.name }}</span>
|
||||
<small>{{ folderAssetCount(folder.id) }}</small>
|
||||
</button>
|
||||
<div class="asset-tree-actions">
|
||||
<button title="Add child folder" type="button" @click="$emit('create-child', folder)"><FolderPlus :size="13" /></button>
|
||||
<button title="Edit folder" type="button" @click="$emit('edit', folder)"><Pencil :size="13" /></button>
|
||||
<button title="Delete folder" type="button" @click="$emit('delete', folder)"><Trash2 :size="13" /></button>
|
||||
</div>
|
||||
<AssetTree
|
||||
v-if="openIds.includes(folder.id)"
|
||||
:folders="folders"
|
||||
:assets="assets"
|
||||
:parent-id="folder.id"
|
||||
:active-folder-id="activeFolderId"
|
||||
:open-ids="openIds"
|
||||
nested
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
@create-child="$emit('create-child', $event)"
|
||||
@edit="$emit('edit', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronRight, Folder, FolderPlus, PackageOpen, Pencil, Trash2 } from '@lucide/vue';
|
||||
|
||||
defineOptions({ name: 'AssetTree' });
|
||||
|
||||
const props = defineProps({
|
||||
folders: { type: Array, default: () => [] },
|
||||
assets: { type: Array, default: () => [] },
|
||||
parentId: { type: String, default: null },
|
||||
activeFolderId: { type: String, default: null },
|
||||
openIds: { type: Array, default: () => [] },
|
||||
nested: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'toggle', 'create-child', 'edit', 'delete']);
|
||||
|
||||
const childFolders = computed(() => props.folders.filter((folder) => (folder.parentId || null) === props.parentId));
|
||||
const assetCount = computed(() => props.assets.length);
|
||||
|
||||
function folderAssetCount(folderId) {
|
||||
return props.assets.filter((asset) => asset.folderId === folderId).length;
|
||||
}
|
||||
|
||||
function toggle(folderId) {
|
||||
emit('toggle', folderId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-tree {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.asset-tree .asset-tree {
|
||||
margin-left: 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.asset-tree-root,
|
||||
.asset-tree-folder {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.asset-tree-root {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.asset-tree-root.active,
|
||||
.asset-tree-folder.active {
|
||||
border-color: rgba(95, 219, 255, 0.34);
|
||||
background: linear-gradient(135deg, rgba(58, 178, 255, 0.2), rgba(143, 93, 255, 0.18));
|
||||
color: #f6fbff;
|
||||
}
|
||||
|
||||
.asset-tree-folder span,
|
||||
.asset-tree-root span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-tree-folder small,
|
||||
.asset-tree-root small {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.asset-tree-node {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.asset-tree-actions {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
justify-content: flex-end;
|
||||
margin: -3px 4px 5px 30px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.asset-tree-node:hover > .asset-tree-actions,
|
||||
.asset-tree-node:focus-within > .asset-tree-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.asset-tree-actions button {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
138
client/src/components/dashboard/DashboardWidget.vue
Normal file
138
client/src/components/dashboard/DashboardWidget.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<article
|
||||
:class="['dashboard-widget glass-panel', `widget-${widget.id}`, { editing, collapsed: widget.collapsed, dragging }]"
|
||||
:style="{ gridColumn: `span ${widget.w}`, gridRow: `span ${widget.collapsed ? 1 : widget.h}` }"
|
||||
:draggable="editing"
|
||||
@dragstart="$emit('drag-start', widget.id)"
|
||||
@dragover.prevent
|
||||
@drop="$emit('drop-on', widget.id)"
|
||||
>
|
||||
<div class="widget-toolbar">
|
||||
<button class="widget-drag" :disabled="!editing" title="Drag widget"><GripVertical :size="15" /></button>
|
||||
<div>
|
||||
<span class="eyebrow">{{ widget.category }}</span>
|
||||
<h3>{{ widget.title }}</h3>
|
||||
</div>
|
||||
<div class="widget-actions">
|
||||
<button title="Collapse card" type="button" @click="$emit('toggle-collapse', widget.id)">
|
||||
<component :is="widget.collapsed ? ChevronDown : ChevronUp" :size="13" />
|
||||
</button>
|
||||
<template v-if="editing">
|
||||
<button title="Narrower" type="button" @click="$emit('resize', widget.id, 'w', -1)"><Minimize2 :size="13" /></button>
|
||||
<button title="Wider" type="button" @click="$emit('resize', widget.id, 'w', 1)"><Maximize2 :size="13" /></button>
|
||||
<button title="Shorter" type="button" @click="$emit('resize', widget.id, 'h', -1)">-</button>
|
||||
<button title="Taller" type="button" @click="$emit('resize', widget.id, 'h', 1)">+</button>
|
||||
<button title="Remove widget" type="button" @click="$emit('set-visible', widget.id, false)"><Trash2 :size="13" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!widget.collapsed">
|
||||
<div v-if="widget.metric" class="stat-card dashboard-stat-widget" :class="`tone-${widget.tone}`">
|
||||
<header><span>{{ widget.label }}</span><div><component :is="widget.icon" :size="19" /></div></header>
|
||||
<strong>{{ widget.value }}</strong>
|
||||
<footer><CheckCircle2 :size="14" /> {{ widget.meta }}</footer>
|
||||
</div>
|
||||
|
||||
<!-- Widget body routing is centralized here so new dashboard widgets have one extension point. -->
|
||||
<div v-else-if="widget.id === 'execution-trend'" class="chart-widget">
|
||||
<div class="chart-bars">
|
||||
<span
|
||||
v-for="point in executionTrend"
|
||||
:key="`${point.label}-${point.value}`"
|
||||
:class="point.status"
|
||||
:style="{ height: `${Math.max(point.value, 12)}%` }"
|
||||
:title="`${point.label}: ${point.status}`"
|
||||
></span>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
<span><i class="completed"></i>Completed</span>
|
||||
<span><i class="running"></i>Running</span>
|
||||
<span><i class="failed"></i>Failed</span>
|
||||
</div>
|
||||
<p v-if="!jobs.length" class="widget-empty">No execution trend yet. RunPlans will chart here after jobs start.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'operations-health'" class="health-widget">
|
||||
<div class="health-ring" :style="{ '--value': `${healthGauge * 3.6}deg` }">
|
||||
<strong>{{ healthGauge }}%</strong>
|
||||
<span>success</span>
|
||||
</div>
|
||||
<div class="health-stats">
|
||||
<span v-for="row in jobStatusStats" :key="row.status">
|
||||
<strong>{{ row.count }}</strong>
|
||||
<small>{{ row.status }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'recent-jobs'" class="job-strip widget-scroll">
|
||||
<button v-for="job in jobs.slice(0, 6)" :key="job.id" @click="$emit('open-job', job.id)">
|
||||
<span :class="['status-dot', job.status]"></span>
|
||||
<strong>{{ job.runplanName || job.id }}</strong>
|
||||
<small>{{ job.status }}</small>
|
||||
</button>
|
||||
<p v-if="!jobs.length" class="widget-empty">No execution jobs have been created yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'configuration'" class="settings-list widget-scroll">
|
||||
<div v-for="row in dashboardSettingsPreview" :key="row.key">
|
||||
<span>{{ settingLabel(row.key) }}</span>
|
||||
<strong>{{ row.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'quick-actions'" class="widget-action-stack">
|
||||
<button class="primary-action compact" type="button" @click="$emit('quick-new-script')"><FilePlus2 :size="15" />New script</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'hosts')"><Server :size="15" />Manage hosts</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'runplans')"><Rocket :size="15" />Compose RunPlan</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('navigate', 'logs')"><Activity :size="15" />Open logs</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="widget.id === 'host-mix'" class="widget-metric-stack">
|
||||
<span v-for="row in hostTransportStats" :key="row.transport"><strong>{{ row.count }}</strong><small>{{ row.transport }}</small></span>
|
||||
</div>
|
||||
<div v-else-if="widget.id === 'script-visibility'" class="widget-metric-stack">
|
||||
<span v-for="row in scriptVisibilityStats" :key="row.visibility"><strong>{{ row.count }}</strong><small>{{ row.visibility }}</small></span>
|
||||
</div>
|
||||
<div v-else-if="widget.id === 'runplan-targets'" class="widget-scroll jobs-watch">
|
||||
<div v-for="row in runPlanTargetStats" :key="row.name"><span>{{ row.name }}</span><small>{{ row.count }} hosts</small></div>
|
||||
<p v-if="!runPlanTargetStats.length" class="widget-empty">No RunPlans have targets yet.</p>
|
||||
</div>
|
||||
<p v-else class="widget-empty">Widget content has not been implemented yet.</p>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Activity, CheckCircle2, ChevronDown, ChevronUp, FilePlus2, GripVertical,
|
||||
Maximize2, Minimize2, Rocket, Server, Trash2
|
||||
} from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
widget: { type: Object, required: true },
|
||||
editing: Boolean,
|
||||
dragging: Boolean,
|
||||
executionTrend: { type: Array, required: true },
|
||||
jobs: { type: Array, required: true },
|
||||
healthGauge: { type: Number, required: true },
|
||||
jobStatusStats: { type: Array, required: true },
|
||||
dashboardSettingsPreview: { type: Array, required: true },
|
||||
hostTransportStats: { type: Array, required: true },
|
||||
scriptVisibilityStats: { type: Array, required: true },
|
||||
runPlanTargetStats: { type: Array, required: true },
|
||||
settingLabel: { type: Function, required: true }
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'drag-start',
|
||||
'drop-on',
|
||||
'toggle-collapse',
|
||||
'resize',
|
||||
'set-visible',
|
||||
'open-job',
|
||||
'navigate',
|
||||
'quick-new-script'
|
||||
]);
|
||||
</script>
|
||||
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal file
34
client/src/components/dashboard/WidgetLibraryModal.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
|
||||
<section class="widget-library glass-panel">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">WIDGET LIBRARY</span>
|
||||
<h3>Add dashboard intelligence</h3>
|
||||
<p>Add hidden cards back to the dashboard. Drag and resize them after they are on the canvas.</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="$emit('close')">x</button>
|
||||
</header>
|
||||
<div class="widget-library-grid">
|
||||
<!-- Hidden widgets are restored through the parent layout state, keeping this modal reusable. -->
|
||||
<article v-for="widget in widgets" :key="widget.id">
|
||||
<span><component :is="widget.icon" :size="20" /></span>
|
||||
<div><strong>{{ widget.title }}</strong><small>{{ widget.category }} - {{ widget.description }}</small></div>
|
||||
<button class="primary-action compact" type="button" @click="$emit('add', widget.id)">Add</button>
|
||||
</article>
|
||||
<p v-if="!widgets.length" class="widget-empty">All dashboard widgets are already visible.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: Boolean,
|
||||
widgets: { type: Array, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['close', 'add']);
|
||||
</script>
|
||||
101
client/src/components/help/HelpCenter.vue
Normal file
101
client/src/components/help/HelpCenter.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<section v-if="open" class="help-center glass-panel" :style="{ left: `${position.x}px`, top: `${position.y}px` }">
|
||||
<header class="help-header" @pointerdown="startDrag">
|
||||
<div>
|
||||
<span class="eyebrow">ONLINE HELP</span>
|
||||
<h3>POSHManager Help Library</h3>
|
||||
</div>
|
||||
<button class="modal-close" type="button" @click="$emit('close')">x</button>
|
||||
</header>
|
||||
|
||||
<div class="help-toolbar">
|
||||
<label>
|
||||
<Search :size="15" />
|
||||
<input :value="query" placeholder="Search operations, API, PSADT, cmdlets..." @input="$emit('search', $event.target.value, activeCategory)" />
|
||||
</label>
|
||||
<select v-model="activeCategory" @change="$emit('search', query, activeCategory)">
|
||||
<option value="">All categories</option>
|
||||
<option v-for="category in categories" :key="category" :value="category">{{ category }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="help-body">
|
||||
<aside class="help-results">
|
||||
<button
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
:class="{ active: article.id === selectedArticle?.id }"
|
||||
type="button"
|
||||
@click="$emit('select', article.id)"
|
||||
>
|
||||
<span>{{ article.category }}</span>
|
||||
<strong>{{ article.title }}</strong>
|
||||
<small>{{ article.summary }}</small>
|
||||
</button>
|
||||
<p v-if="!articles.length" class="widget-empty">No help articles match the current search.</p>
|
||||
</aside>
|
||||
|
||||
<main class="help-article">
|
||||
<div v-if="loading" class="widget-empty">Loading help...</div>
|
||||
<template v-else-if="selectedArticle">
|
||||
<span class="eyebrow">{{ selectedArticle.category }}</span>
|
||||
<h4>{{ selectedArticle.title }}</h4>
|
||||
<p>{{ selectedArticle.summary }}</p>
|
||||
<article v-for="part in selectedArticle.sections" :key="part.heading">
|
||||
<h5>{{ part.heading }}</h5>
|
||||
<p v-for="line in part.body" :key="line">{{ line }}</p>
|
||||
<pre v-if="part.code"><code>{{ part.code }}</code></pre>
|
||||
</article>
|
||||
<div class="help-tags">
|
||||
<span v-for="tag in selectedArticle.tags" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="widget-empty">Choose an article to start.</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { Search } from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
articles: { type: Array, default: () => [] },
|
||||
categories: { type: Array, default: () => [] },
|
||||
selectedArticle: { type: Object, default: null },
|
||||
query: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
defineEmits(['close', 'search', 'select']);
|
||||
|
||||
const activeCategory = ref('');
|
||||
const position = reactive({ x: 300, y: 88 });
|
||||
const drag = reactive({ active: false, offsetX: 0, offsetY: 0 });
|
||||
|
||||
function startDrag(event) {
|
||||
if (event.target.closest('button')) return;
|
||||
drag.active = true;
|
||||
drag.offsetX = event.clientX - position.x;
|
||||
drag.offsetY = event.clientY - position.y;
|
||||
window.addEventListener('pointermove', moveDrag);
|
||||
window.addEventListener('pointerup', stopDrag, { once: true });
|
||||
}
|
||||
|
||||
function moveDrag(event) {
|
||||
if (!drag.active) return;
|
||||
const width = Math.min(window.innerWidth - 24, 980);
|
||||
const height = Math.min(window.innerHeight - 24, 760);
|
||||
position.x = Math.max(12, Math.min(window.innerWidth - width - 12, event.clientX - drag.offsetX));
|
||||
position.y = Math.max(12, Math.min(window.innerHeight - height - 12, event.clientY - drag.offsetY));
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
drag.active = false;
|
||||
window.removeEventListener('pointermove', moveDrag);
|
||||
}
|
||||
</script>
|
||||
191
client/src/components/scripts/ScriptExplorer.vue
Normal file
191
client/src/components/scripts/ScriptExplorer.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<aside class="script-template-tree script-explorer" @contextmenu.prevent="emitContext($event, null, 'root')">
|
||||
<header class="script-explorer-titlebar">
|
||||
<strong>Explorer</strong>
|
||||
<div>
|
||||
<button title="New folder" type="button" @click="$emit('create-folder', null)">
|
||||
<FolderPlus :size="15" />
|
||||
</button>
|
||||
<button title="Explorer actions" type="button" @click.stop="emitContext($event, null, 'root')">
|
||||
<MoreHorizontal :size="15" />
|
||||
</button>
|
||||
<button title="Close Script Library" type="button" @click="$emit('close')">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="tree-search script-explorer-search">
|
||||
<Search :size="14" />
|
||||
<input v-model="query" placeholder="Filter scripts..." />
|
||||
</label>
|
||||
|
||||
<div class="script-tree-scroll script-explorer-scroll" role="tree" aria-label="Script library explorer">
|
||||
<button
|
||||
class="explorer-row explorer-root"
|
||||
type="button"
|
||||
:aria-expanded="rootExpanded"
|
||||
@click="rootExpanded = !rootExpanded"
|
||||
@contextmenu.stop.prevent="emitContext($event, null, 'root')"
|
||||
>
|
||||
<ChevronRight :size="14" :class="{ open: rootExpanded }" />
|
||||
<span>POSHManager</span>
|
||||
<small>{{ scripts.length }}</small>
|
||||
</button>
|
||||
|
||||
<template v-if="rootExpanded">
|
||||
<button
|
||||
v-for="row in visibleRows"
|
||||
:key="row.key"
|
||||
:class="[
|
||||
'explorer-row',
|
||||
`depth-${Math.min(row.depth, 6)}`,
|
||||
row.type === 'folder' ? 'explorer-folder' : 'explorer-file',
|
||||
{ active: row.type === 'script' && selectedScriptId === row.item.id }
|
||||
]"
|
||||
type="button"
|
||||
role="treeitem"
|
||||
:aria-expanded="row.type === 'folder' ? isFolderExpanded(row.item.id) : undefined"
|
||||
:style="{ '--depth': row.depth }"
|
||||
@click="row.type === 'folder' ? toggleFolder(row.item.id) : $emit('select-script', row.item)"
|
||||
@contextmenu.stop.prevent="emitContext($event, row.item, row.type)"
|
||||
>
|
||||
<ChevronRight v-if="row.type === 'folder'" :size="14" :class="{ open: isFolderExpanded(row.item.id) }" />
|
||||
<span v-else class="explorer-spacer"></span>
|
||||
<Folder v-if="row.type === 'folder'" :size="15" />
|
||||
<FileCode2 v-else :size="14" />
|
||||
<span class="explorer-name">{{ row.item.name }}</span>
|
||||
<small v-if="row.type === 'script'">PS1</small>
|
||||
<small v-else>{{ folderScriptCount(row.item.id) || '' }}</small>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="rootExpanded && !visibleRows.length" class="explorer-empty">
|
||||
<strong>{{ query ? 'No matches' : 'No scripts yet' }}</strong>
|
||||
<span>{{ query ? 'Try a different script or folder name.' : 'Create a folder or script to start building the library.' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ChevronRight, FileCode2, Folder, FolderPlus, MoreHorizontal, Search, X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
folders: { type: Array, default: () => [] },
|
||||
scripts: { type: Array, default: () => [] },
|
||||
selectedScriptId: { type: [String, Number], default: null },
|
||||
activeFolderId: { type: [String, Number], default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-script', 'select-folder', 'create-folder', 'close', 'context-menu']);
|
||||
|
||||
const query = ref('');
|
||||
const rootExpanded = ref(true);
|
||||
const expandedFolderIds = ref([]);
|
||||
|
||||
const normalizedQuery = computed(() => query.value.trim().toLowerCase());
|
||||
|
||||
const sortedFolders = computed(() => [...props.folders].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
const sortedScripts = computed(() => [...props.scripts].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const childFoldersByParent = computed(() => groupByParent(sortedFolders.value));
|
||||
const childScriptsByParent = computed(() => groupByParent(sortedScripts.value, 'folderId'));
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const rows = [];
|
||||
appendFolderRows(null, 1, rows);
|
||||
appendScriptRows(null, 1, rows);
|
||||
return rows;
|
||||
});
|
||||
|
||||
watch(() => props.folders.map((folder) => folder.id).join('|'), () => {
|
||||
if (!expandedFolderIds.value.length) {
|
||||
expandedFolderIds.value = props.folders.map((folder) => folder.id);
|
||||
return;
|
||||
}
|
||||
const validIds = new Set(props.folders.map((folder) => folder.id));
|
||||
expandedFolderIds.value = expandedFolderIds.value.filter((id) => validIds.has(id));
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.activeFolderId, (folderId) => {
|
||||
if (!folderId) return;
|
||||
expandAncestors(folderId);
|
||||
});
|
||||
|
||||
function groupByParent(items, key = 'parentId') {
|
||||
return items.reduce((map, item) => {
|
||||
const parentId = item[key] || null;
|
||||
if (!map.has(parentId)) map.set(parentId, []);
|
||||
map.get(parentId).push(item);
|
||||
return map;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function appendFolderRows(parentId, depth, rows) {
|
||||
for (const folder of childFoldersByParent.value.get(parentId) || []) {
|
||||
if (!shouldShowFolder(folder)) continue;
|
||||
rows.push({ key: `folder-${folder.id}`, type: 'folder', item: folder, depth });
|
||||
if (isFolderExpanded(folder.id) || normalizedQuery.value) {
|
||||
appendFolderRows(folder.id, depth + 1, rows);
|
||||
appendScriptRows(folder.id, depth + 1, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendScriptRows(parentId, depth, rows) {
|
||||
for (const script of childScriptsByParent.value.get(parentId) || []) {
|
||||
if (!matchesSearch(script)) continue;
|
||||
rows.push({ key: `script-${script.id}`, type: 'script', item: script, depth });
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowFolder(folder) {
|
||||
if (!normalizedQuery.value) return true;
|
||||
if (matchesSearch(folder)) return true;
|
||||
return hasMatchingDescendant(folder.id);
|
||||
}
|
||||
|
||||
function hasMatchingDescendant(folderId) {
|
||||
const childScripts = childScriptsByParent.value.get(folderId) || [];
|
||||
if (childScripts.some(matchesSearch)) return true;
|
||||
return (childFoldersByParent.value.get(folderId) || []).some((folder) => matchesSearch(folder) || hasMatchingDescendant(folder.id));
|
||||
}
|
||||
|
||||
function matchesSearch(item) {
|
||||
const term = normalizedQuery.value;
|
||||
if (!term) return true;
|
||||
return [item.name, item.description, item.visibility].filter(Boolean).join(' ').toLowerCase().includes(term);
|
||||
}
|
||||
|
||||
function isFolderExpanded(folderId) {
|
||||
return expandedFolderIds.value.includes(folderId);
|
||||
}
|
||||
|
||||
function toggleFolder(folderId) {
|
||||
emit('select-folder', folderId);
|
||||
expandedFolderIds.value = isFolderExpanded(folderId)
|
||||
? expandedFolderIds.value.filter((id) => id !== folderId)
|
||||
: [...expandedFolderIds.value, folderId];
|
||||
}
|
||||
|
||||
function folderScriptCount(folderId) {
|
||||
return props.scripts.filter((script) => script.folderId === folderId).length;
|
||||
}
|
||||
|
||||
function expandAncestors(folderId) {
|
||||
const folderById = new Map(props.folders.map((folder) => [folder.id, folder]));
|
||||
const next = new Set(expandedFolderIds.value);
|
||||
let cursor = folderById.get(folderId);
|
||||
while (cursor) {
|
||||
next.add(cursor.id);
|
||||
cursor = cursor.parentId ? folderById.get(cursor.parentId) : null;
|
||||
}
|
||||
expandedFolderIds.value = [...next];
|
||||
}
|
||||
|
||||
function emitContext(event, item, type) {
|
||||
emit('context-menu', { event, item, type });
|
||||
}
|
||||
</script>
|
||||
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal file
121
client/src/components/scripts/ScriptTestRunModal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Test / Run script"
|
||||
eyebrow="SCRIPT EXPLORER"
|
||||
:description="script ? `Run ${script.name} against one selected host with one selected vault credential.` : 'Run the selected script against one host.'"
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<form class="modal-form script-test-form" @submit.prevent="execute">
|
||||
<div class="script-test-grid">
|
||||
<label>
|
||||
<span>Target host</span>
|
||||
<select v-model="hostId" required>
|
||||
<option value="" disabled>Choose host</option>
|
||||
<option v-for="host in hosts" :key="host.id" :value="host.id">
|
||||
{{ host.name }} - {{ host.address }} ({{ host.transport }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Vault credential</span>
|
||||
<select v-model="credentialId" required>
|
||||
<option value="" disabled>Choose credential</option>
|
||||
<option v-for="credential in credentials" :key="credential.id" :value="credential.id">
|
||||
{{ credential.name }} - {{ credential.kind }}{{ credential.username ? ` / ${credential.username}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="script-test-context">
|
||||
<span><Server :size="16" />{{ selectedHostSummary }}</span>
|
||||
<span><KeyRound :size="16" />{{ selectedCredentialSummary }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions modal-actions full">
|
||||
<button class="ghost-button" type="button" @click="$emit('close')">Close</button>
|
||||
<button class="primary-action" type="submit" :disabled="running || !hostId || !credentialId">
|
||||
<Play :size="16" />{{ running ? 'Executing...' : 'Execute test' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="job" class="script-test-output">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">TERMINAL OUTPUT</span>
|
||||
<strong>{{ job.status || 'queued' }}</strong>
|
||||
</div>
|
||||
<div class="script-test-output-meta">
|
||||
<small>{{ terminalLineCount }} lines</small>
|
||||
<small>{{ selectedHost?.name || 'host' }}</small>
|
||||
</div>
|
||||
</header>
|
||||
<textarea readonly spellcheck="false" :value="terminalOutput"></textarea>
|
||||
</section>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { KeyRound, Play, Server } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
script: { type: Object, default: null },
|
||||
hosts: { type: Array, default: () => [] },
|
||||
credentials: { type: Array, default: () => [] },
|
||||
job: { type: Object, default: null },
|
||||
running: Boolean
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'execute']);
|
||||
|
||||
const hostId = ref('');
|
||||
const credentialId = ref('');
|
||||
|
||||
const selectedHost = computed(() => props.hosts.find((host) => host.id === hostId.value));
|
||||
const selectedCredential = computed(() => props.credentials.find((credential) => credential.id === credentialId.value));
|
||||
const selectedHostSummary = computed(() => selectedHost.value
|
||||
? `${selectedHost.value.name} / ${selectedHost.value.transport} / ${selectedHost.value.address}`
|
||||
: 'No host selected');
|
||||
const selectedCredentialSummary = computed(() => selectedCredential.value
|
||||
? `${selectedCredential.value.name}${selectedCredential.value.username ? ` as ${selectedCredential.value.username}` : ''}`
|
||||
: 'No credential selected');
|
||||
const terminalRows = computed(() => {
|
||||
if (!props.job) return [];
|
||||
const lines = [
|
||||
`POSHManager script test job ${props.job.id}`,
|
||||
`Script: ${props.job.scriptName || props.script?.name || 'selected script'}`,
|
||||
`Host: ${selectedHostSummary.value}`,
|
||||
`Credential: ${selectedCredentialSummary.value}`,
|
||||
`Status: ${props.job.status || 'queued'}`,
|
||||
''
|
||||
];
|
||||
for (const log of props.job.logs || []) {
|
||||
lines.push(`[${log.createdAt || ''}] [${String(log.stream || 'system').toUpperCase()}] [${log.hostName || selectedHost.value?.name || 'host'}] ${log.line}`);
|
||||
}
|
||||
return lines;
|
||||
});
|
||||
const terminalOutput = computed(() => terminalRows.value.join('\n'));
|
||||
const terminalLineCount = computed(() => terminalRows.value.length);
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open) return;
|
||||
hostId.value = props.hosts[0]?.id || '';
|
||||
credentialId.value = selectedHost.value?.credentialId || props.credentials[0]?.id || '';
|
||||
}, { immediate: true });
|
||||
|
||||
watch(hostId, () => {
|
||||
if (selectedHost.value?.credentialId && props.credentials.some((credential) => credential.id === selectedHost.value.credentialId)) {
|
||||
credentialId.value = selectedHost.value.credentialId;
|
||||
}
|
||||
});
|
||||
|
||||
function execute() {
|
||||
emit('execute', { hostId: hostId.value, credentialId: credentialId.value });
|
||||
}
|
||||
</script>
|
||||
223
client/src/components/scripts/VariableEditorModal.vue
Normal file
223
client/src/components/scripts/VariableEditorModal.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
title="Variable Studio"
|
||||
eyebrow="PSADT + POSHMANAGER"
|
||||
description="Browse built-in PSADT session variables, insert POSHManager runtime values, and maintain custom variables for deployment scripts."
|
||||
wide
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="variable-studio">
|
||||
<div class="variable-toolbar">
|
||||
<div class="tabs variable-tabs">
|
||||
<button :class="{ active: tab === 'catalog' }" type="button" @click="tab = 'catalog'"><BookOpenText :size="15" />Catalog</button>
|
||||
<button :class="{ active: tab === 'custom' }" type="button" @click="tab = 'custom'"><SlidersHorizontal :size="15" />Custom</button>
|
||||
</div>
|
||||
<label class="search-control">
|
||||
<Search :size="15" />
|
||||
<input v-model="query" placeholder="Search variables..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'catalog'" class="variable-catalog-layout">
|
||||
<aside class="variable-category-list">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:class="{ active: activeCategory === category }"
|
||||
type="button"
|
||||
@click="activeCategory = category"
|
||||
>
|
||||
<span>{{ category }}</span>
|
||||
<strong>{{ categoryCount(category) }}</strong>
|
||||
</button>
|
||||
</aside>
|
||||
<div class="variable-list">
|
||||
<article v-for="variable in filteredCatalog" :key="variable.id" class="variable-row">
|
||||
<div>
|
||||
<strong>{{ variable.syntax }}</strong>
|
||||
<span>{{ variable.category }}</span>
|
||||
<p>{{ variable.description }}</p>
|
||||
</div>
|
||||
<div class="variable-actions">
|
||||
<button class="ghost-button compact" type="button" @click="$emit('insert', variable.token)">Token</button>
|
||||
<button class="primary-action compact" type="button" @click="$emit('insert', variable.syntax)"><Plus :size="14" />Insert</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!filteredCatalog.length" class="widget-empty">No catalog variables match the current filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="custom-variable-layout">
|
||||
<section class="custom-variable-list">
|
||||
<header>
|
||||
<div>
|
||||
<strong>Custom variables</strong>
|
||||
<span>{{ catalog.custom?.length || 0 }} saved values</span>
|
||||
</div>
|
||||
<button class="primary-action compact" type="button" @click="resetForm"><Plus :size="14" />New variable</button>
|
||||
</header>
|
||||
<article v-for="variable in filteredCustom" :key="variable.id" :class="{ active: form.id === variable.id }" @click="editCustom(variable)">
|
||||
<div>
|
||||
<strong>{{ variable.syntax }}</strong>
|
||||
<span>{{ variable.category }} · {{ variable.visibility }}</span>
|
||||
<p>{{ variable.description || 'No description' }}</p>
|
||||
</div>
|
||||
<button class="ghost-button compact" type="button" @click.stop="$emit('insert', variable.syntax)">Insert</button>
|
||||
</article>
|
||||
<p v-if="!filteredCustom.length" class="widget-empty">No custom variables match the current filter.</p>
|
||||
</section>
|
||||
|
||||
<form class="custom-variable-form" @submit.prevent="saveCustom">
|
||||
<div class="form-grid">
|
||||
<label><span>Name</span><input v-model="form.name" required placeholder="CompanyName" /></label>
|
||||
<label><span>Category</span><input v-model="form.category" placeholder="Deployment" /></label>
|
||||
<label>
|
||||
<span>Value type</span>
|
||||
<select v-model="form.valueType">
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="json">JSON</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<select v-model="form.visibility">
|
||||
<option value="personal">Personal</option>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.visibility === 'group'">
|
||||
<span>Group</span>
|
||||
<select v-model="form.groupId">
|
||||
<option :value="null">Choose group</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle modal-toggle">
|
||||
<input v-model="form.sensitive" type="checkbox" />
|
||||
<span><strong>Sensitive value</strong><small>Hide the value in API/UI reads after save.</small></span>
|
||||
</label>
|
||||
<label class="full"><span>Description</span><textarea v-model="form.description" rows="3" placeholder="Used by install/uninstall scripts" /></label>
|
||||
<label class="full">
|
||||
<span>{{ form.id && form.sensitive ? 'Replace value' : 'Value' }}</span>
|
||||
<textarea v-model="form.value" rows="4" :placeholder="form.sensitive && form.id ? 'Leave blank to keep current encrypted value' : 'Contoso'" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="custom-variable-preview">
|
||||
<code>${{ form.name || 'VariableName' }}</code>
|
||||
<code>{{ previewToken }}</code>
|
||||
</div>
|
||||
<div class="form-actions modal-actions">
|
||||
<button v-if="form.id" class="ghost-button danger-text" type="button" @click="$emit('delete-custom', form.id)"><Trash2 :size="15" />Delete</button>
|
||||
<button class="ghost-button" type="button" @click="resetForm">Reset</button>
|
||||
<button class="primary-action" type="submit"><Save :size="16" />Save variable</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { BookOpenText, Plus, Save, Search, SlidersHorizontal, Trash2 } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
catalog: { type: Object, default: () => ({ builtIns: [], psadt: [], custom: [] }) },
|
||||
groups: { type: Array, default: () => [] }
|
||||
});
|
||||
|
||||
const tab = ref('catalog');
|
||||
const query = ref('');
|
||||
const activeCategory = ref('All');
|
||||
const form = reactive(emptyForm());
|
||||
|
||||
const catalogRows = computed(() => [
|
||||
...(props.catalog.builtIns || []),
|
||||
...(props.catalog.psadt || [])
|
||||
]);
|
||||
|
||||
const categories = computed(() => ['All', ...new Set(catalogRows.value.map((variable) => variable.category))]);
|
||||
|
||||
const filteredCatalog = computed(() => filterVariables(
|
||||
catalogRows.value.filter((variable) => activeCategory.value === 'All' || variable.category === activeCategory.value)
|
||||
));
|
||||
|
||||
const filteredCustom = computed(() => filterVariables(props.catalog.custom || []));
|
||||
const previewToken = computed(() => `{{${form.name || 'VariableName'}}}`);
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
tab.value = 'catalog';
|
||||
query.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(categories, (next) => {
|
||||
if (!next.includes(activeCategory.value)) activeCategory.value = 'All';
|
||||
});
|
||||
|
||||
function filterVariables(rows) {
|
||||
const needle = query.value.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
return rows.filter((variable) => [
|
||||
variable.name,
|
||||
variable.syntax,
|
||||
variable.token,
|
||||
variable.category,
|
||||
variable.description,
|
||||
variable.visibility
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
function categoryCount(category) {
|
||||
return category === 'All'
|
||||
? catalogRows.value.length
|
||||
: catalogRows.value.filter((variable) => variable.category === category).length;
|
||||
}
|
||||
|
||||
function editCustom(variable) {
|
||||
Object.assign(form, {
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
description: variable.description || '',
|
||||
category: variable.category || 'Custom',
|
||||
value: variable.sensitive ? '' : variable.value || '',
|
||||
valueType: variable.valueType || 'string',
|
||||
sensitive: Boolean(variable.sensitive),
|
||||
visibility: variable.visibility || 'personal',
|
||||
groupId: variable.groupId || null
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, emptyForm());
|
||||
}
|
||||
|
||||
function saveCustom() {
|
||||
const payload = { ...form };
|
||||
if (payload.id && payload.sensitive && !payload.value) delete payload.value;
|
||||
emit('save-custom', payload);
|
||||
}
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'Custom',
|
||||
value: '',
|
||||
valueType: 'string',
|
||||
sensitive: false,
|
||||
visibility: 'personal',
|
||||
groupId: null
|
||||
};
|
||||
}
|
||||
|
||||
const emit = defineEmits(['close', 'insert', 'save-custom', 'delete-custom']);
|
||||
</script>
|
||||
57
client/src/components/settings/ConfigSection.vue
Normal file
57
client/src/components/settings/ConfigSection.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<section class="settings-section">
|
||||
<button class="settings-section-toggle" type="button" :aria-expanded="!collapsed" @click="$emit('toggle')">
|
||||
<span><component :is="icon" :size="18" /></span>
|
||||
<div>
|
||||
<h3>{{ section.section }}</h3>
|
||||
<p>{{ description }}</p>
|
||||
</div>
|
||||
<span class="panel-toggle" :title="collapsed ? 'Expand section' : 'Collapse section'">
|
||||
<component :is="collapsed ? ChevronDown : ChevronUp" :size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!collapsed" class="settings-section-body">
|
||||
<div v-for="{ row, key } in section.rows" :key="key" class="setting-row">
|
||||
<div class="setting-copy">
|
||||
<span>{{ labelFor(key) }}</span>
|
||||
<small>{{ descriptionFor(key) }}</small>
|
||||
<code>{{ key }}</code>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button
|
||||
v-if="isBooleanSetting(row.value)"
|
||||
type="button"
|
||||
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
|
||||
:aria-pressed="normalizedBooleanSetting(row.value)"
|
||||
@click="$emit('toggle-boolean', row)"
|
||||
>
|
||||
<i></i>
|
||||
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
|
||||
</button>
|
||||
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
|
||||
<input v-else v-model="row.value" class="settings-input" :placeholder="placeholderFor(key)" />
|
||||
</div>
|
||||
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronDown, ChevronUp } from '@lucide/vue';
|
||||
|
||||
defineProps({
|
||||
section: { type: Object, required: true },
|
||||
icon: { type: [Object, Function], required: true },
|
||||
description: { type: String, required: true },
|
||||
collapsed: Boolean,
|
||||
labelFor: { type: Function, required: true },
|
||||
descriptionFor: { type: Function, required: true },
|
||||
placeholderFor: { type: Function, required: true },
|
||||
sourceLabel: { type: Function, required: true },
|
||||
isBooleanSetting: { type: Function, required: true },
|
||||
normalizedBooleanSetting: { type: Function, required: true }
|
||||
});
|
||||
|
||||
defineEmits(['toggle', 'toggle-boolean']);
|
||||
</script>
|
||||
186
client/src/components/settings/IntuneGraphConfig.vue
Normal file
186
client/src/components/settings/IntuneGraphConfig.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<article class="glass-panel form-panel intune-config-panel">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<span class="eyebrow">CONFIG / INTUNE</span>
|
||||
<h3>Microsoft Graph Intune</h3>
|
||||
<p>Configure Graph environments used for Intune app lookup, deployment linking, and install-status sync.</p>
|
||||
</div>
|
||||
<button class="primary-action compact" type="button" @click="openModal()">
|
||||
<CloudCog :size="15" />Add environment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="intune-config-grid">
|
||||
<section class="publisher-card">
|
||||
<strong>Environment routing</strong>
|
||||
<p>Graph credentials live here under Config / Intune. PSADT publishing screens consume these environments for tracking and sync.</p>
|
||||
<div class="config-stat-row">
|
||||
<span><b>{{ graphConnections.length }}</b><small>environments</small></span>
|
||||
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
|
||||
<span><b>{{ testedCount }}</b><small>tested</small></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="publisher-card graph-config-list">
|
||||
<strong>Graph environments</strong>
|
||||
<div v-for="connection in graphConnections" :key="connection.id" class="check-row">
|
||||
<b>{{ connection.name }}</b>
|
||||
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
|
||||
<span>
|
||||
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
|
||||
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
|
||||
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
:open="modalOpen"
|
||||
:title="form.id ? 'Update Graph environment' : 'New Graph environment'"
|
||||
eyebrow="CONFIG / INTUNE"
|
||||
description="Store the Entra app registration used for Microsoft Graph Intune access. Client secrets are encrypted by the API."
|
||||
wide
|
||||
@close="modalOpen = false"
|
||||
>
|
||||
<form class="form-grid" @submit.prevent="save">
|
||||
<label><span>Name</span><input v-model="form.name" required placeholder="Production Intune" /></label>
|
||||
<label><span>Cloud</span><select v-model="form.cloud" @change="applyCloudDefaults"><option value="global">Global</option><option value="gcc">GCC</option><option value="gcchigh">GCC High</option><option value="dod">DoD</option><option value="china">China</option><option value="custom">Custom</option></select></label>
|
||||
<label><span>Tenant ID</span><input v-model="form.tenantId" required placeholder="tenant-guid-or-domain" /></label>
|
||||
<label><span>Client ID</span><input v-model="form.clientId" required placeholder="app-registration-client-id" /></label>
|
||||
<label class="full"><span>Client secret</span><input v-model="form.clientSecret" :required="!form.id" type="password" placeholder="Stored encrypted; leave blank to keep existing secret" /></label>
|
||||
<label><span>Graph base URL</span><input v-model="form.graphBaseUrl" required /></label>
|
||||
<label><span>Authority host</span><input v-model="form.authorityHost" required /></label>
|
||||
<label><span>Default API version</span><select v-model="form.defaultApiVersion"><option value="v1.0">v1.0</option><option value="beta">beta</option></select></label>
|
||||
<label><span>Visibility</span><select v-model="form.visibility"><option value="personal">Personal</option><option value="shared">Shared</option><option value="group">Group</option></select></label>
|
||||
<label v-if="form.visibility === 'group'"><span>Group</span><select v-model="form.groupId"><option value="">Choose group</option><option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option></select></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.enabled" type="checkbox" /><span><strong>Enabled</strong><small>Allow this environment to be used by Intune tracking.</small></span></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.allowWrite" type="checkbox" /><span><strong>Allow tenant write-back</strong><small>Permit publishing/updating Win32 apps in this tenant. Admin only; leave off for read-only tracking.</small></span></label>
|
||||
<label class="toggle modal-toggle"><input v-model="form.requireApproval" type="checkbox" /><span><strong>Require approval</strong><small>Tenant-changing actions create a change request that an admin must approve before they run.</small></span></label>
|
||||
<template v-if="isAdmin">
|
||||
<label class="full"><span>Authorized publishers</span><select v-model="form.publisherIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Empty = any user with a write-enabled connection may publish.</small></label>
|
||||
<label class="full"><span>Authorized approvers</span><select v-model="form.approverIds" multiple size="3"><option v-for="u in users" :key="u.id" :value="u.id">{{ u.displayName }} ({{ u.email }})</option></select><small class="field-hint">Named approvers may approve change requests in addition to admins.</small></label>
|
||||
</template>
|
||||
<div class="form-actions modal-actions full">
|
||||
<button class="ghost-button" type="button" @click="modalOpen = false">Cancel</button>
|
||||
<button class="primary-action" type="submit"><Save :size="17" />Save environment</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { CloudCog, Save } from '@lucide/vue';
|
||||
import BaseModal from '../ui/BaseModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
graphConnections: { type: Array, default: () => [] },
|
||||
groups: { type: Array, default: () => [] },
|
||||
users: { type: Array, default: () => [] },
|
||||
isAdmin: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'delete', 'test']);
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const form = reactive(defaultForm());
|
||||
const enabledCount = computed(() => props.graphConnections.filter((connection) => connection.enabled).length);
|
||||
const testedCount = computed(() => props.graphConnections.filter((connection) => connection.lastTestStatus).length);
|
||||
|
||||
function defaultForm() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
tenantId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
cloud: 'global',
|
||||
graphBaseUrl: 'https://graph.microsoft.com',
|
||||
authorityHost: 'https://login.microsoftonline.com',
|
||||
defaultApiVersion: 'v1.0',
|
||||
enabled: true,
|
||||
allowWrite: false,
|
||||
requireApproval: false,
|
||||
publisherIds: [],
|
||||
approverIds: [],
|
||||
visibility: 'personal',
|
||||
groupId: ''
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(connection = null) {
|
||||
Object.assign(form, defaultForm(), connection || {});
|
||||
form.clientSecret = '';
|
||||
form.groupId = connection?.groupId || '';
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', { ...form, groupId: form.groupId || null });
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
function applyCloudDefaults() {
|
||||
const defaults = {
|
||||
global: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
|
||||
gcc: ['https://graph.microsoft.com', 'https://login.microsoftonline.com'],
|
||||
gcchigh: ['https://graph.microsoft.us', 'https://login.microsoftonline.us'],
|
||||
dod: ['https://dod-graph.microsoft.us', 'https://login.microsoftonline.us'],
|
||||
china: ['https://microsoftgraph.chinacloudapi.cn', 'https://login.chinacloudapi.cn']
|
||||
};
|
||||
const next = defaults[form.cloud];
|
||||
if (!next) return;
|
||||
form.graphBaseUrl = next[0];
|
||||
form.authorityHost = next[1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intune-config-panel {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.intune-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.65fr) minmax(0, 1.35fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.config-stat-row span {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.config-stat-row b,
|
||||
.config-stat-row small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.config-stat-row small {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.graph-config-list {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.intune-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
client/src/components/ui/BaseModal.vue
Normal file
42
client/src/components/ui/BaseModal.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal-backdrop" @mousedown.self="$emit('close')">
|
||||
<section
|
||||
:class="['form-modal glass-panel relative isolate shadow-2xl', { 'compact-modal': compact, 'wide-form-modal': wide }]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="titleId"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span v-if="eyebrow" class="eyebrow">{{ eyebrow }}</span>
|
||||
<h3 :id="titleId">{{ title }}</h3>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" aria-label="Close dialog" @click="$emit('close')">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: Boolean,
|
||||
title: { type: String, required: true },
|
||||
eyebrow: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
compact: Boolean,
|
||||
wide: Boolean
|
||||
});
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
const titleId = computed(() => `modal-title-${props.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'dialog'}`);
|
||||
</script>
|
||||
100
client/src/components/ui/ResourceTable.vue
Normal file
100
client/src/components/ui/ResourceTable.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<article class="glass-panel resource-table-panel">
|
||||
<div class="resource-toolbar">
|
||||
<slot name="toolbar-leading" />
|
||||
<label class="search-control">
|
||||
<Search :size="15" />
|
||||
<input :value="search" :placeholder="searchPlaceholder" @input="$emit('update:search', $event.target.value)" />
|
||||
<button v-if="search" class="search-clear" type="button" aria-label="Clear search" @click="$emit('update:search', '')">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="table-density-select">
|
||||
<span>Rows</span>
|
||||
<select :value="pageSize" @change="$emit('update:pageSize', Number($event.target.value))">
|
||||
<option :value="8">8</option>
|
||||
<option :value="12">12</option>
|
||||
<option :value="20">20</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="toolbar-count">{{ total }} {{ itemLabel }}</span>
|
||||
<slot name="toolbar-actions" />
|
||||
</div>
|
||||
<div v-if="search || sort" class="resource-filter-strip">
|
||||
<span v-if="search">Search: <strong>{{ search }}</strong></span>
|
||||
<span v-if="sort">Sort: <strong>{{ activeSortLabel }}</strong> {{ direction }}</span>
|
||||
</div>
|
||||
<div v-if="rows.length" class="data-table-wrap">
|
||||
<table class="data-table resource-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in columns" :key="column.key">
|
||||
<button v-if="column.sortable !== false" type="button" :aria-sort="sortAria(column.key)" @click="$emit('sort', column.key)">
|
||||
{{ column.label }}
|
||||
<component :is="sortIcon(column.key)" :size="13" />
|
||||
</button>
|
||||
<template v-else>{{ column.label }}</template>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td v-for="column in columns" :key="column.key" :class="column.class">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
|
||||
{{ row[column.key] || '-' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="resource-empty-shell">
|
||||
<slot name="empty">
|
||||
<div class="widget-empty">{{ emptyText }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button class="ghost-button compact" :disabled="page <= 1" aria-label="Previous page" @click="$emit('update:page', page - 1)">
|
||||
<ChevronLeft :size="15" />Previous
|
||||
</button>
|
||||
<span>Page {{ Math.min(page, pageCount) }} of {{ pageCount }}</span>
|
||||
<button class="ghost-button compact" :disabled="page >= pageCount" aria-label="Next page" @click="$emit('update:page', page + 1)">
|
||||
Next<ChevronRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ArrowDownAZ, ArrowUpAZ, ChevronsUpDown, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
total: { type: Number, required: true },
|
||||
search: { type: String, default: '' },
|
||||
page: { type: Number, default: 1 },
|
||||
pageCount: { type: Number, default: 1 },
|
||||
pageSize: { type: Number, default: 8 },
|
||||
sort: { type: String, default: '' },
|
||||
direction: { type: String, default: 'asc' },
|
||||
searchPlaceholder: { type: String, default: 'Search...' },
|
||||
itemLabel: { type: String, default: 'items' },
|
||||
emptyText: { type: String, default: 'No rows match the current filters.' }
|
||||
});
|
||||
|
||||
defineEmits(['update:search', 'update:page', 'update:pageSize', 'sort']);
|
||||
|
||||
const activeSortLabel = computed(() => props.columns.find((column) => column.key === props.sort)?.label || props.sort);
|
||||
|
||||
function sortIcon(key) {
|
||||
if (props.sort !== key) return ChevronsUpDown;
|
||||
return props.direction === 'asc' ? ArrowUpAZ : ArrowDownAZ;
|
||||
}
|
||||
|
||||
function sortAria(key) {
|
||||
if (props.sort !== key) return 'none';
|
||||
return props.direction === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
</script>
|
||||
281
client/src/editor/powershellMonaco.js
Normal file
281
client/src/editor/powershellMonaco.js
Normal 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
6
client/src/main.js
Normal 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');
|
||||
84
client/src/plugins/vuetify.js
Normal file
84
client/src/plugins/vuetify.js
Normal 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
6914
client/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
823
client/src/views/AssetsView.vue
Normal file
823
client/src/views/AssetsView.vue
Normal 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>
|
||||
105
client/src/views/DashboardView.vue
Normal file
105
client/src/views/DashboardView.vue
Normal 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>
|
||||
20
client/src/views/ErrorView.vue
Normal file
20
client/src/views/ErrorView.vue
Normal 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>
|
||||
80
client/src/views/LoginView.vue
Normal file
80
client/src/views/LoginView.vue
Normal 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>
|
||||
1762
client/src/views/PsadtView.vue
Normal file
1762
client/src/views/PsadtView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user