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

@@ -8,10 +8,12 @@
*/
import { logger } from './logger.js';
import { cacheStore } from './cacheStore.js';
import { normalizedCacheStore as cacheStore } from './normalizedCacheStore.js';
import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js';
import { OBJECT_TYPES, getObjectTypesBySyncPriority } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
import { schemaDiscoveryService } from './schemaDiscoveryService.js';
import type { ObjectEntry } from '../domain/jiraAssetsPayload.js';
// =============================================================================
// Types
@@ -61,6 +63,7 @@ class SyncEngine {
private incrementalInterval: number;
private batchSize: number;
private lastIncrementalSync: Date | null = null;
private lastConfigCheck: number = 0; // Track last config check time to avoid log spam
constructor() {
this.incrementalInterval = parseInt(
@@ -93,7 +96,26 @@ class SyncEngine {
logger.info('SyncEngine: Sync uses service account token (JIRA_SERVICE_ACCOUNT_TOKEN) from .env');
this.isRunning = true;
// Sync can run automatically using service account token
// Check if configuration is complete before starting scheduler
const { schemaConfigurationService } = await import('./schemaConfigurationService.js');
const isConfigured = await schemaConfigurationService.isConfigurationComplete();
// Start incremental sync scheduler if token is available AND configuration is complete
if (jiraAssetsClient.hasToken()) {
if (isConfigured) {
this.startIncrementalSyncScheduler();
logger.info('SyncEngine: Incremental sync scheduler started (configuration complete)');
} else {
logger.info('SyncEngine: Incremental sync scheduler NOT started - schema configuration not complete. Please configure object types in settings first.');
// Start scheduler but it will check configuration on each run
// This allows scheduler to start automatically when configuration is completed later
this.startIncrementalSyncScheduler();
logger.info('SyncEngine: Incremental sync scheduler started (will check configuration on each run)');
}
} else {
logger.info('SyncEngine: Service account token not configured, incremental sync disabled');
}
logger.info('SyncEngine: Initialized (using service account token for sync operations)');
}
@@ -163,14 +185,42 @@ class SyncEngine {
logger.info('SyncEngine: Starting full sync...');
try {
// Get object types sorted by sync priority
const objectTypes = getObjectTypesBySyncPriority();
// Check if configuration is complete
const { schemaConfigurationService } = await import('./schemaConfigurationService.js');
const isConfigured = await schemaConfigurationService.isConfigurationComplete();
if (!isConfigured) {
throw new Error('Schema configuration not complete. Please configure at least one object type to be synced in the settings page.');
}
for (const typeDef of objectTypes) {
const typeStat = await this.syncObjectType(typeDef.typeName as CMDBObjectTypeName);
stats.push(typeStat);
totalObjects += typeStat.objectsProcessed;
totalRelations += typeStat.relationsExtracted;
// Get enabled object types from configuration
logger.info('SyncEngine: Fetching enabled object types from configuration...');
const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes();
logger.info(`SyncEngine: Found ${enabledTypes.length} enabled object types to sync`);
if (enabledTypes.length === 0) {
throw new Error('No object types enabled for syncing. Please enable at least one object type in the settings page.');
}
// Schema discovery will happen automatically when needed (e.g., for relation extraction)
// It's no longer required upfront - the user has already configured which object types to sync
logger.info('SyncEngine: Starting object sync for configured object types...');
// Sync each enabled object type
for (const enabledType of enabledTypes) {
try {
const typeStat = await this.syncConfiguredObjectType(enabledType);
stats.push(typeStat);
totalObjects += typeStat.objectsProcessed;
totalRelations += typeStat.relationsExtracted;
} catch (error) {
logger.error(`SyncEngine: Failed to sync ${enabledType.displayName}`, error);
stats.push({
objectType: enabledType.displayName,
objectsProcessed: 0,
relationsExtracted: 0,
duration: 0,
});
}
}
// Update sync metadata
@@ -205,81 +255,216 @@ class SyncEngine {
}
/**
* Sync a single object type
* Store an object and all its nested referenced objects recursively
* This method processes the entire object tree, storing all nested objects
* and extracting all relations, while preventing infinite loops with circular references.
*
* @param entry - The object entry to store (in ObjectEntry format from API)
* @param typeName - The type name of the object
* @param processedIds - Set of already processed object IDs (to prevent duplicates and circular refs)
* @returns Statistics about objects stored and relations extracted
*/
private async syncObjectType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
private async storeObjectTree(
entry: ObjectEntry,
typeName: CMDBObjectTypeName,
processedIds: Set<string>
): Promise<{ objectsStored: number; relationsExtracted: number }> {
const entryId = String(entry.id);
// Skip if already processed (handles circular references)
if (processedIds.has(entryId)) {
logger.debug(`SyncEngine: Skipping already processed object ${entry.objectKey || entryId} of type ${typeName}`);
return { objectsStored: 0, relationsExtracted: 0 };
}
processedIds.add(entryId);
let objectsStored = 0;
let relationsExtracted = 0;
try {
logger.debug(`SyncEngine: [Recursive] Storing object tree for ${entry.objectKey || entryId} of type ${typeName} (depth: ${processedIds.size - 1})`);
// 1. Adapt and parse the object
const adapted = jiraAssetsClient.adaptObjectEntryToJiraAssetsObject(entry);
if (!adapted) {
logger.warn(`SyncEngine: Failed to adapt object ${entry.objectKey || entryId}`);
return { objectsStored: 0, relationsExtracted: 0 };
}
const parsed = await jiraAssetsClient.parseObject(adapted);
if (!parsed) {
logger.warn(`SyncEngine: Failed to parse object ${entry.objectKey || entryId}`);
return { objectsStored: 0, relationsExtracted: 0 };
}
// 2. Store the object
await cacheStore.upsertObject(typeName, parsed);
objectsStored++;
logger.debug(`SyncEngine: Stored object ${parsed.objectKey || parsed.id} of type ${typeName}`);
// 3. Schema discovery must be manually triggered via API endpoints
// No automatic discovery
// 4. Extract and store relations for this object
await cacheStore.extractAndStoreRelations(typeName, parsed);
relationsExtracted++;
logger.debug(`SyncEngine: Extracted relations for object ${parsed.objectKey || parsed.id}`);
// 5. Recursively process nested referenced objects
// Note: Lookup maps should already be initialized by getAllObjectsOfType
// Use a separate Set for extraction to avoid conflicts with storage tracking
const extractionProcessedIds = new Set<string>();
const nestedRefs = jiraAssetsClient.extractNestedReferencedObjects(
entry,
extractionProcessedIds, // Separate Set for extraction (prevents infinite loops in traversal)
5, // max depth
0 // current depth
);
if (nestedRefs.length > 0) {
logger.debug(`SyncEngine: [Recursive] Found ${nestedRefs.length} nested referenced objects for ${entry.objectKey || entryId}`);
// Group by type for better logging
const refsByType = new Map<string, number>();
for (const ref of nestedRefs) {
refsByType.set(ref.typeName, (refsByType.get(ref.typeName) || 0) + 1);
}
const typeSummary = Array.from(refsByType.entries())
.map(([type, count]) => `${count} ${type}`)
.join(', ');
logger.debug(`SyncEngine: [Recursive] Nested objects by type: ${typeSummary}`);
}
// 6. Recursively store each nested object
for (const { entry: nestedEntry, typeName: nestedTypeName } of nestedRefs) {
logger.debug(`SyncEngine: [Recursive] Processing nested object ${nestedEntry.objectKey || nestedEntry.id} of type ${nestedTypeName}`);
const nestedResult = await this.storeObjectTree(
nestedEntry,
nestedTypeName as CMDBObjectTypeName,
processedIds
);
objectsStored += nestedResult.objectsStored;
relationsExtracted += nestedResult.relationsExtracted;
}
logger.debug(`SyncEngine: [Recursive] Completed storing object tree for ${entry.objectKey || entryId}: ${objectsStored} objects, ${relationsExtracted} relations`);
return { objectsStored, relationsExtracted };
} catch (error) {
logger.error(`SyncEngine: Failed to store object tree for ${entry.objectKey || entryId}`, error);
return { objectsStored, relationsExtracted };
}
}
/**
* Sync a configured object type (from schema configuration)
*/
private async syncConfiguredObjectType(enabledType: {
schemaId: string;
objectTypeId: number;
objectTypeName: string;
displayName: string;
}): Promise<SyncStats> {
const startTime = Date.now();
let objectsProcessed = 0;
let relationsExtracted = 0;
try {
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
logger.warn(`SyncEngine: Unknown type ${typeName}`);
return { objectType: typeName, objectsProcessed: 0, relationsExtracted: 0, duration: 0 };
}
logger.info(`SyncEngine: Syncing ${enabledType.displayName} (${enabledType.objectTypeName}) from schema ${enabledType.schemaId}...`);
logger.debug(`SyncEngine: Syncing ${typeName}...`);
// Fetch all objects from Jira using the configured schema and object type
// This returns raw entries for recursive processing (includeAttributesDeep=2 provides nested data)
const { objects: jiraObjects, rawEntries } = await jiraAssetsClient.getAllObjectsOfType(
enabledType.displayName, // Use display name for Jira API
this.batchSize,
enabledType.schemaId
);
logger.info(`SyncEngine: Fetched ${jiraObjects.length} ${enabledType.displayName} objects from Jira (schema: ${enabledType.schemaId})`);
// Fetch all objects from Jira
const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize);
logger.info(`SyncEngine: Fetched ${jiraObjects.length} ${typeName} objects from Jira`);
// Schema discovery must be manually triggered via API endpoints
// No automatic discovery
// Parse and cache objects
const parsedObjects: CMDBObject[] = [];
// Use objectTypeName for cache storage (PascalCase)
const typeName = enabledType.objectTypeName as CMDBObjectTypeName;
// Process each main object recursively using storeObjectTree
// This will store the object and all its nested referenced objects
const processedIds = new Set<string>(); // Track processed objects to prevent duplicates and circular refs
const failedObjects: Array<{ id: string; key: string; label: string; reason: string }> = [];
for (const jiraObj of jiraObjects) {
const parsed = jiraAssetsClient.parseObject(jiraObj);
if (parsed) {
parsedObjects.push(parsed);
} else {
// Track objects that failed to parse
failedObjects.push({
id: jiraObj.id?.toString() || 'unknown',
key: jiraObj.objectKey || 'unknown',
label: jiraObj.label || 'unknown',
reason: 'parseObject returned null',
});
logger.warn(`SyncEngine: Failed to parse ${typeName} object: ${jiraObj.objectKey || jiraObj.id} (${jiraObj.label || 'unknown label'})`);
if (rawEntries && rawEntries.length > 0) {
logger.info(`SyncEngine: Processing ${rawEntries.length} ${enabledType.displayName} objects recursively...`);
for (const rawEntry of rawEntries) {
try {
const result = await this.storeObjectTree(rawEntry, typeName, processedIds);
objectsProcessed += result.objectsStored;
relationsExtracted += result.relationsExtracted;
} catch (error) {
const entryId = String(rawEntry.id);
failedObjects.push({
id: entryId,
key: rawEntry.objectKey || 'unknown',
label: rawEntry.label || 'unknown',
reason: error instanceof Error ? error.message : 'Unknown error',
});
logger.warn(`SyncEngine: Failed to store object tree for ${enabledType.displayName} object: ${rawEntry.objectKey || entryId} (${rawEntry.label || 'unknown label'})`, error);
}
}
} else {
// Fallback: if rawEntries not available, use adapted objects (less efficient, no recursion)
logger.warn(`SyncEngine: Raw entries not available, using fallback linear processing (no recursive nesting)`);
const parsedObjects: CMDBObject[] = [];
for (const jiraObj of jiraObjects) {
const parsed = await jiraAssetsClient.parseObject(jiraObj);
if (parsed) {
parsedObjects.push(parsed);
} else {
failedObjects.push({
id: jiraObj.id?.toString() || 'unknown',
key: jiraObj.objectKey || 'unknown',
label: jiraObj.label || 'unknown',
reason: 'parseObject returned null',
});
logger.warn(`SyncEngine: Failed to parse ${enabledType.displayName} object: ${jiraObj.objectKey || jiraObj.id} (${jiraObj.label || 'unknown label'})`);
}
}
if (parsedObjects.length > 0) {
await cacheStore.batchUpsertObjects(typeName, parsedObjects);
objectsProcessed = parsedObjects.length;
// Extract relations
for (const obj of parsedObjects) {
await cacheStore.extractAndStoreRelations(typeName, obj);
relationsExtracted++;
}
}
}
// Log parsing statistics
if (failedObjects.length > 0) {
logger.warn(`SyncEngine: ${failedObjects.length} ${typeName} objects failed to parse:`, failedObjects.map(o => `${o.key} (${o.label})`).join(', '));
}
// Batch upsert to cache
if (parsedObjects.length > 0) {
await cacheStore.batchUpsertObjects(typeName, parsedObjects);
objectsProcessed = parsedObjects.length;
// Extract relations
for (const obj of parsedObjects) {
await cacheStore.extractAndStoreRelations(typeName, obj);
relationsExtracted++;
}
logger.warn(`SyncEngine: ${failedObjects.length} ${enabledType.displayName} objects failed to process:`, failedObjects.map(o => `${o.key} (${o.label}): ${o.reason}`).join(', '));
}
const duration = Date.now() - startTime;
const skippedCount = jiraObjects.length - objectsProcessed;
if (skippedCount > 0) {
logger.warn(`SyncEngine: Synced ${objectsProcessed}/${jiraObjects.length} ${typeName} objects in ${duration}ms (${skippedCount} skipped)`);
logger.warn(`SyncEngine: Synced ${objectsProcessed}/${jiraObjects.length} ${enabledType.displayName} objects in ${duration}ms (${skippedCount} skipped)`);
} else {
logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`);
logger.debug(`SyncEngine: Synced ${objectsProcessed} ${enabledType.displayName} objects in ${duration}ms`);
}
return {
objectType: typeName,
objectType: enabledType.displayName,
objectsProcessed,
relationsExtracted,
duration,
};
} catch (error) {
logger.error(`SyncEngine: Failed to sync ${typeName}`, error);
logger.error(`SyncEngine: Failed to sync ${enabledType.displayName}`, error);
return {
objectType: typeName,
objectType: enabledType.displayName,
objectsProcessed,
relationsExtracted,
duration: Date.now() - startTime,
@@ -287,12 +472,27 @@ class SyncEngine {
}
}
/**
* Sync a single object type (legacy method, kept for backward compatibility)
*/
private async syncObjectType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
// This method is deprecated - use syncConfiguredObjectType instead
logger.warn(`SyncEngine: syncObjectType(${typeName}) is deprecated, use configured object types instead`);
return {
objectType: typeName,
objectsProcessed: 0,
relationsExtracted: 0,
duration: 0,
};
}
// ==========================================================================
// Incremental Sync
// ==========================================================================
/**
* Start the incremental sync scheduler
* The scheduler will check configuration on each run and only sync if configuration is complete
*/
private startIncrementalSyncScheduler(): void {
if (this.incrementalTimer) {
@@ -300,9 +500,11 @@ class SyncEngine {
}
logger.info(`SyncEngine: Starting incremental sync scheduler (every ${this.incrementalInterval}ms)`);
logger.info('SyncEngine: Scheduler will only perform syncs when schema configuration is complete');
this.incrementalTimer = setInterval(() => {
if (!this.isSyncing && this.isRunning) {
// incrementalSync() will check if configuration is complete before syncing
this.incrementalSync().catch(err => {
logger.error('SyncEngine: Incremental sync failed', err);
});
@@ -324,6 +526,26 @@ class SyncEngine {
return { success: false, updatedCount: 0 };
}
// Check if configuration is complete before attempting sync
const { schemaConfigurationService } = await import('./schemaConfigurationService.js');
const isConfigured = await schemaConfigurationService.isConfigurationComplete();
if (!isConfigured) {
// Don't log on every interval - only log once per minute to avoid spam
const now = Date.now();
if (!this.lastConfigCheck || now - this.lastConfigCheck > 60000) {
logger.debug('SyncEngine: Schema configuration not complete, skipping incremental sync. Please configure object types in settings.');
this.lastConfigCheck = now;
}
return { success: false, updatedCount: 0 };
}
// Get enabled object types - will be used later to filter updated objects
const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes();
if (enabledTypes.length === 0) {
logger.debug('SyncEngine: No enabled object types, skipping incremental sync');
return { success: false, updatedCount: 0 };
}
if (this.isSyncing) {
return { success: false, updatedCount: 0 };
}
@@ -339,6 +561,15 @@ class SyncEngine {
logger.debug(`SyncEngine: Incremental sync since ${since.toISOString()}`);
// Get enabled object types to filter incremental sync
const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes();
const enabledTypeNames = new Set(enabledTypes.map(et => et.objectTypeName));
if (enabledTypeNames.size === 0) {
logger.debug('SyncEngine: No enabled object types, skipping incremental sync');
return { success: false, updatedCount: 0 };
}
// Fetch updated objects from Jira
const updatedObjects = await jiraAssetsClient.getUpdatedObjectsSince(since, this.batchSize);
@@ -368,15 +599,49 @@ class SyncEngine {
return { success: true, updatedCount: 0 };
}
let updatedCount = 0;
// Schema discovery must be manually triggered via API endpoints
// No automatic discovery
let updatedCount = 0;
const processedIds = new Set<string>(); // Track processed objects for recursive sync
// Filter updated objects to only process enabled object types
// Use recursive processing to handle nested references
for (const jiraObj of updatedObjects) {
const parsed = jiraAssetsClient.parseObject(jiraObj);
const parsed = await jiraAssetsClient.parseObject(jiraObj);
if (parsed) {
const typeName = parsed._objectType as CMDBObjectTypeName;
await cacheStore.upsertObject(typeName, parsed);
await cacheStore.extractAndStoreRelations(typeName, parsed);
updatedCount++;
// Only sync if this object type is enabled
if (!enabledTypeNames.has(typeName)) {
logger.debug(`SyncEngine: Skipping ${typeName} in incremental sync - not enabled`);
continue;
}
// Get raw entry for recursive processing
const objectId = parsed.id;
try {
const entry = await jiraAssetsClient.getObjectEntry(objectId);
if (entry) {
// Use recursive storeObjectTree to process object and all nested references
const result = await this.storeObjectTree(entry, typeName, processedIds);
if (result.objectsStored > 0) {
updatedCount++;
logger.debug(`SyncEngine: Incremental sync processed ${objectId}: ${result.objectsStored} objects, ${result.relationsExtracted} relations`);
}
} else {
// Fallback to linear processing if raw entry not available
await cacheStore.upsertObject(typeName, parsed);
await cacheStore.extractAndStoreRelations(typeName, parsed);
updatedCount++;
}
} catch (error) {
logger.warn(`SyncEngine: Failed to get raw entry for ${objectId}, using fallback`, error);
// Fallback to linear processing
await cacheStore.upsertObject(typeName, parsed);
await cacheStore.extractAndStoreRelations(typeName, parsed);
updatedCount++;
}
}
}
@@ -404,6 +669,7 @@ class SyncEngine {
/**
* Trigger a sync for a specific object type
* Only syncs if the object type is enabled in configuration
* Allows concurrent syncs for different types, but blocks if:
* - A full sync is in progress
* - An incremental sync is in progress
@@ -420,10 +686,19 @@ class SyncEngine {
throw new Error(`Sync already in progress for ${typeName}`);
}
// Check if this type is enabled in configuration
const { schemaConfigurationService } = await import('./schemaConfigurationService.js');
const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes();
const enabledType = enabledTypes.find(et => et.objectTypeName === typeName);
if (!enabledType) {
throw new Error(`Object type ${typeName} is not enabled for syncing. Please enable it in the Schema Configuration settings page.`);
}
this.syncingTypes.add(typeName);
try {
return await this.syncObjectType(typeName);
return await this.syncConfiguredObjectType(enabledType);
} finally {
this.syncingTypes.delete(typeName);
}
@@ -431,20 +706,39 @@ class SyncEngine {
/**
* Force sync a single object
* Only syncs if the object type is enabled in configuration
* If the object was deleted from Jira, it will be removed from the local cache
* Uses recursive processing to store nested referenced objects
*/
async syncObject(typeName: CMDBObjectTypeName, objectId: string): Promise<boolean> {
try {
const jiraObj = await jiraAssetsClient.getObject(objectId);
if (!jiraObj) return false;
// Check if this type is enabled in configuration
const { schemaConfigurationService } = await import('./schemaConfigurationService.js');
const enabledTypes = await schemaConfigurationService.getEnabledObjectTypes();
const isEnabled = enabledTypes.some(et => et.objectTypeName === typeName);
const parsed = jiraAssetsClient.parseObject(jiraObj);
if (!parsed) return false;
if (!isEnabled) {
logger.warn(`SyncEngine: Cannot sync object ${objectId} - type ${typeName} is not enabled for syncing`);
return false;
}
await cacheStore.upsertObject(typeName, parsed);
await cacheStore.extractAndStoreRelations(typeName, parsed);
// Schema discovery must be manually triggered via API endpoints
// No automatic discovery
// Get raw ObjectEntry for recursive processing
const entry = await jiraAssetsClient.getObjectEntry(objectId);
if (!entry) return false;
// Use recursive storeObjectTree to process object and all nested references
const processedIds = new Set<string>();
const result = await this.storeObjectTree(entry, typeName, processedIds);
return true;
if (result.objectsStored > 0) {
logger.info(`SyncEngine: Synced object ${objectId} recursively: ${result.objectsStored} objects, ${result.relationsExtracted} relations`);
return true;
}
return false;
} catch (error) {
// If object was deleted from Jira, remove it from our cache
if (error instanceof JiraObjectNotFoundError) {