Consolidate settings into 8 hubs and fix security vulnerabilities
Settings Consolidation (37+ pages to 8 hubs): - Asset Type Hub: Unified type management with plugin descriptors - Model Catalog Hub: Vendors, types, models, operating systems - Asset Configuration Hub: Statuses, relationships, fields, identifiers - Security Hub: Authentication, tokens, users, audit - Site Configuration Hub: Site settings, floor map, branding, imports - Integrations Hub: ServiceNow, Zabbix, Dell warranty - Locations Hub: Locations, business units, employees - Network Infrastructure Hub: VLANs, subnets Plugin Descriptor Pattern: - Add get_asset_type_descriptor hook to BasePlugin - Implement descriptors in 6 plugins (printers, computers, machines, network, measuringtools, locations) - Add /api/pluginui/asset-type-descriptors and core-type-descriptors endpoints - Generic SubtypesTab component renders CRUD UI from descriptor schema Security Fixes: - Fix stored XSS via javascript: URIs in Models and Support Teams - Add URL scheme validation (allow only http/https) in create/update endpoints - Fix plugin contract violations in endpoints plugin (use shopdb.api imports) - Add missing requiresAdmin guard to printer model supplies route - Add user-facing error messages to API token load failures Integration Improvements: - Add persistent tab navigation to ServiceNow, Zabbix, and Dell Warranty settings - Standardize integration settings UI across all three pages - Fix tab visibility when navigating between integrations Tested with 94-agent orchestrated review for ADR compliance and security.
This commit is contained in:
@@ -36,40 +36,158 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/vendors',
|
||||
name: 'vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue'),
|
||||
path: 'settings/catalog',
|
||||
name: 'model-catalog',
|
||||
component: () => import('../../views/settings/ModelCatalogHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'vendors',
|
||||
name: 'model-catalog-vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'types',
|
||||
name: 'model-catalog-types',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'model-catalog-models',
|
||||
component: () => import('../../views/settings/ModelsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'operatingsystems',
|
||||
name: 'model-catalog-os',
|
||||
component: () => import('../../.plugins-staged/computers/views/OperatingSystemsList.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/assetconfig',
|
||||
name: 'asset-config',
|
||||
component: () => import('../../views/settings/AssetConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'statuses',
|
||||
name: 'asset-config-statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'relationships',
|
||||
name: 'asset-config-relationships',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'fields',
|
||||
name: 'asset-config-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'identifiers',
|
||||
name: 'asset-config-identifiers',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/security',
|
||||
name: 'security',
|
||||
component: () => import('../../views/settings/SecurityHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'authentication',
|
||||
name: 'security-authentication',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'tokens',
|
||||
name: 'security-tokens',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'security-users',
|
||||
component: () => import('../../views/settings/UsersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'security-audit',
|
||||
component: () => import('../../views/settings/AuditSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'auditlogs',
|
||||
name: 'security-auditlogs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/siteconfig',
|
||||
name: 'site-config',
|
||||
component: () => import('../../views/settings/SiteConfigHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'site',
|
||||
name: 'site-config-site',
|
||||
component: () => import('../../views/settings/SiteSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'floormap',
|
||||
name: 'site-config-floormap',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
name: 'site-config-branding',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue')
|
||||
},
|
||||
{
|
||||
path: 'imports',
|
||||
name: 'site-config-imports',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('../../views/settings/IntegrationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/modeltypes',
|
||||
name: 'modeltypes',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue'),
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
name: 'locations-hub',
|
||||
component: () => import('../../views/settings/LocationsHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'locations-hub-locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'businessunits',
|
||||
name: 'locations-hub-businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings/statuses',
|
||||
name: 'statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/models',
|
||||
name: 'models',
|
||||
component: () => import('../../views/settings/ModelsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/businessunits',
|
||||
name: 'businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
path: 'settings/network',
|
||||
name: 'network-hub',
|
||||
component: () => import('../../views/settings/NetworkHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: []
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
@@ -83,18 +201,6 @@ export default [
|
||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/site',
|
||||
name: 'site-settings',
|
||||
component: () => import('../../views/settings/SiteSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/relationshiptypes',
|
||||
name: 'relationship-types',
|
||||
component: () => import('../../views/settings/RelationshipTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locationtypes',
|
||||
name: 'location-types',
|
||||
@@ -103,15 +209,22 @@ export default [
|
||||
},
|
||||
{
|
||||
path: 'settings/assettypes',
|
||||
name: 'asset-types',
|
||||
component: () => import('../../views/settings/AssetTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/customfields',
|
||||
name: 'custom-fields',
|
||||
component: () => import('../../views/settings/CustomFieldsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
component: () => import('../../views/settings/AssetTypeHub.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'asset-types-hub',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
|
||||
},
|
||||
{
|
||||
path: ':assettypeid/subtypes',
|
||||
name: 'asset-types-hub-subtypes',
|
||||
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||
props: route => ({ assettypeid: route.params.assettypeid })
|
||||
},
|
||||
]
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
@@ -138,24 +251,6 @@ export default [
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/branding',
|
||||
name: 'branding-settings',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/importsiteassets',
|
||||
name: 'import-site-assets',
|
||||
component: () => import('../../views/settings/ImportSiteAssets.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
@@ -168,60 +263,18 @@ export default [
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/audit',
|
||||
name: 'audit-settings',
|
||||
component: () => import('../../views/settings/AuditSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/authentication',
|
||||
name: 'authentication-settings',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/apitokens',
|
||||
name: 'apitokens-settings',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/floormap',
|
||||
name: 'floor-map-settings',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/auditlogs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/users',
|
||||
name: 'users',
|
||||
component: () => import('../../views/settings/UsersList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/shopdbbackups',
|
||||
name: 'shopdbbackups',
|
||||
|
||||
@@ -327,6 +327,11 @@ async function loadData() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading API tokens:', err)
|
||||
if (err.response?.data?.error) {
|
||||
toast.error(err.response.data.error)
|
||||
} else {
|
||||
toast.error('Failed to load tokens or permissions. Please refresh the page.')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
111
frontend/src/views/settings/AssetConfigHub.vue
Normal file
111
frontend/src/views/settings/AssetConfigHub.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="asset-config-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Asset Configuration</h2>
|
||||
<p class="description">Configure statuses, relationships, custom fields, and identifiers</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-statuses' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-statuses' }"
|
||||
>
|
||||
Statuses
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-relationships' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-relationships' }"
|
||||
>
|
||||
Relationship Types
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-fields' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-fields' }"
|
||||
>
|
||||
Custom Fields
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'asset-config-identifiers' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-config-identifiers' }"
|
||||
>
|
||||
Identifiers
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'asset-config') {
|
||||
router.replace({ name: 'asset-config-statuses' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-config-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
306
frontend/src/views/settings/AssetTypeHub.vue
Normal file
306
frontend/src/views/settings/AssetTypeHub.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="asset-type-hub">
|
||||
<div class="hub-rail">
|
||||
<div class="rail-header">
|
||||
<h3>Types</h3>
|
||||
</div>
|
||||
<div v-if="loading" class="rail-loading">Loading...</div>
|
||||
<div v-else class="rail-list">
|
||||
<!-- Asset Types Section -->
|
||||
<div v-if="assetTypeItems.length > 0" class="rail-section">
|
||||
<div class="section-header">Asset Types</div>
|
||||
<router-link
|
||||
v-for="item in assetTypeItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
|
||||
class="rail-item"
|
||||
:class="{ active: item.id === currentTypeId }"
|
||||
>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Other Types Section -->
|
||||
<div v-if="otherTypeItems.length > 0" class="rail-section">
|
||||
<div class="section-header">Other Types</div>
|
||||
<router-link
|
||||
v-for="item in otherTypeItems"
|
||||
:key="item.id"
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: item.id } }"
|
||||
class="rail-item"
|
||||
:class="{ active: item.id === currentTypeId }"
|
||||
>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hub-content">
|
||||
<div v-if="currentDescriptor" class="content-header">
|
||||
<h2>{{ currentDescriptor.type_name_plural || capitalize(currentDescriptor.assettype) }}</h2>
|
||||
<p v-if="currentDescriptor.description" class="description">
|
||||
{{ currentDescriptor.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDescriptor" class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'asset-types-hub-subtypes', params: { assettypeid: currentTypeId } }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'asset-types-hub-subtypes' }"
|
||||
>
|
||||
{{ currentDescriptor.type_name_plural || 'Subtypes' }}
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-for="tab in optionalTabs"
|
||||
:key="tab"
|
||||
:to="{ name: `asset-types-hub-${tab}`, params: { assettypeid: currentTypeId } }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === `asset-types-hub-${tab}` }"
|
||||
>
|
||||
{{ capitalize(tab) }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view :key="$route.fullPath" :descriptor="currentDescriptor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const assetTypes = ref([])
|
||||
const allDescriptors = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const assetTypeItems = computed(() => {
|
||||
const items = []
|
||||
for (const at of assetTypes.value) {
|
||||
const desc = allDescriptors.value.find(d => d.assettype === at.assettype)
|
||||
if (desc) {
|
||||
items.push({
|
||||
id: at.assettypeid,
|
||||
label: capitalize(at.assettype),
|
||||
descriptor: desc
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const otherTypeItems = computed(() => {
|
||||
return allDescriptors.value
|
||||
.filter(d => d.group === 'other')
|
||||
.map((d, index) => ({
|
||||
id: `other-${index}`,
|
||||
label: d.type_name_plural || d.category,
|
||||
descriptor: d
|
||||
}))
|
||||
})
|
||||
|
||||
const currentTypeId = computed(() => route.params.assettypeid || null)
|
||||
|
||||
const currentDescriptor = computed(() => {
|
||||
const typeId = currentTypeId.value
|
||||
if (!typeId) return null
|
||||
|
||||
if (typeId.toString().startsWith('other-')) {
|
||||
const index = parseInt(typeId.replace('other-', ''))
|
||||
return allDescriptors.value.filter(d => d.group === 'other')[index]
|
||||
} else {
|
||||
const at = assetTypes.value.find(t => t.assettypeid === parseInt(typeId))
|
||||
if (!at) return null
|
||||
return allDescriptors.value.find(d => d.assettype === at.assettype)
|
||||
}
|
||||
})
|
||||
|
||||
const optionalTabs = computed(() => currentDescriptor.value?.optional_tabs || [])
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
if (!currentTypeId.value && assetTypeItems.value.length > 0) {
|
||||
router.replace({
|
||||
name: 'asset-types-hub-subtypes',
|
||||
params: { assettypeid: assetTypeItems.value[0].id }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.params.assettypeid, (newId) => {
|
||||
if (!newId && assetTypeItems.value.length > 0) {
|
||||
router.replace({
|
||||
name: 'asset-types-hub-subtypes',
|
||||
params: { assettypeid: assetTypeItems.value[0].id }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [typesRes, assetDescRes, coreDescRes] = await Promise.all([
|
||||
api.get('/assets/types', { params: { perpage: 100, active: true } }),
|
||||
api.get('/pluginui/asset-type-descriptors'),
|
||||
api.get('/pluginui/core-type-descriptors')
|
||||
])
|
||||
assetTypes.value = typesRes.data.data || []
|
||||
const assetDescs = assetDescRes.data.data || []
|
||||
const coreDescs = coreDescRes.data.data || []
|
||||
allDescriptors.value = [...assetDescs, ...coreDescs]
|
||||
} catch (err) {
|
||||
console.error('Error loading type hub data:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.asset-type-hub {
|
||||
display: flex;
|
||||
height: calc(100vh - 120px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hub-rail {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rail-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rail-loading {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rail-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rail-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.rail-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.rail-item.active {
|
||||
background: var(--bg-primary);
|
||||
border-left-color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hub-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0 2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
112
frontend/src/views/settings/IntegrationsHub.vue
Normal file
112
frontend/src/views/settings/IntegrationsHub.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="integrations-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Integrations</h2>
|
||||
<p class="description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="hub-notice">
|
||||
<p>Click a tab above to configure integrations</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
// Auto-redirect to first tab if on hub landing page
|
||||
if (route.name === 'integrations') {
|
||||
router.replace({ name: 'servicenow-settings' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.integrations-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-notice {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.hub-notice p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/views/settings/IntegrationsLayout.vue
Normal file
90
frontend/src/views/settings/IntegrationsLayout.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="integrations-layout">
|
||||
<div class="hub-header">
|
||||
<h2>Integrations</h2>
|
||||
<p class="description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.integrations-layout {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.content {
|
||||
/* Content styled by child pages */
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/views/settings/LocationsHub.vue
Normal file
104
frontend/src/views/settings/LocationsHub.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="locations-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Locations & Organization</h2>
|
||||
<p class="description">Manage physical locations, business units, and employee directory</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'locations-hub-locations' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'locations-hub-locations' }"
|
||||
>
|
||||
Locations
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'locations-hub-businessunits' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'locations-hub-businessunits' }"
|
||||
>
|
||||
Business Units
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'employee-directory' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'employee-directory' || $route.name === 'locations-hub-employees' }"
|
||||
>
|
||||
Employee Directory
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'locations-hub') {
|
||||
router.replace({ name: 'locations-hub-locations' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.locations-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
112
frontend/src/views/settings/ModelCatalogHub.vue
Normal file
112
frontend/src/views/settings/ModelCatalogHub.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="model-catalog-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Model Catalog</h2>
|
||||
<p class="description">Manage vendors, model types, equipment models, and operating systems</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-vendors' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-vendors' }"
|
||||
>
|
||||
Vendors
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-types' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-types' }"
|
||||
>
|
||||
Model Types
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-models' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-models' }"
|
||||
>
|
||||
Models
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'model-catalog-os' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'model-catalog-os' }"
|
||||
>
|
||||
Operating Systems
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
// Redirect to vendors tab if no child route
|
||||
if (route.name === 'model-catalog') {
|
||||
router.replace({ name: 'model-catalog-vendors' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.model-catalog-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content is styled by child components */
|
||||
}
|
||||
</style>
|
||||
104
frontend/src/views/settings/NetworkHub.vue
Normal file
104
frontend/src/views/settings/NetworkHub.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="network-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Network Infrastructure</h2>
|
||||
<p class="description">Manage VLANs and IP subnets</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'vlans' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'vlans' }"
|
||||
>
|
||||
VLANs
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'subnets' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'subnets' }"
|
||||
>
|
||||
Subnets
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="hub-notice">
|
||||
<p>Click a tab above to manage network infrastructure</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'network-hub') {
|
||||
router.replace({ name: 'vlans' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.hub-notice {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.hub-notice p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/views/settings/SecurityHub.vue
Normal file
118
frontend/src/views/settings/SecurityHub.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="security-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Access & Security</h2>
|
||||
<p class="description">Manage authentication, API tokens, users, roles, and audit logs</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'security-authentication' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-authentication' }"
|
||||
>
|
||||
Authentication
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-tokens' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-tokens' }"
|
||||
>
|
||||
API Tokens
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-users' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-users' }"
|
||||
>
|
||||
Users & Roles
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-audit' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-audit' }"
|
||||
>
|
||||
Audit Settings
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'security-auditlogs' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'security-auditlogs' }"
|
||||
>
|
||||
Audit Logs
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'security') {
|
||||
router.replace({ name: 'security-authentication' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.security-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>ServiceNow</h2>
|
||||
<h2>Integrations</h2>
|
||||
<p class="header-description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="integration-tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
@@ -103,3 +128,39 @@ const {
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-description {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.integration-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.integration-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
111
frontend/src/views/settings/SiteConfigHub.vue
Normal file
111
frontend/src/views/settings/SiteConfigHub.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="site-config-hub">
|
||||
<div class="hub-header">
|
||||
<h2>Site Configuration</h2>
|
||||
<p class="description">Configure site identity, facility settings, branding, and asset imports</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<router-link
|
||||
:to="{ name: 'site-config-site' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-site' }"
|
||||
>
|
||||
Site & Facility
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-floormap' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-floormap' }"
|
||||
>
|
||||
Floor Map
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-branding' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-branding' }"
|
||||
>
|
||||
Branding
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'site-config-imports' }"
|
||||
class="tab"
|
||||
:class="{ active: $route.name === 'site-config-imports' }"
|
||||
>
|
||||
Import Site Assets
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (route.name === 'site-config') {
|
||||
router.replace({ name: 'site-config-site' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-config-hub {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
/* Content styled by child components */
|
||||
}
|
||||
</style>
|
||||
303
frontend/src/views/settings/asset-types/SubtypesTab.vue
Normal file
303
frontend/src/views/settings/asset-types/SubtypesTab.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="subtypes-tab">
|
||||
<div class="tab-header">
|
||||
<h3>{{ typeLabel }} Types</h3>
|
||||
<div class="actions">
|
||||
<label class="show-inactive">
|
||||
<input type="checkbox" v-model="showInactive" />
|
||||
Show inactive
|
||||
</label>
|
||||
<button v-if="descriptor" class="btn btn-primary" @click="openModal()">
|
||||
+ Add {{ typeLabel }} Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="!descriptor" class="empty-state">
|
||||
<p>No descriptor found for this asset type.</p>
|
||||
<p class="hint">The plugin may not have implemented the get_asset_type_descriptor hook yet.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ capitalize(descriptor.subtype_label_field) }}</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in visibleItems" :key="item[descriptor.subtype_id_field]">
|
||||
<td>{{ item[descriptor.subtype_label_field] }}</td>
|
||||
<td class="cell-truncate" :title="item[descriptor.subtype_description_field]">
|
||||
{{ item[descriptor.subtype_description_field] || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="item[descriptor.subtype_color_field]"
|
||||
class="color-badge"
|
||||
:style="{ backgroundColor: item[descriptor.subtype_color_field] }"
|
||||
>
|
||||
{{ item[descriptor.subtype_color_field] }}
|
||||
</span>
|
||||
<span v-else class="text-light">auto</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span v-if="item.isactive === false" class="badge badge-secondary" style="margin-right:6px;">
|
||||
inactive
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No {{ typeLabel }} types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit' : 'Add' }} {{ typeLabel }} Type</h3>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ capitalize(descriptor.subtype_label_field) }} *</label>
|
||||
<input
|
||||
v-model="form[descriptor.subtype_label_field]"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="form[descriptor.subtype_description_field]"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form[descriptor.subtype_color_field]" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.isactive" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const props = defineProps({
|
||||
assettypeid: { type: [Number, String], required: true },
|
||||
descriptor: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
const typeLabel = computed(() =>
|
||||
props.descriptor?.type_name || capitalize(props.descriptor?.assettype) || 'Type'
|
||||
)
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
showInactive.value
|
||||
? items.value
|
||||
: items.value.filter(x => x.isactive !== false)
|
||||
)
|
||||
|
||||
onMounted(loadData)
|
||||
watch(() => props.descriptor, loadData)
|
||||
|
||||
async function loadData() {
|
||||
if (!props.descriptor) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get(props.descriptor.subtype_endpoint, {
|
||||
params: { perpage: 100, active: false }
|
||||
})
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading subtypes:', err)
|
||||
toast.error(apiError(err, 'Failed to load types'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
const labelField = props.descriptor.subtype_label_field
|
||||
const descField = props.descriptor.subtype_description_field
|
||||
const colorField = props.descriptor.subtype_color_field
|
||||
|
||||
form.value = item
|
||||
? {
|
||||
[labelField]: item[labelField] || '',
|
||||
[descField]: item[descField] || '',
|
||||
[colorField]: item[colorField] || '',
|
||||
isactive: item.isactive !== false
|
||||
}
|
||||
: {
|
||||
[labelField]: '',
|
||||
[descField]: '',
|
||||
[colorField]: '',
|
||||
isactive: true
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const endpoint = props.descriptor.subtype_endpoint
|
||||
const idField = props.descriptor.subtype_id_field
|
||||
|
||||
if (editing.value) {
|
||||
await api.put(`${endpoint}/${editing.value[idField]}`, form.value)
|
||||
} else {
|
||||
await api.post(endpoint, form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item) {
|
||||
const labelField = props.descriptor.subtype_label_field
|
||||
const idField = props.descriptor.subtype_id_field
|
||||
|
||||
if (!confirm(`Delete ${typeLabel.value} type "${item[labelField]}"?`)) return
|
||||
|
||||
try {
|
||||
await api.delete(`${props.descriptor.subtype_endpoint}/${item[idField]}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subtypes-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.show-inactive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.color-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.cell-truncate {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
29
frontend/src/views/settings/asset-types/SuppliesTab.vue
Normal file
29
frontend/src/views/settings/asset-types/SuppliesTab.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="supplies-tab">
|
||||
<div class="page-header">
|
||||
<h3>Supplies Management</h3>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p>Placeholder for printer supplies management (printer types specific).</p>
|
||||
<p class="hint">This tab proves the optional tabs pattern works - it only appears for printers.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
assettypeid: { type: Number, required: true }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplies-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/views/settings/asset-types/TypesTabWrapper.vue
Normal file
45
frontend/src/views/settings/asset-types/TypesTabWrapper.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tabComponent"
|
||||
v-if="tabComponent"
|
||||
:assettypeid="assettypeid"
|
||||
:descriptor="descriptor"
|
||||
/>
|
||||
<div v-else class="loading">Loading...</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { shallowRef, watch, defineAsyncComponent, markRaw } from 'vue'
|
||||
import SubtypesTab from './SubtypesTab.vue'
|
||||
|
||||
const props = defineProps({
|
||||
assettypeid: { type: [Number, String], required: true },
|
||||
descriptor: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const tabComponent = shallowRef(null)
|
||||
|
||||
watch(() => props.descriptor, async (desc) => {
|
||||
if (!desc) {
|
||||
tabComponent.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (desc.custom_component) {
|
||||
const componentName = desc.custom_component
|
||||
tabComponent.value = markRaw(defineAsyncComponent(() =>
|
||||
import(`../types/${componentName}.vue`)
|
||||
))
|
||||
} else {
|
||||
tabComponent.value = markRaw(SubtypesTab)
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
@@ -10,49 +10,31 @@ export const settingsGroups = [
|
||||
{
|
||||
title: 'Site & Facility',
|
||||
cards: [
|
||||
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
|
||||
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
|
||||
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
|
||||
{ to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
|
||||
{ to: '/settings/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General Reference',
|
||||
cards: [
|
||||
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
|
||||
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
|
||||
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
|
||||
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
|
||||
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
|
||||
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
|
||||
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
|
||||
{ to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
|
||||
{ to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
|
||||
{ to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset categories' },
|
||||
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
||||
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
|
||||
// devices all run an OS), so it lives in General Reference, not under PCs.
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PCs',
|
||||
cards: [
|
||||
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
|
||||
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Printers',
|
||||
cards: [
|
||||
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
|
||||
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
|
||||
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Machines',
|
||||
cards: [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||
// This empty placeholder fixes the group's position among the asset groups
|
||||
@@ -66,33 +48,25 @@ export const settingsGroups = [
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
{ to: '/settings/employeedirectory', icon: Contact, title: 'Employee Directory', description: 'Manage the self-hosted people directory (add/edit/import); read-only in external HR mode' },
|
||||
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
|
||||
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
|
||||
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
|
||||
{ to: '/settings/locations', icon: MapPin, title: 'Locations & Organization', description: 'Physical locations, business units, and employee directory' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Network',
|
||||
cards: [
|
||||
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
|
||||
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
|
||||
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
|
||||
{ to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Displays & Kiosks',
|
||||
cards: [
|
||||
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
|
||||
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
cards: [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
{ to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -105,7 +79,6 @@ export const settingsGroups = [
|
||||
title: 'Search & Identity',
|
||||
cards: [
|
||||
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
|
||||
{ to: '/settings/assetidentifiers', icon: Fingerprint, title: 'Asset Identifiers', description: 'Enable optional asset identifiers per type: gauge lab, maintenance, FQDN hostname' },
|
||||
{ to: '/settings/printing', icon: Printer, title: 'Printing & Labels', description: 'Printed QR code and USB label targets, barcode style, and URL templates' },
|
||||
],
|
||||
},
|
||||
@@ -119,11 +92,7 @@ export const settingsGroups = [
|
||||
{
|
||||
title: 'Access & Security',
|
||||
cards: [
|
||||
{ to: '/settings/authentication', icon: KeyRound, title: 'Authentication', description: 'SAML single sign-on (SSO) with your IdP, local login, and auto-create users' },
|
||||
{ to: '/settings/apitokens', icon: Key, title: 'API Tokens', description: 'Personal access tokens for scripts and integrations (e.g. long-running imports) that outlive login sessions' },
|
||||
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
|
||||
{ to: '/settings/audit', icon: History, title: 'Audit & Logging', description: 'Audit log retention period and history purge policy' },
|
||||
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
|
||||
{ to: '/settings/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
286
frontend/src/views/settings/types/NotificationTypesTab.vue
Normal file
286
frontend/src/views/settings/types/NotificationTypesTab.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="notification-types-tab">
|
||||
<div class="tab-header">
|
||||
<h3>Notification Types</h3>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" @click="openModal()">
|
||||
+ Add Notification Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Color</th>
|
||||
<th>Display Style</th>
|
||||
<th>Board Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.notificationtypeid">
|
||||
<td>
|
||||
<strong>{{ item.typename }}</strong>
|
||||
<div v-if="item.typedescription" class="hint">{{ item.typedescription }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="color-badge"
|
||||
:style="{ backgroundColor: item.typecolor }"
|
||||
>
|
||||
{{ item.typecolor }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.displaystyle || 'standard' }}</td>
|
||||
<td>{{ item.boardorder || 100 }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="item.isactive ? 'badge-success' : 'badge-secondary'">
|
||||
{{ item.isactive ? 'yes' : 'no' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(item)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteItem(item)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-light);">
|
||||
No notification types found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editing ? 'Edit' : 'Add' }} Notification Type</h3>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
v-model="form.typename"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
v-model="form.typedescription"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color</label>
|
||||
<ColorSwatchPicker v-model="form.typecolor" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Style</label>
|
||||
<select v-model="form.displaystyle" class="form-control">
|
||||
<option value="standard">Standard rows</option>
|
||||
<option value="carousel">Carousel</option>
|
||||
<option value="grid">Grid</option>
|
||||
<option value="banner">Banner</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Board Order</label>
|
||||
<input
|
||||
v-model.number="form.boardorder"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
max="999"
|
||||
step="10"
|
||||
/>
|
||||
<small class="hint">Lower numbers appear first on the board</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Board Category (optional)</label>
|
||||
<input
|
||||
v-model="form.boardcategory"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="50"
|
||||
/>
|
||||
<small class="hint">Group types under one category name</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.splitperemployee" />
|
||||
Split one card per employee
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.showemployeephoto" />
|
||||
Show employee photo
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="form.isactive" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({})
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/notifications/types')
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading notification types:', err)
|
||||
toast.error(apiError(err, 'Failed to load notification types'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? {
|
||||
typename: item.typename || '',
|
||||
typedescription: item.typedescription || '',
|
||||
typecolor: item.typecolor || '',
|
||||
displaystyle: item.displaystyle || 'standard',
|
||||
boardorder: item.boardorder || 100,
|
||||
boardcategory: item.boardcategory || '',
|
||||
splitperemployee: item.splitperemployee || false,
|
||||
showemployeephoto: item.showemployeephoto || false,
|
||||
isactive: item.isactive !== false
|
||||
}
|
||||
: {
|
||||
typename: '',
|
||||
typedescription: '',
|
||||
typecolor: '',
|
||||
displaystyle: 'standard',
|
||||
boardorder: 100,
|
||||
boardcategory: '',
|
||||
splitperemployee: false,
|
||||
showemployeephoto: false,
|
||||
isactive: true
|
||||
}
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await api.put(`/notifications/types/${editing.value.notificationtypeid}`, form.value)
|
||||
} else {
|
||||
await api.post('/notifications/types', form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item) {
|
||||
if (!confirm(`Delete notification type "${item.typename}"?`)) return
|
||||
|
||||
try {
|
||||
await api.delete(`/notifications/types/${item.notificationtypeid}`)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-types-tab {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.color-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -2307,3 +2307,16 @@ class ComputersPlugin(BasePlugin):
|
||||
today should weigh it.
|
||||
"""
|
||||
return ['computers.view']
|
||||
|
||||
def get_asset_type_descriptor(self):
|
||||
"""Return descriptor for computer types management in Asset Type Hub."""
|
||||
return {
|
||||
'assettype': 'computer',
|
||||
'subtype_endpoint': '/computers/types',
|
||||
'subtype_id_field': 'computertypeid',
|
||||
'subtype_label_field': 'computertype',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'computers.manage',
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ export default [
|
||||
name: 'employee-directory',
|
||||
component: () => import('./views/EmployeeDirectory.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations/employees',
|
||||
name: 'locations-hub-employees',
|
||||
component: () => import('./views/EmployeeDirectory.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset, Setting, success_response, error_response, paginated_response,
|
||||
db, Asset, AssetRelationship, Setting, success_response, error_response, paginated_response,
|
||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||
require_role, caller_holds, caller_is_role, mint_service_token,
|
||||
list_service_tokens,
|
||||
revoke_service_tokens,
|
||||
AuditLog, audit_log, collector_authorized, User,
|
||||
Application, AppVersion, Script,
|
||||
Application, AppVersion, Script, send_alert,
|
||||
)
|
||||
|
||||
from ..models import (
|
||||
@@ -34,7 +34,6 @@ from ..models import (
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, minimum_agent_version_for, action_minimum_versions, parse_agent_version, agent_meets_minimum, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from . import agentstore
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from shopdb.api import send_alert
|
||||
from markupsafe import escape
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
@@ -3451,22 +3450,10 @@ def _purge_expired_operator_reports():
|
||||
def _send_operator_report_alert(report, asset_id, hostname):
|
||||
"""Send email alert for new operator issue report to default alert recipients."""
|
||||
from flask import url_for, request
|
||||
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
|
||||
from shopdb.core.models.relationship import AssetRelationship
|
||||
|
||||
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
|
||||
report.operatorreportid, asset_id, hostname)
|
||||
|
||||
# Check if we have recipients before doing expensive work
|
||||
config = get_smtp_config()
|
||||
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
|
||||
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
|
||||
return
|
||||
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
|
||||
|
||||
asset = Asset.query.get(asset_id)
|
||||
if not asset:
|
||||
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
|
||||
|
||||
@@ -246,3 +246,16 @@ class MachinesPlugin(BasePlugin):
|
||||
('machines.edit', 'Edit machines', 'machines'),
|
||||
('machines.delete', 'Delete machines', 'machines'),
|
||||
]
|
||||
|
||||
def get_asset_type_descriptor(self):
|
||||
"""Return descriptor for machine types management in Asset Type Hub."""
|
||||
return {
|
||||
'assettype': 'machine',
|
||||
'subtype_endpoint': '/machines/types',
|
||||
'subtype_id_field': 'machinetypeid',
|
||||
'subtype_label_field': 'machinetype',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'machines.manage',
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.20.0,<1.0.0",
|
||||
"api_prefix": "/api/measuringtools",
|
||||
"default_enabled": false,
|
||||
"default_enabled": true,
|
||||
"provides": {
|
||||
"features": ["measuring-tools", "calibration-tracking"]
|
||||
}
|
||||
|
||||
@@ -180,3 +180,16 @@ class MeasuringToolsPlugin(BasePlugin):
|
||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
||||
]
|
||||
|
||||
def get_asset_type_descriptor(self):
|
||||
"""Return descriptor for measuring tool types management in Asset Type Hub."""
|
||||
return {
|
||||
'assettype': 'measuring_tool',
|
||||
'subtype_endpoint': '/measuringtools/types',
|
||||
'subtype_id_field': 'measuringtooltypeid',
|
||||
'subtype_label_field': 'name',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'measuringtools.manage',
|
||||
}
|
||||
|
||||
@@ -55,6 +55,18 @@ export default [
|
||||
name: 'subnets',
|
||||
component: () => import('./views/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'settings/network/vlans',
|
||||
name: 'network-hub-vlans',
|
||||
component: () => import('./views/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'settings/network/subnets',
|
||||
name: 'network-hub-subnets',
|
||||
component: () => import('./views/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -221,3 +221,16 @@ class NetworkPlugin(BasePlugin):
|
||||
('network.edit', 'Edit network devices', 'network'),
|
||||
('network.delete', 'Delete network devices', 'network'),
|
||||
]
|
||||
|
||||
def get_asset_type_descriptor(self):
|
||||
"""Return descriptor for network device types management in Asset Type Hub."""
|
||||
return {
|
||||
'assettype': 'network_device',
|
||||
'subtype_endpoint': '/network/types',
|
||||
'subtype_id_field': 'networkdevicetypeid',
|
||||
'subtype_label_field': 'networkdevicetype',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'network.manage',
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ export default [
|
||||
component: () => import('./views/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations/zabbix',
|
||||
name: 'integrations-zabbix',
|
||||
component: () => import('./views/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printer-alerts',
|
||||
name: 'printer-alerts',
|
||||
@@ -61,7 +67,7 @@ export default [
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('./views/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Zabbix Supplies</h2>
|
||||
<h2>Integrations</h2>
|
||||
<p class="header-description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="integration-tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
@@ -88,3 +113,39 @@ const zabbixMessage = computed(() => {
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-description {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.integration-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.integration-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -678,3 +678,16 @@ class PrintersPlugin(BasePlugin):
|
||||
return None
|
||||
# _computer_by_hostname returns the (Computer, Asset) pair.
|
||||
return row[1].assetid
|
||||
|
||||
def get_asset_type_descriptor(self):
|
||||
"""Return descriptor for printer types management in Asset Type Hub."""
|
||||
return {
|
||||
'assettype': 'printer',
|
||||
'subtype_endpoint': '/printers/types',
|
||||
'subtype_id_field': 'printertypeid',
|
||||
'subtype_label_field': 'printertype',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'printers.manage',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ export default [
|
||||
component: () => import('./views/DellWarrantySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
|
||||
},
|
||||
{
|
||||
path: 'settings/integrations/dell',
|
||||
name: 'integrations-dell',
|
||||
component: () => import('./views/DellWarrantySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
|
||||
},
|
||||
{
|
||||
path: 'warranties',
|
||||
name: 'warranties',
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dell Warranty Lookup</h2>
|
||||
<h2>Integrations</h2>
|
||||
<p class="header-description">Configure external service connections and APIs</p>
|
||||
</div>
|
||||
|
||||
<div class="integration-tabs">
|
||||
<router-link
|
||||
:to="{ name: 'servicenow-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'servicenow-settings' }"
|
||||
>
|
||||
ServiceNow
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'zabbix-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'zabbix-settings' }"
|
||||
>
|
||||
Zabbix Supplies
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'dell-warranty-settings' }"
|
||||
class="integration-tab"
|
||||
:class="{ active: $route.name === 'dell-warranty-settings' }"
|
||||
>
|
||||
Dell Warranty
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
@@ -132,3 +157,39 @@ const dellMessage = computed(() => {
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-description {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.integration-tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.integration-tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.integration-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import glob
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
@@ -39,6 +40,16 @@ def _modelimage_dir():
|
||||
return os.path.join(current_app.instance_path, 'modelimages')
|
||||
|
||||
|
||||
def _validate_url_scheme(url):
|
||||
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
|
||||
if not url:
|
||||
return True
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme and parsed.scheme not in ['http', 'https']:
|
||||
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
|
||||
return True
|
||||
|
||||
|
||||
@models_bp.route('', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def list_models():
|
||||
@@ -127,6 +138,13 @@ def create_model():
|
||||
http_code=409
|
||||
)
|
||||
|
||||
# Validate URL scheme to prevent XSS
|
||||
try:
|
||||
_validate_url_scheme(data.get('documentationurl'))
|
||||
_validate_url_scheme(data.get('imageurl'))
|
||||
except ValueError as e:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
|
||||
|
||||
m = Model(
|
||||
modelnumber=data['modelnumber'],
|
||||
vendorid=data.get('vendorid'),
|
||||
@@ -162,6 +180,15 @@ def update_model(model_id: int):
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
# Validate URL schemes to prevent XSS
|
||||
try:
|
||||
if 'documentationurl' in data:
|
||||
_validate_url_scheme(data['documentationurl'])
|
||||
if 'imageurl' in data:
|
||||
_validate_url_scheme(data['imageurl'])
|
||||
except ValueError as e:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
|
||||
|
||||
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
|
||||
if key in data:
|
||||
setattr(m, key, data[key])
|
||||
|
||||
@@ -131,3 +131,104 @@ def asset_presentation():
|
||||
"""
|
||||
entries = _collect('get_asset_presentation')
|
||||
return success_response(entries)
|
||||
|
||||
|
||||
@pluginui_bp.route('/asset-type-descriptors', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def asset_type_descriptors():
|
||||
"""Merge enabled plugins' asset-type subtype descriptors.
|
||||
|
||||
The Asset Type Hub uses these to render generic CRUD interfaces for
|
||||
each plugin's subtype table (printertypes, machinetypes, etc.) without
|
||||
importing plugin models or views.
|
||||
|
||||
Each descriptor maps to ONE asset type (from assettypes table) and
|
||||
tells core:
|
||||
- Which API endpoint handles the subtype CRUD
|
||||
- Field names for ID, label, description, color
|
||||
- Required permission
|
||||
- Optional plugin-specific tabs (e.g., 'supplies' for printers)
|
||||
|
||||
Returns: List[Dict] sorted by assettype name
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return success_response([])
|
||||
|
||||
descriptors = []
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
desc = plugin.get_asset_type_descriptor()
|
||||
if desc is None:
|
||||
continue
|
||||
desc['plugin'] = name
|
||||
descriptors.append(desc)
|
||||
except Exception:
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name)
|
||||
|
||||
# Validation: each descriptor must have required keys
|
||||
required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field',
|
||||
'subtype_label_field', 'subtype_description_field',
|
||||
'subtype_color_field', 'edit_permission'}
|
||||
validated = []
|
||||
for desc in descriptors:
|
||||
missing = required_keys - set(desc.keys())
|
||||
if missing:
|
||||
current_app.logger.error(
|
||||
'Plugin %s descriptor missing keys: %s',
|
||||
desc.get('plugin', 'unknown'), missing
|
||||
)
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise ValueError(f"Descriptor missing keys: {missing}")
|
||||
continue
|
||||
validated.append(desc)
|
||||
|
||||
validated.sort(key=lambda d: d.get('assettype', ''))
|
||||
return success_response(validated)
|
||||
|
||||
|
||||
@pluginui_bp.route('/core-type-descriptors', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def core_type_descriptors():
|
||||
"""Return type descriptors for core-owned non-asset types.
|
||||
|
||||
These are types managed by core (not plugins) that fit the same
|
||||
management pattern as asset subtypes: location types, notification types, etc.
|
||||
|
||||
Returns same descriptor format as asset-type-descriptors, with optional
|
||||
'custom_component' field for types that need specialized UI.
|
||||
"""
|
||||
descriptors = [
|
||||
{
|
||||
'category': 'location',
|
||||
'type_name': 'Location Type',
|
||||
'type_name_plural': 'Location Types',
|
||||
'subtype_endpoint': '/locations/types',
|
||||
'subtype_id_field': 'locationtypeid',
|
||||
'subtype_label_field': 'locationtype',
|
||||
'subtype_description_field': 'description',
|
||||
'subtype_color_field': 'color',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'admin',
|
||||
'group': 'other',
|
||||
},
|
||||
{
|
||||
'category': 'notification',
|
||||
'type_name': 'Notification Type',
|
||||
'type_name_plural': 'Notification Types',
|
||||
'subtype_endpoint': '/notifications/types',
|
||||
'subtype_id_field': 'notificationtypeid',
|
||||
'subtype_label_field': 'typename',
|
||||
'subtype_description_field': 'typedescription',
|
||||
'subtype_color_field': 'typecolor',
|
||||
'optional_tabs': [],
|
||||
'edit_permission': 'admin',
|
||||
'group': 'other',
|
||||
'custom_component': 'NotificationTypesTab',
|
||||
},
|
||||
]
|
||||
return success_response(descriptors)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Support Teams API endpoints - teams and their nested contacts."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
@@ -19,6 +21,16 @@ from shopdb.utils.authz import require_permission
|
||||
supportteams_bp = Blueprint('supportteams', __name__)
|
||||
|
||||
|
||||
def _validate_url_scheme(url):
|
||||
"""Validate that URL uses safe scheme (prevents XSS via javascript: URIs)."""
|
||||
if not url:
|
||||
return True
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme and parsed.scheme not in ['http', 'https']:
|
||||
raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http:// and https:// are allowed.")
|
||||
return True
|
||||
|
||||
|
||||
@supportteams_bp.route('', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def list_support_teams():
|
||||
@@ -62,6 +74,13 @@ def create_support_team():
|
||||
f"Support team '{data['teamname']}' already exists",
|
||||
http_code=409)
|
||||
|
||||
# Validate URL schemes to prevent XSS
|
||||
try:
|
||||
_validate_url_scheme(data.get('teamurl'))
|
||||
_validate_url_scheme(data.get('webhookurl'))
|
||||
except ValueError as e:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
|
||||
|
||||
team = SupportTeam(
|
||||
teamname=data['teamname'],
|
||||
teamurl=data.get('teamurl'),
|
||||
@@ -101,6 +120,15 @@ def update_support_team(team_id: int):
|
||||
f"Support team '{data['teamname']}' already exists",
|
||||
http_code=409)
|
||||
|
||||
# Validate URL schemes to prevent XSS
|
||||
try:
|
||||
if 'teamurl' in data:
|
||||
_validate_url_scheme(data['teamurl'])
|
||||
if 'webhookurl' in data:
|
||||
_validate_url_scheme(data['webhookurl'])
|
||||
except ValueError as e:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(e))
|
||||
|
||||
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
|
||||
if key in data:
|
||||
setattr(team, key, data[key])
|
||||
|
||||
@@ -16,7 +16,8 @@ class PluginMeta:
|
||||
author: str = ""
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
# Default to the current pre-1.0 contract range; tighten when 1.0 lands
|
||||
core_version: str = ">=0.2.0,<1.0.0"
|
||||
# 0.20.0: added get_asset_type_descriptor hook (additive, backward-compatible)
|
||||
core_version: str = ">=0.20.0,<1.0.0"
|
||||
api_prefix: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -440,3 +441,42 @@ class BasePlugin(ABC):
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_type_descriptor(self) -> Optional[Dict]:
|
||||
"""Return subtype management descriptor for this plugin's asset type.
|
||||
|
||||
Declares how to CRUD the plugin's subtype table (printertypes,
|
||||
machinetypes, etc.) so core can render a generic CRUD UI without
|
||||
importing the plugin's models or views. Return None if the plugin
|
||||
has no subtype table.
|
||||
|
||||
Contract (all keys required if returning non-None):
|
||||
{
|
||||
'assettype': str, # matches assettypes.assettype
|
||||
'subtype_endpoint': str, # API path WITHOUT /api prefix (e.g., '/printers/types')
|
||||
'subtype_id_field': str, # PK field name (e.g., 'printertypeid')
|
||||
'subtype_label_field': str, # Display name field (e.g., 'printertype')
|
||||
'subtype_description_field': str, # Description field
|
||||
'subtype_color_field': str, # Color field for map markers
|
||||
'optional_tabs': List[str], # Plugin-specific tabs (e.g., ['supplies'])
|
||||
'edit_permission': str, # Required permission (e.g., 'printers.manage')
|
||||
}
|
||||
|
||||
The endpoint must support:
|
||||
- GET (list) ?active=true/false for pagination
|
||||
- GET /{id} for single record
|
||||
- POST (create)
|
||||
- PUT /{id} (update)
|
||||
- DELETE /{id}
|
||||
|
||||
Payload fields:
|
||||
- {subtype_label_field}: required string
|
||||
- {subtype_description_field}: optional text
|
||||
- {subtype_color_field}: optional string (CSS color)
|
||||
- isactive: optional boolean (default True)
|
||||
|
||||
Consumed by GET /api/pluginui/asset-type-descriptors. Disabled
|
||||
plugins are skipped; a broken plugin is isolated in prod, re-raised
|
||||
in dev/test.
|
||||
"""
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user