UI styling improvements: dashboard headers and navigation

- Restore blue PageHeader on Dashboard (/app-components)
- Update homepage (/) with subtle header design without blue bar
- Add uniform PageHeader styling to application edit page
- Fix Rapporten link on homepage to point to /reports overview
- Improve header descriptions spacing for better readability
This commit is contained in:
2026-01-21 03:24:56 +01:00
parent e276e77fbc
commit cdee0e8819
138 changed files with 24551 additions and 3352 deletions

View File

@@ -1,17 +1,16 @@
/**
* DataService - Main entry point for application data access
*
* Routes requests to either:
* - CMDBService (using local cache) for real Jira data
* - MockDataService for development without Jira
* ALWAYS uses Jira Assets API via CMDBService (local cache layer).
* Mock data has been removed - all data must come from Jira Assets.
*/
import { config } from '../config/env.js';
import { cmdbService, type UpdateResult } from './cmdbService.js';
import { cacheStore, type CacheStats } from './cacheStore.js';
import { normalizedCacheStore as cacheStore, type CacheStats } from './normalizedCacheStore.js';
import { normalizedCacheStore } from './normalizedCacheStore.js';
import { jiraAssetsClient } from './jiraAssetsClient.js';
import { jiraAssetsService } from './jiraAssets.js';
import { mockDataService } from './mockData.js';
import { logger } from './logger.js';
import type {
ApplicationComponent,
@@ -47,16 +46,8 @@ import type {
import { calculateRequiredEffortWithMinMax } from './effortCalculation.js';
import { calculateApplicationCompleteness } from './dataCompletenessConfig.js';
// Determine if we should use real Jira Assets or mock data
// Jira PAT is now configured per-user, so we check if schema is configured
// The actual PAT is provided per-request via middleware
const useJiraAssets = !!config.jiraSchemaId;
if (useJiraAssets) {
logger.info('DataService: Using CMDB cache layer with Jira Assets API');
} else {
logger.info('DataService: Using mock data (Jira credentials not configured)');
}
// NOTE: All data comes from Jira Assets API - no mock data fallback
// If schemas aren't configured yet, operations will fail gracefully with appropriate errors
// =============================================================================
// Reference Cache (for enriching IDs to ObjectReferences)
@@ -121,42 +112,111 @@ async function lookupReferences<T extends CMDBObject>(
// Helper Functions
// =============================================================================
/**
* Load description for an object from database
* Looks for a description attribute (field_name like 'description' or attr_name like 'Description')
*/
async function getDescriptionFromDatabase(objectId: string): Promise<string | null> {
try {
const { normalizedCacheStore } = await import('./normalizedCacheStore.js');
const db = (normalizedCacheStore as any).db;
if (!db) return null;
// Try to find description attribute by common field names
const descriptionFieldNames = ['description', 'Description', 'DESCRIPTION'];
// First, get the object to find its type
const objRow = await db.queryOne<{ object_type_name: string }>(`
SELECT object_type_name FROM objects WHERE id = ?
`, [objectId]);
if (!objRow) return null;
// Try each possible description field name
for (const fieldName of descriptionFieldNames) {
const descRow = await db.queryOne<{ text_value: string }>(`
SELECT av.text_value
FROM attribute_values av
JOIN attributes a ON av.attribute_id = a.id
WHERE av.object_id = ?
AND (a.field_name = ? OR a.attr_name = ?)
AND av.text_value IS NOT NULL
AND av.text_value != ''
LIMIT 1
`, [objectId, fieldName, fieldName]);
if (descRow?.text_value) {
return descRow.text_value;
}
}
return null;
} catch (error) {
logger.debug(`Failed to get description from database for object ${objectId}`, error);
return null;
}
}
/**
* Convert ObjectReference to ReferenceValue format used by frontend
* Try to enrich with description from jiraAssetsService cache if available
* If not in cache or cache entry has no description, fetch it async
* PRIMARY: Load from database cache (no API calls)
* FALLBACK: Only use API if object not in database
*/
async function toReferenceValue(ref: ObjectReference | null | undefined): Promise<ReferenceValue | null> {
if (!ref) return null;
// Try to get enriched ReferenceValue from jiraAssetsService cache (includes description if available)
const enriched = useJiraAssets ? jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId) : null;
// PRIMARY SOURCE: Try to load from database first (no API calls)
try {
const { normalizedCacheStore } = await import('./normalizedCacheStore.js');
const db = (normalizedCacheStore as any).db;
if (db) {
await db.ensureInitialized?.();
// Get basic object info from database
const objRow = await db.queryOne<{
id: string;
object_key: string;
label: string;
}>(`
SELECT id, object_key, label
FROM objects
WHERE id = ? OR object_key = ?
LIMIT 1
`, [ref.objectId, ref.objectKey]);
if (objRow) {
// Object exists in database - extract description if available
const description = await getDescriptionFromDatabase(objRow.id);
return {
objectId: objRow.id,
key: objRow.object_key || ref.objectKey,
name: objRow.label || ref.label,
...(description && { description }),
};
}
}
} catch (error) {
logger.debug(`Failed to load reference object ${ref.objectId} from database`, error);
}
// FALLBACK: Object not in database - check Jira Assets service cache
// Only fetch from API if really needed (object missing from database)
const enriched = jiraAssetsService.getEnrichedReferenceValue(ref.objectKey, ref.objectId);
if (enriched && enriched.description) {
// Use enriched value with description
// Use enriched value with description from service cache
return enriched;
}
// Cache miss or no description - fetch it async if using Jira Assets
if (useJiraAssets && enriched && !enriched.description) {
// We have a cached value but it lacks description - fetch it
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
if (fetched) {
return fetched;
}
// If fetch failed, return the cached value anyway
// Last resort: Object not in database and not in service cache
// Only return basic info - don't fetch from API here
// API fetching should only happen during sync operations
if (enriched) {
return enriched;
}
if (useJiraAssets) {
// Cache miss - fetch it
const fetched = await jiraAssetsService.fetchEnrichedReferenceValue(ref.objectKey, ref.objectId);
if (fetched) {
return fetched;
}
}
// Fallback to basic conversion without description (if fetch failed or not using Jira Assets)
// Basic fallback - return what we have from the ObjectReference
return {
objectId: ref.objectId,
key: ref.objectKey,
@@ -172,7 +232,8 @@ function toReferenceValues(refs: ObjectReference[] | null | undefined): Referenc
return refs.map(ref => ({
objectId: ref.objectId,
key: ref.objectKey,
name: ref.label,
// Use label if available, otherwise fall back to objectKey, then objectId
name: ref.label || ref.objectKey || ref.objectId || 'Unknown',
}));
}
@@ -225,6 +286,18 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
logger.info(`[toApplicationDetails] Converting cached object ${app.objectKey || app.id} to ApplicationDetails`);
logger.info(`[toApplicationDetails] confluenceSpace from cache: ${app.confluenceSpace} (type: ${typeof app.confluenceSpace})`);
// Debug logging for reference fields
if (process.env.NODE_ENV === 'development') {
logger.debug(`[toApplicationDetails] businessOwner: ${JSON.stringify(app.businessOwner)}`);
logger.debug(`[toApplicationDetails] systemOwner: ${JSON.stringify(app.systemOwner)}`);
logger.debug(`[toApplicationDetails] technicalApplicationManagement: ${JSON.stringify(app.technicalApplicationManagement)}`);
logger.debug(`[toApplicationDetails] supplierProduct: ${JSON.stringify(app.supplierProduct)}`);
logger.debug(`[toApplicationDetails] applicationFunction: ${JSON.stringify(app.applicationFunction)}`);
logger.debug(`[toApplicationDetails] applicationManagementDynamicsFactor: ${JSON.stringify(app.applicationManagementDynamicsFactor)}`);
logger.debug(`[toApplicationDetails] applicationManagementComplexityFactor: ${JSON.stringify(app.applicationManagementComplexityFactor)}`);
logger.debug(`[toApplicationDetails] applicationManagementNumberOfUsers: ${JSON.stringify(app.applicationManagementNumberOfUsers)}`);
}
// Handle confluenceSpace - it can be a string (URL) or number (legacy), convert to string
const confluenceSpaceValue = app.confluenceSpace !== null && app.confluenceSpace !== undefined
? (typeof app.confluenceSpace === 'string' ? app.confluenceSpace : String(app.confluenceSpace))
@@ -302,57 +375,17 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
// Convert array of ObjectReferences to ReferenceValue[]
const applicationFunctions = toReferenceValues(app.applicationFunction);
return {
id: app.id,
key: app.objectKey,
name: app.label,
description: app.description || null,
status: (app.status || 'In Production') as ApplicationStatus,
searchReference: app.searchReference || null,
// Organization info
organisation: organisation?.name || null,
businessOwner: extractLabel(app.businessOwner),
systemOwner: extractLabel(app.systemOwner),
functionalApplicationManagement: app.functionalApplicationManagement || null,
technicalApplicationManagement: extractLabel(app.technicalApplicationManagement),
technicalApplicationManagementPrimary: extractDisplayValue(app.technicalApplicationManagementPrimary),
technicalApplicationManagementSecondary: extractDisplayValue(app.technicalApplicationManagementSecondary),
// Technical info
medischeTechniek: app.medischeTechniek || false,
technischeArchitectuur: app.technischeArchitectuurTA || null,
supplierProduct: extractLabel(app.supplierProduct),
// Classification
applicationFunctions,
businessImportance: businessImportance?.name || null,
businessImpactAnalyse,
hostingType,
// Application Management
governanceModel,
applicationType,
applicationSubteam,
applicationTeam,
dynamicsFactor,
complexityFactor,
numberOfUsers,
applicationManagementHosting,
applicationManagementTAM,
platform,
// Override
overrideFTE: app.applicationManagementOverrideFTE ?? null,
requiredEffortApplicationManagement: null,
// Enterprise Architect reference
reference: app.reference || null,
// Confluence Space (URL string)
confluenceSpace: confluenceSpaceValue,
};
// Convert supplier fields to ReferenceValue format
const [
supplierTechnical,
supplierImplementation,
supplierConsultancy,
] = await Promise.all([
toReferenceValue(app.supplierTechnical),
toReferenceValue(app.supplierImplementation),
toReferenceValue(app.supplierConsultancy),
]);
// Calculate data completeness percentage
// Convert ApplicationDetails-like structure to format expected by completeness calculator
@@ -399,6 +432,9 @@ async function toApplicationDetails(app: ApplicationComponent): Promise<Applicat
medischeTechniek: app.medischeTechniek || false,
technischeArchitectuur: app.technischeArchitectuurTA || null,
supplierProduct: extractLabel(app.supplierProduct),
supplierTechnical: supplierTechnical,
supplierImplementation: supplierImplementation,
supplierConsultancy: supplierConsultancy,
// Classification
applicationFunctions,
@@ -659,22 +695,31 @@ export const dataService = {
page: number = 1,
pageSize: number = 25
): Promise<SearchResult> {
if (!useJiraAssets) {
return mockDataService.searchApplications(filters, page, pageSize);
}
// Get all applications from cache
// Get all applications from cache (always from Jira Assets)
let apps = await cmdbService.getObjects<ApplicationComponent>('ApplicationComponent');
logger.debug(`DataService: Found ${apps.length} applications in cache for search`);
// If cache is empty, log a warning
if (apps.length === 0) {
logger.warn('DataService: Cache is empty - no applications found. A full sync may be needed.');
}
// Apply filters locally
if (filters.searchText) {
const search = filters.searchText.toLowerCase();
apps = apps.filter(app =>
app.label.toLowerCase().includes(search) ||
app.objectKey.toLowerCase().includes(search) ||
app.searchReference?.toLowerCase().includes(search) ||
app.description?.toLowerCase().includes(search)
);
if (filters.searchText && filters.searchText.trim()) {
const search = filters.searchText.toLowerCase().trim();
const beforeFilter = apps.length;
apps = apps.filter(app => {
const label = app.label?.toLowerCase() || '';
const objectKey = app.objectKey?.toLowerCase() || '';
const searchRef = app.searchReference?.toLowerCase() || '';
const description = app.description?.toLowerCase() || '';
return label.includes(search) ||
objectKey.includes(search) ||
searchRef.includes(search) ||
description.includes(search);
});
logger.debug(`DataService: Search filter "${filters.searchText}" reduced results from ${beforeFilter} to ${apps.length}`);
}
if (filters.statuses && filters.statuses.length > 0) {
@@ -834,11 +879,14 @@ export const dataService = {
* Get application by ID (from cache)
*/
async getApplicationById(id: string): Promise<ApplicationDetails | null> {
if (!useJiraAssets) {
return mockDataService.getApplicationById(id);
// Try to get by ID first (handles both Jira object IDs and object keys)
let app = await cmdbService.getObject<ApplicationComponent>('ApplicationComponent', id);
// If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys)
if (!app) {
app = await cmdbService.getObjectByKey<ApplicationComponent>('ApplicationComponent', id);
}
const app = await cmdbService.getObject<ApplicationComponent>('ApplicationComponent', id);
if (!app) return null;
return toApplicationDetails(app);
@@ -848,13 +896,18 @@ export const dataService = {
* Get application for editing (force refresh from Jira)
*/
async getApplicationForEdit(id: string): Promise<ApplicationDetails | null> {
if (!useJiraAssets) {
return mockDataService.getApplicationById(id);
}
const app = await cmdbService.getObject<ApplicationComponent>('ApplicationComponent', id, {
// Try to get by ID first (handles both Jira object IDs and object keys)
let app = await cmdbService.getObject<ApplicationComponent>('ApplicationComponent', id, {
forceRefresh: true,
});
// If not found by ID, try by object key (e.g., "ICMT-123" or numeric IDs that might be keys)
if (!app) {
app = await cmdbService.getObjectByKey<ApplicationComponent>('ApplicationComponent', id, {
forceRefresh: true,
});
}
if (!app) return null;
return toApplicationDetails(app);
@@ -884,11 +937,7 @@ export const dataService = {
): Promise<UpdateResult> {
logger.info(`dataService.updateApplication called for ${id}`);
if (!useJiraAssets) {
const success = await mockDataService.updateApplication(id, updates);
return { success };
}
// Always update via Jira Assets API
// Convert to CMDBService format
// IMPORTANT: For reference fields, we pass ObjectReference objects (with objectKey)
// because buildAttributeValues in cmdbService expects to extract objectKey for Jira API
@@ -978,7 +1027,7 @@ export const dataService = {
// ===========================================================================
async getDynamicsFactors(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getDynamicsFactors();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementDynamicsFactor>('ApplicationManagementDynamicsFactor');
return items.map(item => ({
objectId: item.id,
@@ -991,7 +1040,7 @@ export const dataService = {
},
async getComplexityFactors(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getComplexityFactors();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementComplexityFactor>('ApplicationManagementComplexityFactor');
return items.map(item => ({
objectId: item.id,
@@ -1004,7 +1053,7 @@ export const dataService = {
},
async getNumberOfUsers(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getNumberOfUsers();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementNumberOfUsers>('ApplicationManagementNumberOfUsers');
return items.map(item => ({
objectId: item.id,
@@ -1017,7 +1066,7 @@ export const dataService = {
},
async getGovernanceModels(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getGovernanceModels();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<IctGovernanceModel>('IctGovernanceModel');
return items.map(item => ({
objectId: item.id,
@@ -1030,24 +1079,26 @@ export const dataService = {
},
async getOrganisations(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getOrganisations();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<Organisation>('Organisation');
logger.debug(`DataService: Found ${items.length} organisations in cache`);
return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label }));
},
async getHostingTypes(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getHostingTypes();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<HostingType>('HostingType');
return items.map(item => ({
objectId: item.id,
key: item.objectKey,
logger.debug(`DataService: Found ${items.length} hosting types in cache`);
return items.map(item => ({
objectId: item.id,
key: item.objectKey,
name: item.label,
summary: item.description || undefined, // Use description as summary for display
}));
},
async getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getBusinessImpactAnalyses();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<BusinessImpactAnalyse>('BusinessImpactAnalyse');
return items.map(item => ({
objectId: item.id,
@@ -1059,7 +1110,7 @@ export const dataService = {
},
async getApplicationManagementHosting(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getApplicationManagementHosting();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementHosting>('ApplicationManagementHosting');
return items.map(item => ({
objectId: item.id,
@@ -1070,7 +1121,7 @@ export const dataService = {
},
async getApplicationManagementTAM(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getApplicationManagementTAM();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementTam>('ApplicationManagementTam');
return items.map(item => ({
objectId: item.id,
@@ -1081,7 +1132,7 @@ export const dataService = {
},
async getApplicationFunctions(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getApplicationFunctions();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationFunction>('ApplicationFunction');
return items.map(item => ({
objectId: item.id,
@@ -1098,7 +1149,7 @@ export const dataService = {
},
async getApplicationFunctionCategories(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getApplicationFunctionCategories();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationFunctionCategory>('ApplicationFunctionCategory');
return items.map(item => ({
objectId: item.id,
@@ -1109,19 +1160,17 @@ export const dataService = {
},
async getApplicationSubteams(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return []; // Mock mode: no subteams
// Use jiraAssetsService directly as schema doesn't include this object type
// Always get from Jira Assets API (schema doesn't include this object type)
return jiraAssetsService.getApplicationSubteams();
},
async getApplicationTeams(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return []; // Mock mode: no teams
// Use jiraAssetsService directly as schema doesn't include this object type
// Always get from Jira Assets API (schema doesn't include this object type)
return jiraAssetsService.getApplicationTeams();
},
async getSubteamToTeamMapping(): Promise<Record<string, ReferenceValue | null>> {
if (!useJiraAssets) return {}; // Mock mode: no mapping
// Always get from Jira Assets API
// Convert Map to plain object for JSON serialization
const mapping = await jiraAssetsService.getSubteamToTeamMapping();
const result: Record<string, ReferenceValue | null> = {};
@@ -1132,7 +1181,7 @@ export const dataService = {
},
async getApplicationTypes(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getApplicationTypes();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<ApplicationManagementApplicationType>('ApplicationManagementApplicationType');
return items.map(item => ({
objectId: item.id,
@@ -1143,8 +1192,9 @@ export const dataService = {
},
async getBusinessImportance(): Promise<ReferenceValue[]> {
if (!useJiraAssets) return mockDataService.getBusinessImportance();
// Always get from Jira Assets cache
const items = await cmdbService.getObjects<BusinessImportance>('BusinessImportance');
logger.debug(`DataService: Found ${items.length} business importance values in cache`);
return items.map(item => ({ objectId: item.id, key: item.objectKey, name: item.label }));
},
@@ -1153,8 +1203,7 @@ export const dataService = {
// ===========================================================================
async getStats(includeDistributions: boolean = true) {
if (!useJiraAssets) return mockDataService.getStats();
// Always get from Jira Assets cache
const allApps = await cmdbService.getObjects<ApplicationComponent>('ApplicationComponent');
// Statuses to exclude for most metrics
@@ -1231,9 +1280,7 @@ export const dataService = {
},
async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
if (!useJiraAssets) return mockDataService.getTeamDashboardData(excludedStatuses);
// Use jiraAssetsService directly as it has proper Team/Subteam field parsing
// Always get from Jira Assets API (has proper Team/Subteam field parsing)
return jiraAssetsService.getTeamDashboardData(excludedStatuses);
},
@@ -1253,7 +1300,7 @@ export const dataService = {
applicationCount: number;
}>;
}> {
// For mock data, use the same implementation (cmdbService routes to mock data when useJiraAssets is false)
// Always get from Jira Assets cache
// Get all applications from cache to access all fields including BIA
let apps = await cmdbService.getObjects<ApplicationComponent>('ApplicationComponent');
@@ -1421,13 +1468,13 @@ export const dataService = {
// Utility
// ===========================================================================
isUsingJiraAssets(): boolean {
return useJiraAssets;
async isUsingJiraAssets(): Promise<boolean> {
// Always returns true - mock data removed, only Jira Assets is used
return true;
},
async testConnection(): Promise<boolean> {
if (!useJiraAssets) return true;
// Only test connection if token is configured
// Always test Jira Assets connection (requires token)
if (!jiraAssetsClient.hasToken()) {
return false;
}