This commit is contained in:
2026-06-25 20:57:09 -05:00
parent 552b86c323
commit e47d09d7d6
35 changed files with 4507 additions and 101 deletions

View File

@@ -8,16 +8,46 @@
</button>
</div>
<nav class="metismenu">
<div class="menu-label">Operations</div>
<button v-for="item in primaryNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<div class="menu-label">Administration</div>
<button v-for="item in adminNav" :key="item.id" :class="{ active: view === item.id }" :title="collapsed ? item.label : undefined" @click="$emit('update:view', item.id)">
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<template v-for="item in nav" :key="item.id">
<button
v-if="!item.children?.length"
:class="['nav-root-button', { active: view === item.id }]"
:title="collapsed ? item.label : undefined"
type="button"
@click="$emit('update:view', item.id)"
>
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
</button>
<div v-else :class="['nav-group', { open: isGroupOpen(item.id), active: groupContainsView(item, view) }]">
<button
class="nav-group-toggle"
:aria-expanded="isGroupOpen(item.id)"
:title="collapsed ? item.label : undefined"
type="button"
@click="toggleGroup(item.id)"
>
<span class="parent-icon"><component :is="item.icon" :size="18" /></span>
<span class="menu-title">{{ item.label }}</span>
<ChevronDown class="group-chevron" :size="15" />
</button>
<div v-if="isGroupOpen(item.id)" class="nav-submenu">
<button
v-for="child in item.children"
:key="child.id"
:class="['nav-child-button', { active: view === child.id }]"
:title="collapsed ? child.label : undefined"
type="button"
@click="$emit('update:view', child.id)"
>
<span class="child-rail" aria-hidden="true"></span>
<span class="parent-icon child-icon"><component :is="child.icon" :size="16" /></span>
<span class="menu-title">{{ child.label }}</span>
</button>
</div>
</div>
</template>
</nav>
<div class="sidebar-user">
<span class="user-orb">{{ initials }}</span>
@@ -30,8 +60,8 @@
</template>
<script setup>
import { computed } from 'vue';
import { PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { ChevronDown, PanelLeftClose, PanelLeftOpen } from '@lucide/vue';
const props = defineProps({
nav: { type: Array, required: true },
@@ -42,8 +72,34 @@ const props = defineProps({
defineEmits(['update:view', 'toggle-collapse']);
const accountNav = ['profile', 'users', 'admin'];
const primaryNav = computed(() => props.nav.filter((item) => !accountNav.includes(item.id)));
const adminNav = computed(() => props.nav.filter((item) => accountNav.includes(item.id)));
const collapsedGroups = ref(loadCollapsedGroups());
const initials = computed(() => (props.user?.displayName || 'PS').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase());
watch(collapsedGroups, (value) => {
localStorage.setItem('poshmanager.navGroupsCollapsed', JSON.stringify([...value]));
}, { deep: true });
function loadCollapsedGroups() {
try {
const parsed = JSON.parse(localStorage.getItem('poshmanager.navGroupsCollapsed') || '[]');
return new Set(Array.isArray(parsed) ? parsed : []);
} catch {
return new Set();
}
}
function isGroupOpen(groupId) {
return !collapsedGroups.value.has(groupId);
}
function toggleGroup(groupId) {
const next = new Set(collapsedGroups.value);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
collapsedGroups.value = next;
}
function groupContainsView(group, view) {
return group.children?.some((child) => child.id === view);
}
</script>

View File

@@ -23,13 +23,14 @@
type="button"
:class="['switch-control', { active: normalizedBooleanSetting(row.value) }]"
:aria-pressed="normalizedBooleanSetting(row.value)"
:disabled="isLockedSetting(key, row)"
@click="$emit('toggle-boolean', row)"
>
<i></i>
<strong>{{ normalizedBooleanSetting(row.value) ? 'Enabled' : 'Disabled' }}</strong>
</button>
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)"></textarea>
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" />
<textarea v-else-if="key === 'trusted_origins'" v-model="row.value" class="settings-input settings-textarea" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)"></textarea>
<input v-else v-model="row.value" :type="isSecretSetting(key) ? 'password' : 'text'" class="settings-input" :placeholder="placeholderFor(key)" :disabled="isLockedSetting(key, row)" />
</div>
<span :class="['setting-source', row.source]">{{ sourceLabel(row.source) }}</span>
</div>
@@ -51,7 +52,8 @@ defineProps({
isSecretSetting: { type: Function, default: () => false },
sourceLabel: { type: Function, required: true },
isBooleanSetting: { type: Function, required: true },
normalizedBooleanSetting: { type: Function, required: true }
normalizedBooleanSetting: { type: Function, required: true },
isLockedSetting: { type: Function, default: () => false }
});
defineEmits(['toggle', 'toggle-boolean']);

View File

@@ -11,35 +11,61 @@
</button>
</div>
<div class="intune-config-grid">
<section class="publisher-card">
<strong>Inventory sources</strong>
<div class="vmware-config-grid">
<section class="publisher-card vmware-summary-card">
<div class="vmware-card-heading">
<span class="vmware-card-icon"><i class="mdi mdi-view-grid-outline" aria-hidden="true"></i></span>
<div>
<strong>Inventory sources</strong>
<small>Credential-backed VMware inventory and power-state checks.</small>
</div>
</div>
<p>vCenter and standalone ESXi entries use Credential Vault entries for VMware API authentication, inventory import, and power-state checks.</p>
<div class="config-stat-row">
<span><b>{{ connections.length }}</b><small>systems</small></span>
<span><b>{{ enabledCount }}</b><small>enabled</small></span>
<div class="vmware-stat-grid" aria-label="VMware connection summary">
<span><b>{{ connections.length }}</b><small>Systems</small></span>
<span><b>{{ enabledCount }}</b><small>Enabled</small></span>
<span><b>{{ vcenterCount }}</b><small>vCenter</small></span>
<span><b>{{ standaloneCount }}</b><small>hosts</small></span>
<span><b>{{ testedCount }}</b><small>tested</small></span>
<span><b>{{ standaloneCount }}</b><small>ESXi Hosts</small></span>
<span><b>{{ testedCount }}</b><small>Tested</small></span>
</div>
</section>
<section class="publisher-card graph-config-list">
<strong>VMware systems</strong>
<div v-for="connection in connections" :key="connection.id" class="check-row">
<b>{{ connection.name }}</b>
<small>{{ connection.baseUrl }} - {{ connectionTypeLabel(connection) }} - {{ connection.credentialName || connection.username || 'No vault credential' }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">
<section class="publisher-card vmware-system-list">
<div class="vmware-list-heading">
<strong>VMware systems</strong>
<small>{{ connections.length ? `${connections.length} configured` : 'No systems configured' }}</small>
</div>
<div v-for="connection in connections" :key="connection.id" class="vmware-system-row">
<div class="vmware-system-lead" aria-hidden="true">
<i :class="connection.targetType === 'host' ? 'mdi mdi-server' : 'mdi mdi-server-network'"></i>
</div>
<div class="vmware-system-main">
<div class="vmware-system-title">
<b>{{ connection.name }}</b>
<span :class="['status-pill', connection.enabled ? 'success' : 'skipped']">{{ connection.enabled ? 'Enabled' : 'Disabled' }}</span>
<span :class="['status-pill', connectionStatusTone(connection)]">{{ connection.lastTestStatus || 'Untested' }}</span>
</div>
<div class="vmware-system-meta">
<span><i class="mdi mdi-source-branch" aria-hidden="true"></i>{{ connectionTypeLabel(connection) }}</span>
<span><i class="mdi mdi-link-variant" aria-hidden="true"></i>{{ connection.baseUrl }}</span>
<span><i class="mdi mdi-key-variant" aria-hidden="true"></i>{{ credentialLabel(connection) }}</span>
<span v-if="connection.lastTestAt"><i class="mdi mdi-clock-outline" aria-hidden="true"></i>{{ formatDate(connection.lastTestAt) }}</span>
</div>
<small v-if="connection.lastTestMessage" class="vmware-test-message">{{ connection.lastTestMessage }}</small>
</div>
<div class="vmware-system-actions">
<button class="ghost-button icon-text compact" type="button" :aria-label="`Edit ${connection.name}`" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>Edit
</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">
<button class="ghost-button icon-text compact" type="button" :aria-label="`Test ${connection.name}`" @click="$emit('test', connection.id)">
<i class="mdi mdi-lan-check" aria-hidden="true"></i>Test
</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">
<button class="ghost-button icon-text compact danger-text" type="button" :aria-label="`Delete ${connection.name}`" @click="$emit('delete', connection.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>Delete
</button>
</span>
</div>
</div>
<p v-if="!connections.length" class="widget-empty">No saved VMware systems configured. The legacy env/settings default can still be used by vCenter import.</p>
</section>
@@ -165,4 +191,272 @@ function save() {
function connectionTypeLabel(connection) {
return connection.targetType === 'host' ? 'Standalone ESXi host' : 'vCenter inventory';
}
function credentialLabel(connection) {
return connection.credentialName || connection.username || 'No vault credential';
}
function connectionStatusTone(connection) {
const status = String(connection.lastTestStatus || '').toLowerCase();
if (!status) return 'neutral';
if (status === 'success' || status === 'ok' || status === 'passed') return 'success';
if (status === 'failed' || status === 'error') return 'failed';
return 'warning';
}
function formatDate(value) {
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'short',
timeStyle: 'short'
}).format(new Date(value));
} catch {
return value;
}
}
</script>
<style scoped>
.vmware-config-grid {
display: grid;
grid-template-columns: minmax(280px, .72fr) minmax(0, 1.28fr);
gap: 16px;
align-items: stretch;
}
.vmware-summary-card,
.vmware-system-list {
min-width: 0;
}
.vmware-card-heading,
.vmware-list-heading,
.vmware-system-title,
.vmware-system-meta,
.vmware-system-actions {
display: flex;
align-items: center;
}
.vmware-card-heading {
gap: 12px;
margin-bottom: 10px;
}
.vmware-card-heading strong,
.vmware-list-heading strong {
display: block;
color: rgba(247, 250, 255, .96);
}
.vmware-card-heading small,
.vmware-list-heading small {
color: rgba(213, 225, 246, .62);
font-size: .76rem;
}
.vmware-card-icon,
.vmware-system-lead {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgba(88, 221, 255, .2);
background:
linear-gradient(135deg, rgba(88, 221, 255, .16), rgba(154, 107, 255, .1)),
rgba(10, 16, 34, .72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 14px 30px rgba(0, 0, 0, .18);
color: #8be0ff;
}
.vmware-card-icon {
width: 42px;
height: 42px;
border-radius: 14px;
}
.vmware-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.vmware-stat-grid span {
min-width: 0;
padding: 13px 14px;
border: 1px solid rgba(255, 255, 255, .095);
border-radius: 14px;
background:
linear-gradient(135deg, rgba(88, 221, 255, .08), rgba(154, 107, 255, .055)),
rgba(255, 255, 255, .045);
}
.vmware-stat-grid b,
.vmware-stat-grid small {
display: block;
}
.vmware-stat-grid b {
color: #f7faff;
font-size: 1.35rem;
line-height: 1.1;
}
.vmware-stat-grid small {
margin-top: 4px;
color: rgba(213, 225, 246, .58);
font-size: .68rem;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}
.vmware-list-heading {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.vmware-system-list {
display: grid;
gap: 10px;
align-content: start;
}
.vmware-system-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
min-width: 0;
padding: 14px;
border: 1px solid rgba(255, 255, 255, .095);
border-radius: 16px;
background:
linear-gradient(135deg, rgba(11, 21, 43, .88), rgba(26, 24, 58, .74)),
rgba(4, 9, 22, .42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .055);
}
.vmware-system-lead {
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 1.1rem;
}
.vmware-system-main {
min-width: 0;
}
.vmware-system-title {
flex-wrap: wrap;
gap: 8px;
}
.vmware-system-title b {
min-width: 0;
max-width: min(34rem, 100%);
overflow: hidden;
color: #f7faff;
font-size: .96rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-system-meta {
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 8px;
color: rgba(213, 225, 246, .68);
font-size: .76rem;
}
.vmware-system-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.vmware-system-meta i {
flex: 0 0 auto;
color: #8be0ff;
opacity: .82;
}
.vmware-system-meta span:nth-child(2) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-test-message {
display: block;
margin-top: 8px;
overflow: hidden;
color: rgba(255, 196, 204, .72);
font-size: .73rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.vmware-system-actions {
justify-content: flex-end;
gap: 8px;
}
.vmware-system-actions .ghost-button {
min-height: 40px;
white-space: nowrap;
}
@media (max-width: 1180px) {
.vmware-config-grid {
grid-template-columns: 1fr;
}
.vmware-stat-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.intune-config-panel :deep(.section-title) {
align-items: stretch;
}
.intune-config-panel :deep(.section-title .primary-action) {
width: 100%;
justify-content: center;
}
.vmware-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vmware-system-row {
grid-template-columns: 40px minmax(0, 1fr);
}
.vmware-system-actions {
grid-column: 1 / -1;
justify-content: stretch;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.vmware-system-actions .ghost-button {
justify-content: center;
}
}
@media (max-width: 460px) {
.vmware-stat-grid,
.vmware-system-actions {
grid-template-columns: 1fr;
}
}
</style>