Add database adapter system, production deployment configs, and new dashboard components
- Add PostgreSQL and SQLite database adapters with factory pattern - Add migration script for SQLite to PostgreSQL - Add production Dockerfiles and docker-compose configs - Add deployment documentation and scripts - Add BIA sync dashboard and matching service - Add data completeness configuration and components - Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.) - Update various services and routes - Remove deprecated management-parameters.json and taxonomy files
This commit is contained in:
@@ -45,6 +45,7 @@ import type {
|
||||
PlatformWithWorkloads,
|
||||
} from '../types/index.js';
|
||||
import { calculateRequiredEffortWithMinMax } from './effortCalculation.js';
|
||||
import { calculateApplicationCompleteness } from './dataCompletenessConfig.js';
|
||||
|
||||
// Determine if we should use real Jira Assets or mock data
|
||||
const useJiraAssets = !!(config.jiraPat && config.jiraSchemaId);
|
||||
@@ -286,6 +287,36 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
};
|
||||
|
||||
// Calculate data completeness percentage
|
||||
// Convert ApplicationListItem-like structure to format expected by completeness calculator
|
||||
const appForCompleteness = {
|
||||
...result,
|
||||
organisation: organisation?.name || null,
|
||||
applicationFunctions: result.applicationFunctions,
|
||||
status: result.status,
|
||||
businessImpactAnalyse: businessImpactAnalyse,
|
||||
hostingType: hostingType,
|
||||
supplierProduct: result.supplierProduct,
|
||||
businessOwner: result.businessOwner,
|
||||
systemOwner: result.systemOwner,
|
||||
functionalApplicationManagement: result.functionalApplicationManagement,
|
||||
technicalApplicationManagement: result.technicalApplicationManagement,
|
||||
governanceModel: governanceModel,
|
||||
applicationType: applicationType,
|
||||
applicationManagementHosting: applicationManagementHosting,
|
||||
applicationManagementTAM: applicationManagementTAM,
|
||||
dynamicsFactor: dynamicsFactor,
|
||||
complexityFactor: complexityFactor,
|
||||
numberOfUsers: numberOfUsers,
|
||||
};
|
||||
|
||||
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
|
||||
|
||||
return {
|
||||
...result,
|
||||
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-loaded factor caches for synchronous access
|
||||
@@ -416,16 +447,17 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
const applicationFunctions = toReferenceValues(app.applicationFunction);
|
||||
const applicationManagementHosting = toReferenceValue(app.applicationManagementHosting);
|
||||
const applicationManagementTAM = toReferenceValue(app.applicationManagementTAM);
|
||||
const businessImpactAnalyse = toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Calculate effort using minimal details
|
||||
const minimalDetails = toMinimalDetailsForEffort(app);
|
||||
const effortResult = calculateRequiredEffortWithMinMax(minimalDetails);
|
||||
|
||||
return {
|
||||
const result: ApplicationListItem = {
|
||||
id: app.id,
|
||||
key: app.objectKey,
|
||||
name: app.label,
|
||||
status: (app.status || 'In Production') as ApplicationStatus,
|
||||
status: app.status as ApplicationStatus | null,
|
||||
applicationFunctions,
|
||||
governanceModel,
|
||||
dynamicsFactor,
|
||||
@@ -436,11 +468,41 @@ function toApplicationListItem(app: ApplicationComponent): ApplicationListItem {
|
||||
platform,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
businessImpactAnalyse,
|
||||
requiredEffortApplicationManagement: effortResult.finalEffort,
|
||||
minFTE: effortResult.minFTE,
|
||||
maxFTE: effortResult.maxFTE,
|
||||
overrideFTE: app.applicationManagementOverrideFTE ?? null,
|
||||
};
|
||||
|
||||
// Calculate data completeness percentage
|
||||
// Convert ApplicationListItem to format expected by completeness calculator
|
||||
const appForCompleteness = {
|
||||
organisation: toReferenceValue(app.organisation)?.name || null,
|
||||
applicationFunctions: result.applicationFunctions,
|
||||
status: result.status,
|
||||
businessImpactAnalyse: result.businessImpactAnalyse,
|
||||
hostingType: toReferenceValue(app.applicationComponentHostingType),
|
||||
supplierProduct: app.supplierProduct?.label || null,
|
||||
businessOwner: app.businessOwner?.label || null,
|
||||
systemOwner: app.systemOwner?.label || null,
|
||||
functionalApplicationManagement: app.functionalApplicationManagement || null,
|
||||
technicalApplicationManagement: app.technicalApplicationManagement?.label || null,
|
||||
governanceModel: result.governanceModel,
|
||||
applicationType: result.applicationType,
|
||||
applicationManagementHosting: result.applicationManagementHosting,
|
||||
applicationManagementTAM: result.applicationManagementTAM,
|
||||
dynamicsFactor: result.dynamicsFactor,
|
||||
complexityFactor: result.complexityFactor,
|
||||
numberOfUsers: toReferenceValue(app.applicationManagementNumberOfUsers),
|
||||
};
|
||||
|
||||
const completenessPercentage = calculateApplicationCompleteness(appForCompleteness);
|
||||
|
||||
return {
|
||||
...result,
|
||||
dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -475,7 +537,11 @@ export const dataService = {
|
||||
}
|
||||
|
||||
if (filters.statuses && filters.statuses.length > 0) {
|
||||
apps = apps.filter(app => filters.statuses!.includes(app.status as ApplicationStatus));
|
||||
apps = apps.filter(app => {
|
||||
// Handle empty/null status - treat as 'Undefined' for filtering
|
||||
const status = app.status || 'Undefined';
|
||||
return filters.statuses!.includes(status as ApplicationStatus);
|
||||
});
|
||||
}
|
||||
|
||||
// Organisation filter (now ObjectReference)
|
||||
@@ -988,7 +1054,7 @@ export const dataService = {
|
||||
unclassifiedCount,
|
||||
withApplicationFunction,
|
||||
applicationFunctionPercentage,
|
||||
cacheStatus: cmdbService.getCacheStats(),
|
||||
cacheStatus: await cmdbService.getCacheStats(),
|
||||
};
|
||||
|
||||
if (includeDistributions) {
|
||||
@@ -1030,6 +1096,186 @@ export const dataService = {
|
||||
return jiraAssetsService.getTeamDashboardData(excludedStatuses);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get team portfolio health metrics
|
||||
* Calculates average complexity, dynamics, BIA, and governance maturity per team
|
||||
*/
|
||||
async getTeamPortfolioHealth(excludedStatuses: ApplicationStatus[] = []): Promise<{
|
||||
teams: Array<{
|
||||
team: ReferenceValue | null;
|
||||
metrics: {
|
||||
complexity: number; // Average complexity factor (0-1 normalized)
|
||||
dynamics: number; // Average dynamics factor (0-1 normalized)
|
||||
bia: number; // Average BIA level (0-1 normalized, F=1.0, A=0.0)
|
||||
governanceMaturity: number; // Average governance maturity (0-1 normalized, A=1.0, E=0.0)
|
||||
};
|
||||
applicationCount: number;
|
||||
}>;
|
||||
}> {
|
||||
// For mock data, use the same implementation (cmdbService routes to mock data when useJiraAssets is false)
|
||||
// Get all applications from cache to access all fields including BIA
|
||||
let apps = await cmdbService.getObjects<ApplicationComponent>('ApplicationComponent');
|
||||
|
||||
// Filter out excluded statuses
|
||||
if (excludedStatuses.length > 0) {
|
||||
apps = apps.filter(app => !app.status || !excludedStatuses.includes(app.status as ApplicationStatus));
|
||||
}
|
||||
|
||||
// Ensure factor caches are loaded
|
||||
await ensureFactorCaches();
|
||||
|
||||
// Helper to convert BIA letter to numeric (F=6, E=5, D=4, C=3, B=2, A=1)
|
||||
// Handles formats like "BIA-2024-0042 (Klasse E)" or just "E"
|
||||
const biaToNumeric = (bia: string | null): number | null => {
|
||||
if (!bia) return null;
|
||||
// Extract letter from patterns like "Klasse E", "E", or "(Klasse E)"
|
||||
const match = bia.match(/[Kk]lasse\s+([A-F])/i) || bia.match(/\b([A-F])\b/i);
|
||||
if (match) {
|
||||
const letter = match[1].toUpperCase();
|
||||
const biaMap: Record<string, number> = { 'F': 6, 'E': 5, 'D': 4, 'C': 3, 'B': 2, 'A': 1 };
|
||||
return biaMap[letter] || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to convert governance model to maturity score (A=5, B=4, C=3, D=2, E=1)
|
||||
const governanceToMaturity = (govModel: string | null): number | null => {
|
||||
if (!govModel) return null;
|
||||
// Extract letter from "Regiemodel X" or just "X"
|
||||
const match = govModel.match(/Regiemodel\s+([A-E]\+?)/i) || govModel.match(/^([A-E]\+?)$/i);
|
||||
if (match) {
|
||||
const letter = match[1].toUpperCase();
|
||||
if (letter === 'A') return 5;
|
||||
if (letter === 'B' || letter === 'B+') return 4;
|
||||
if (letter === 'C') return 3;
|
||||
if (letter === 'D') return 2;
|
||||
if (letter === 'E') return 1;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to get factor value from ReferenceValue
|
||||
const getFactorValue = (ref: ReferenceValue | null): number | null => {
|
||||
if (!ref) return null;
|
||||
// Look up in dynamics factors cache
|
||||
const dynamicsFactor = dynamicsFactorCache?.get(ref.objectId);
|
||||
if (dynamicsFactor?.factor !== undefined) return dynamicsFactor.factor;
|
||||
// Look up in complexity factors cache
|
||||
const complexityFactor = complexityFactorCache?.get(ref.objectId);
|
||||
if (complexityFactor?.factor !== undefined) return complexityFactor.factor;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Collect all applications grouped by team
|
||||
const teamMetrics: Map<string, {
|
||||
team: ReferenceValue | null;
|
||||
complexityValues: number[];
|
||||
dynamicsValues: number[];
|
||||
biaValues: number[];
|
||||
governanceValues: number[];
|
||||
applicationCount: number;
|
||||
}> = new Map();
|
||||
|
||||
// Process each application
|
||||
for (const app of apps) {
|
||||
// Get team from application (via subteam lookup if needed)
|
||||
let team: ReferenceValue | null = null;
|
||||
const applicationSubteam = toReferenceValue((app as any).applicationManagementSubteam);
|
||||
const applicationTeam = toReferenceValue((app as any).applicationManagementTeam);
|
||||
|
||||
// Prefer direct team assignment, otherwise try to get from subteam
|
||||
if (applicationTeam) {
|
||||
team = applicationTeam;
|
||||
} else if (applicationSubteam) {
|
||||
// Look up team from subteam (would need subteam cache, but for now use subteam as fallback)
|
||||
team = applicationSubteam; // Fallback: use subteam if team not directly assigned
|
||||
}
|
||||
|
||||
const teamKey = team?.objectId || 'unassigned';
|
||||
if (!teamMetrics.has(teamKey)) {
|
||||
teamMetrics.set(teamKey, {
|
||||
team,
|
||||
complexityValues: [],
|
||||
dynamicsValues: [],
|
||||
biaValues: [],
|
||||
governanceValues: [],
|
||||
applicationCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = teamMetrics.get(teamKey)!;
|
||||
metrics.applicationCount++;
|
||||
|
||||
// Get complexity factor value
|
||||
if (app.applicationManagementComplexityFactor && typeof app.applicationManagementComplexityFactor === 'object') {
|
||||
const factorObj = complexityFactorCache?.get(app.applicationManagementComplexityFactor.objectId);
|
||||
if (factorObj?.factor !== undefined) {
|
||||
metrics.complexityValues.push(factorObj.factor);
|
||||
}
|
||||
}
|
||||
|
||||
// Get dynamics factor value
|
||||
if (app.applicationManagementDynamicsFactor && typeof app.applicationManagementDynamicsFactor === 'object') {
|
||||
const factorObj = dynamicsFactorCache?.get(app.applicationManagementDynamicsFactor.objectId);
|
||||
if (factorObj?.factor !== undefined) {
|
||||
metrics.dynamicsValues.push(factorObj.factor);
|
||||
}
|
||||
}
|
||||
|
||||
// Get BIA value
|
||||
if (app.businessImpactAnalyse) {
|
||||
const biaRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
if (biaRef) {
|
||||
const biaNum = biaToNumeric(biaRef.name);
|
||||
if (biaNum !== null) metrics.biaValues.push(biaNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Get governance maturity
|
||||
if (app.ictGovernanceModel) {
|
||||
const govRef = toReferenceValue(app.ictGovernanceModel);
|
||||
if (govRef) {
|
||||
const maturity = governanceToMaturity(govRef.name);
|
||||
if (maturity !== null) metrics.governanceValues.push(maturity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages and normalize to 0-1 scale
|
||||
const result = Array.from(teamMetrics.values()).map(metrics => {
|
||||
// Calculate averages
|
||||
const avgComplexity = metrics.complexityValues.length > 0
|
||||
? metrics.complexityValues.reduce((a, b) => a + b, 0) / metrics.complexityValues.length
|
||||
: 0;
|
||||
const avgDynamics = metrics.dynamicsValues.length > 0
|
||||
? metrics.dynamicsValues.reduce((a, b) => a + b, 0) / metrics.dynamicsValues.length
|
||||
: 0;
|
||||
const avgBIA = metrics.biaValues.length > 0
|
||||
? metrics.biaValues.reduce((a, b) => a + b, 0) / metrics.biaValues.length
|
||||
: 0;
|
||||
const avgGovernance = metrics.governanceValues.length > 0
|
||||
? metrics.governanceValues.reduce((a, b) => a + b, 0) / metrics.governanceValues.length
|
||||
: 0;
|
||||
|
||||
// Normalize to 0-1 scale
|
||||
// Complexity and Dynamics: assume max factor is 1.0 (already normalized)
|
||||
// BIA: 1-6 scale -> normalize to 0-1 (1=0.0, 6=1.0)
|
||||
// Governance: 1-5 scale -> normalize to 0-1 (1=0.0, 5=1.0)
|
||||
return {
|
||||
team: metrics.team,
|
||||
metrics: {
|
||||
complexity: Math.min(1, Math.max(0, avgComplexity)),
|
||||
dynamics: Math.min(1, Math.max(0, avgDynamics)),
|
||||
bia: (avgBIA - 1) / 5, // (1-6) -> (0-1)
|
||||
governanceMaturity: (avgGovernance - 1) / 4, // (1-5) -> (0-1)
|
||||
},
|
||||
applicationCount: metrics.applicationCount,
|
||||
};
|
||||
});
|
||||
|
||||
return { teams: result };
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Utility
|
||||
// ===========================================================================
|
||||
@@ -1046,15 +1292,15 @@ export const dataService = {
|
||||
/**
|
||||
* Get cache status
|
||||
*/
|
||||
getCacheStatus(): CacheStats {
|
||||
return cacheStore.getStats();
|
||||
async getCacheStatus(): Promise<CacheStats> {
|
||||
return await cacheStore.getStats();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if cache is warm
|
||||
*/
|
||||
isCacheWarm(): boolean {
|
||||
return cacheStore.isWarm();
|
||||
async isCacheWarm(): Promise<boolean> {
|
||||
return await cacheStore.isWarm();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1078,4 +1324,159 @@ export const dataService = {
|
||||
referenceCache.clear();
|
||||
clearFactorCaches();
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Business Importance vs BIA Comparison
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Get Business Importance vs BIA comparison data
|
||||
*/
|
||||
async getBusinessImportanceComparison() {
|
||||
// Fetch all applications
|
||||
const allApps = await cmdbService.getObjects<ApplicationComponent>('ApplicationComponent');
|
||||
|
||||
// Fetch Business Importance reference values from CMDB
|
||||
const businessImportanceRefs = await this.getBusinessImportance();
|
||||
|
||||
// Create a map for quick lookup: name -> normalized value
|
||||
const biNormalizationMap = new Map<string, number>();
|
||||
for (const ref of businessImportanceRefs) {
|
||||
// Extract numeric prefix from name (e.g., "0 - Critical Infrastructure" -> 0)
|
||||
const match = ref.name.match(/^(\d+)\s*-/);
|
||||
if (match) {
|
||||
const numValue = parseInt(match[1], 10);
|
||||
// Only include 0-6, exclude 9 (Unknown)
|
||||
if (numValue >= 0 && numValue <= 6) {
|
||||
biNormalizationMap.set(ref.name, numValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comparisonItems: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
searchReference: string | null;
|
||||
businessImportance: string | null;
|
||||
businessImportanceNormalized: number | null;
|
||||
businessImpactAnalyse: ReferenceValue | null;
|
||||
biaClass: string | null;
|
||||
biaClassNormalized: number | null;
|
||||
discrepancyScore: number;
|
||||
discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data';
|
||||
}> = [];
|
||||
|
||||
// Process each application directly from the objects we already have
|
||||
for (const app of allApps) {
|
||||
if (!app.id || !app.label) continue;
|
||||
|
||||
// Extract Business Importance from app object
|
||||
const businessImportanceRef = toReferenceValue(app.businessImportance);
|
||||
const businessImportanceName = businessImportanceRef?.name || null;
|
||||
|
||||
// Normalize Business Importance
|
||||
let biNormalized: number | null = null;
|
||||
if (businessImportanceName) {
|
||||
// Try to find matching ReferenceValue
|
||||
const matchedRef = businessImportanceRefs.find(ref => ref.name === businessImportanceName);
|
||||
if (matchedRef) {
|
||||
biNormalized = biNormalizationMap.get(matchedRef.name) ?? null;
|
||||
} else {
|
||||
// Fallback: try to extract directly from the string
|
||||
const directMatch = businessImportanceName.match(/^(\d+)\s*-/);
|
||||
if (directMatch) {
|
||||
const numValue = parseInt(directMatch[1], 10);
|
||||
if (numValue >= 0 && numValue <= 6) {
|
||||
biNormalized = numValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract BIA from app object
|
||||
const businessImpactAnalyseRef = toReferenceValue(app.businessImpactAnalyse);
|
||||
|
||||
// Normalize BIA Class
|
||||
let biaClass: string | null = null;
|
||||
let biaNormalized: number | null = null;
|
||||
if (businessImpactAnalyseRef?.name) {
|
||||
// Extract class letter from name (e.g., "BIA-2024-0042 (Klasse E)" -> "E")
|
||||
const biaMatch = businessImpactAnalyseRef.name.match(/Klasse\s+([A-F])/i);
|
||||
if (biaMatch) {
|
||||
biaClass = biaMatch[1].toUpperCase();
|
||||
// Convert to numeric: A=1, B=2, C=3, D=4, E=5, F=6
|
||||
biaNormalized = biaClass.charCodeAt(0) - 64; // A=65, so 65-64=1, etc.
|
||||
} else {
|
||||
// Try to extract single letter if format is different
|
||||
const singleLetterMatch = businessImpactAnalyseRef.name.match(/\b([A-F])\b/i);
|
||||
if (singleLetterMatch) {
|
||||
biaClass = singleLetterMatch[1].toUpperCase();
|
||||
biaNormalized = biaClass.charCodeAt(0) - 64;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate discrepancy
|
||||
let discrepancyScore = 0;
|
||||
let discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data' = 'missing_data';
|
||||
|
||||
if (biNormalized !== null && biaNormalized !== null) {
|
||||
discrepancyScore = Math.abs(biNormalized - biaNormalized);
|
||||
|
||||
// Categorize discrepancy
|
||||
if (biNormalized <= 2 && biaNormalized <= 2) {
|
||||
// High BI (0-2: Critical Infrastructure/Critical/Highest) AND Low BIA (A-B: Low impact)
|
||||
// IT thinks critical (0-2) but business says low impact (A-B)
|
||||
discrepancyCategory = 'high_bi_low_bia';
|
||||
} else if (biNormalized >= 5 && biaNormalized >= 5) {
|
||||
// Low BI (5-6: Low/Lowest) AND High BIA (E-F: High impact)
|
||||
// IT thinks low priority (5-6) but business says high impact (E-F)
|
||||
discrepancyCategory = 'low_bi_high_bia';
|
||||
} else if (discrepancyScore <= 2) {
|
||||
// Aligned: values are reasonably close (discrepancy ≤ 2)
|
||||
discrepancyCategory = 'aligned';
|
||||
} else {
|
||||
// Medium discrepancy (3-4) - still consider aligned if not in extreme categories
|
||||
discrepancyCategory = 'aligned';
|
||||
}
|
||||
}
|
||||
|
||||
comparisonItems.push({
|
||||
id: app.id,
|
||||
key: app.objectKey,
|
||||
name: app.label,
|
||||
searchReference: app.searchReference || null,
|
||||
businessImportance: businessImportanceName,
|
||||
businessImportanceNormalized: biNormalized,
|
||||
businessImpactAnalyse: businessImpactAnalyseRef,
|
||||
biaClass,
|
||||
biaClassNormalized: biaNormalized,
|
||||
discrepancyScore,
|
||||
discrepancyCategory,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const total = comparisonItems.length;
|
||||
const withBothFields = comparisonItems.filter(item =>
|
||||
item.businessImportanceNormalized !== null && item.biaClassNormalized !== null
|
||||
).length;
|
||||
const highBiLowBia = comparisonItems.filter(item => item.discrepancyCategory === 'high_bi_low_bia').length;
|
||||
const lowBiHighBia = comparisonItems.filter(item => item.discrepancyCategory === 'low_bi_high_bia').length;
|
||||
const aligned = comparisonItems.filter(item => item.discrepancyCategory === 'aligned').length;
|
||||
const missingData = comparisonItems.filter(item => item.discrepancyCategory === 'missing_data').length;
|
||||
|
||||
return {
|
||||
applications: comparisonItems,
|
||||
summary: {
|
||||
total,
|
||||
withBothFields,
|
||||
highBiLowBia,
|
||||
lowBiHighBia,
|
||||
aligned,
|
||||
missingData,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user