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 }
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/vendors',
|
path: 'settings/catalog',
|
||||||
name: 'vendors',
|
name: 'model-catalog',
|
||||||
component: () => import('../../views/vendors/VendorsList.vue'),
|
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 }
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/modeltypes',
|
path: 'settings/servicenow',
|
||||||
name: 'modeltypes',
|
name: 'servicenow-settings',
|
||||||
component: () => import('../../views/settings/ModelTypesList.vue'),
|
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
meta: { requiresAuth: true, requiresAdmin: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/locations',
|
path: 'settings/locations',
|
||||||
name: 'locations',
|
name: 'locations-hub',
|
||||||
component: () => import('../../views/settings/LocationsList.vue'),
|
component: () => import('../../views/settings/LocationsHub.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/network',
|
||||||
name: 'statuses',
|
name: 'network-hub',
|
||||||
component: () => import('../../views/settings/StatusesList.vue'),
|
component: () => import('../../views/settings/NetworkHub.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
},
|
children: []
|
||||||
{
|
|
||||||
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/supportteams',
|
path: 'settings/supportteams',
|
||||||
@@ -83,18 +201,6 @@ export default [
|
|||||||
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/locationtypes',
|
||||||
name: 'location-types',
|
name: 'location-types',
|
||||||
@@ -103,15 +209,22 @@ export default [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/assettypes',
|
path: 'settings/assettypes',
|
||||||
name: 'asset-types',
|
component: () => import('../../views/settings/AssetTypeHub.vue'),
|
||||||
component: () => import('../../views/settings/AssetTypesList.vue'),
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
children: [
|
||||||
},
|
{
|
||||||
{
|
path: '',
|
||||||
path: 'settings/customfields',
|
name: 'asset-types-hub',
|
||||||
name: 'custom-fields',
|
component: () => import('../../views/settings/asset-types/TypesTabWrapper.vue'),
|
||||||
component: () => import('../../views/settings/CustomFieldsList.vue'),
|
props: route => ({ assettypeid: route.params.assettypeid || route.params.assettypeid })
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
},
|
||||||
|
{
|
||||||
|
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
|
// The old monolithic System Settings tab page is split into one routed page
|
||||||
// per section (below). This redirect keeps old bookmarks working: bare
|
// per section (below). This redirect keeps old bookmarks working: bare
|
||||||
@@ -138,24 +251,6 @@ export default [
|
|||||||
},
|
},
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/printing',
|
||||||
name: 'printing-settings',
|
name: 'printing-settings',
|
||||||
@@ -168,60 +263,18 @@ export default [
|
|||||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/globalsearch',
|
||||||
name: 'global-search-settings',
|
name: 'global-search-settings',
|
||||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/plugins',
|
||||||
name: 'plugins',
|
name: 'plugins',
|
||||||
component: () => import('../../views/settings/PluginsList.vue'),
|
component: () => import('../../views/settings/PluginsList.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true }
|
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',
|
path: 'settings/shopdbbackups',
|
||||||
name: 'shopdbbackups',
|
name: 'shopdbbackups',
|
||||||
|
|||||||
@@ -327,6 +327,11 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading API tokens:', 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 {
|
} finally {
|
||||||
loading.value = false
|
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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<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>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -103,3 +128,39 @@ const {
|
|||||||
|
|
||||||
onMounted(loadSettings)
|
onMounted(loadSettings)
|
||||||
</script>
|
</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',
|
title: 'Site & Facility',
|
||||||
cards: [
|
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/siteconfig', icon: Home, title: 'Site Configuration', description: 'Site identity, facility settings, floor map, branding, and asset imports' },
|
||||||
{ 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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'General Reference',
|
title: 'General Reference',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
|
{ to: '/settings/catalog', icon: Package, title: 'Model Catalog', description: 'Manage vendors, model types, equipment models, and operating systems' },
|
||||||
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
|
{ to: '/settings/assetconfig', icon: Cog, title: 'Asset Configuration', description: 'Configure asset statuses, relationship types, custom fields, and identifiers' },
|
||||||
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
|
{ to: '/settings/assettypes', icon: Factory, title: 'Asset Types', description: 'Manage asset type subtypes, colors, and custom fields for all asset 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/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
{ 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',
|
title: 'PCs',
|
||||||
cards: [
|
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' },
|
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Printers',
|
title: 'Printers',
|
||||||
cards: [
|
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/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' },
|
{ 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 "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||||
// This empty placeholder fixes the group's position among the asset groups
|
// This empty placeholder fixes the group's position among the asset groups
|
||||||
@@ -66,33 +48,25 @@ export const settingsGroups = [
|
|||||||
{
|
{
|
||||||
title: 'Locations & Organization',
|
title: 'Locations & Organization',
|
||||||
cards: [
|
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 & Organization', description: 'Physical locations, business units, and employee directory' },
|
||||||
{ 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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Network',
|
title: 'Network',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
|
{ to: '/settings/network', icon: Globe, title: 'Network Infrastructure', description: 'Manage VLANs and IP subnets' },
|
||||||
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
|
|
||||||
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Displays & Kiosks',
|
title: 'Displays & Kiosks',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
|
{ 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',
|
title: 'Integrations',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
{ to: '/settings/integrations', icon: Link, title: 'Integrations', description: 'ServiceNow, Zabbix, and Dell Warranty API connections' },
|
||||||
{ 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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,7 +79,6 @@ export const settingsGroups = [
|
|||||||
title: 'Search & Identity',
|
title: 'Search & Identity',
|
||||||
cards: [
|
cards: [
|
||||||
{ to: '/settings/globalsearch', icon: Search, title: 'Global Search', description: 'Choose which content types (domains) appear in global search results' },
|
{ 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' },
|
{ 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',
|
title: 'Access & Security',
|
||||||
cards: [
|
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/security', icon: ShieldCheck, title: 'Access & Security', description: 'Authentication, API tokens, users, roles, and audit logs' },
|
||||||
{ 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' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
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.
|
today should weigh it.
|
||||||
"""
|
"""
|
||||||
return ['computers.view']
|
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',
|
name: 'employee-directory',
|
||||||
component: () => import('./views/EmployeeDirectory.vue'),
|
component: () => import('./views/EmployeeDirectory.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
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 flask_jwt_extended import current_user
|
||||||
|
|
||||||
from shopdb.api import (
|
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,
|
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||||
require_role, caller_holds, caller_is_role, mint_service_token,
|
require_role, caller_holds, caller_is_role, mint_service_token,
|
||||||
list_service_tokens,
|
list_service_tokens,
|
||||||
revoke_service_tokens,
|
revoke_service_tokens,
|
||||||
AuditLog, audit_log, collector_authorized, User,
|
AuditLog, audit_log, collector_authorized, User,
|
||||||
Application, AppVersion, Script,
|
Application, AppVersion, Script, send_alert,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import (
|
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 ..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 . import agentstore
|
||||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||||
from shopdb.api import send_alert
|
|
||||||
from markupsafe import escape
|
from markupsafe import escape
|
||||||
|
|
||||||
endpoints_bp = Blueprint('endpoints', __name__)
|
endpoints_bp = Blueprint('endpoints', __name__)
|
||||||
@@ -3451,22 +3450,10 @@ def _purge_expired_operator_reports():
|
|||||||
def _send_operator_report_alert(report, asset_id, hostname):
|
def _send_operator_report_alert(report, asset_id, hostname):
|
||||||
"""Send email alert for new operator issue report to default alert recipients."""
|
"""Send email alert for new operator issue report to default alert recipients."""
|
||||||
from flask import url_for, request
|
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',
|
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
|
||||||
report.operatorreportid, asset_id, hostname)
|
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)
|
asset = Asset.query.get(asset_id)
|
||||||
if not asset:
|
if not asset:
|
||||||
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
|
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.edit', 'Edit machines', 'machines'),
|
||||||
('machines.delete', 'Delete 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": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.20.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/measuringtools",
|
"api_prefix": "/api/measuringtools",
|
||||||
"default_enabled": false,
|
"default_enabled": true,
|
||||||
"provides": {
|
"provides": {
|
||||||
"features": ["measuring-tools", "calibration-tracking"]
|
"features": ["measuring-tools", "calibration-tracking"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,3 +180,16 @@ class MeasuringToolsPlugin(BasePlugin):
|
|||||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||||
('measuringtools.delete', 'Delete 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',
|
name: 'subnets',
|
||||||
component: () => import('./views/SubnetsList.vue'),
|
component: () => import('./views/SubnetsList.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
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.edit', 'Edit network devices', 'network'),
|
||||||
('network.delete', 'Delete 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'),
|
component: () => import('./views/ZabbixSettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
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',
|
path: 'settings/printer-alerts',
|
||||||
name: 'printer-alerts',
|
name: 'printer-alerts',
|
||||||
@@ -61,7 +67,7 @@ export default [
|
|||||||
path: 'settings/modelsupplies',
|
path: 'settings/modelsupplies',
|
||||||
name: 'model-supplies',
|
name: 'model-supplies',
|
||||||
component: () => import('./views/ModelSuppliesList.vue'),
|
component: () => import('./views/ModelSuppliesList.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'printers' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/printerdrivers',
|
path: 'settings/printerdrivers',
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<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>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -88,3 +113,39 @@ const zabbixMessage = computed(() => {
|
|||||||
|
|
||||||
onMounted(loadSettings)
|
onMounted(loadSettings)
|
||||||
</script>
|
</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
|
return None
|
||||||
# _computer_by_hostname returns the (Computer, Asset) pair.
|
# _computer_by_hostname returns the (Computer, Asset) pair.
|
||||||
return row[1].assetid
|
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'),
|
component: () => import('./views/DellWarrantySettings.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'warranty' }
|
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',
|
path: 'warranties',
|
||||||
name: 'warranties',
|
name: 'warranties',
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<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>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -132,3 +157,39 @@ const dellMessage = computed(() => {
|
|||||||
|
|
||||||
onMounted(loadSettings)
|
onMounted(loadSettings)
|
||||||
</script>
|
</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 glob
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Blueprint, request, current_app
|
from flask import Blueprint, request, current_app
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
@@ -39,6 +40,16 @@ def _modelimage_dir():
|
|||||||
return os.path.join(current_app.instance_path, 'modelimages')
|
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'])
|
@models_bp.route('', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def list_models():
|
def list_models():
|
||||||
@@ -127,6 +138,13 @@ def create_model():
|
|||||||
http_code=409
|
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(
|
m = Model(
|
||||||
modelnumber=data['modelnumber'],
|
modelnumber=data['modelnumber'],
|
||||||
vendorid=data.get('vendorid'),
|
vendorid=data.get('vendorid'),
|
||||||
@@ -162,6 +180,15 @@ def update_model(model_id: int):
|
|||||||
if not data:
|
if not data:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
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']:
|
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(m, key, data[key])
|
setattr(m, key, data[key])
|
||||||
|
|||||||
@@ -131,3 +131,104 @@ def asset_presentation():
|
|||||||
"""
|
"""
|
||||||
entries = _collect('get_asset_presentation')
|
entries = _collect('get_asset_presentation')
|
||||||
return success_response(entries)
|
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."""
|
"""Support Teams API endpoints - teams and their nested contacts."""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
@@ -19,6 +21,16 @@ from shopdb.utils.authz import require_permission
|
|||||||
supportteams_bp = Blueprint('supportteams', __name__)
|
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'])
|
@supportteams_bp.route('', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def list_support_teams():
|
def list_support_teams():
|
||||||
@@ -62,6 +74,13 @@ def create_support_team():
|
|||||||
f"Support team '{data['teamname']}' already exists",
|
f"Support team '{data['teamname']}' already exists",
|
||||||
http_code=409)
|
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(
|
team = SupportTeam(
|
||||||
teamname=data['teamname'],
|
teamname=data['teamname'],
|
||||||
teamurl=data.get('teamurl'),
|
teamurl=data.get('teamurl'),
|
||||||
@@ -101,6 +120,15 @@ def update_support_team(team_id: int):
|
|||||||
f"Support team '{data['teamname']}' already exists",
|
f"Support team '{data['teamname']}' already exists",
|
||||||
http_code=409)
|
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']:
|
for key in ['teamname', 'teamurl', 'webhookurl', 'isactive']:
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(team, key, data[key])
|
setattr(team, key, data[key])
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ class PluginMeta:
|
|||||||
author: str = ""
|
author: str = ""
|
||||||
dependencies: List[str] = field(default_factory=list)
|
dependencies: List[str] = field(default_factory=list)
|
||||||
# Default to the current pre-1.0 contract range; tighten when 1.0 lands
|
# 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
|
api_prefix: str = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -440,3 +441,42 @@ class BasePlugin(ABC):
|
|||||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||||
"""
|
"""
|
||||||
return []
|
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