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:
2026-01-14 00:38:40 +01:00
parent ca21b9538d
commit a7f8301196
73 changed files with 12878 additions and 2003 deletions

View File

@@ -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,
},
};
},
};