diff --git a/.env.example b/.env.example index f4cc9c1..e6d2fa2 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,23 @@ +# Application +PORT=3001 +NODE_ENV=development + +# Database Configuration +# Use 'postgres' for PostgreSQL or 'sqlite' for SQLite (default) +DATABASE_TYPE=postgres +DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb +# Or use individual components: +# DATABASE_HOST=localhost +# DATABASE_PORT=5432 +# DATABASE_NAME=cmdb +# DATABASE_USER=cmdb +# DATABASE_PASSWORD=cmdb-dev +# DATABASE_SSL=false + # Jira Assets Configuration JIRA_HOST=https://jira.zuyderland.nl JIRA_PAT=your_personal_access_token_here JIRA_SCHEMA_ID=your_schema_id - JIRA_API_BATCH_SIZE=20 # Claude API @@ -13,7 +28,3 @@ TAVILY_API_KEY=your_tavily_api_key_here # OpenAI API OPENAI_API_KEY=your_openai_api_key_here - -# Application -PORT=3001 -NODE_ENV=development diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..10c0be9 --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,42 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy source +COPY . . + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install only production dependencies +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy built files +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/src/generated ./src/generated + +# Create data directory with proper permissions +RUN mkdir -p /app/data && chown -R node:node /app/data + +# Switch to non-root user +USER node + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start production server +CMD ["node", "dist/index.js"] diff --git a/backend/data/data-completeness-config.json b/backend/data/data-completeness-config.json new file mode 100644 index 0000000..083387e --- /dev/null +++ b/backend/data/data-completeness-config.json @@ -0,0 +1,132 @@ +{ + "metadata": { + "version": "2.0.0", + "description": "Configuration for Data Completeness Score fields", + "lastUpdated": "2026-01-12T22:11:20.047Z" + }, + "categories": [ + { + "id": "general", + "name": "General", + "description": "General application information fields", + "fields": [ + { + "id": "status", + "name": "Status", + "fieldPath": "status", + "enabled": true + }, + { + "id": "applicationFunctions", + "name": "ApplicationFunction", + "fieldPath": "applicationFunctions", + "enabled": true + }, + { + "id": "businessImpactAnalyse", + "name": "Business Impact Analyse", + "fieldPath": "businessImpactAnalyse", + "enabled": true + }, + { + "id": "hostingType", + "name": "Application Component Hosting Type", + "fieldPath": "hostingType", + "enabled": true + } + ] + }, + { + "id": "1768255689773-uskqbfesn", + "name": "Ownership", + "description": "", + "fields": [ + { + "id": "organisation", + "name": "Organisation", + "fieldPath": "organisation", + "enabled": true + }, + { + "id": "businessOwner", + "name": "Business Owner", + "fieldPath": "businessOwner", + "enabled": true + }, + { + "id": "systemOwner", + "name": "System Owner", + "fieldPath": "systemOwner", + "enabled": true + }, + { + "id": "functionalApplicationManagement", + "name": "Functional Application Management", + "fieldPath": "functionalApplicationManagement", + "enabled": true + }, + { + "id": "technicalApplicationManagement", + "name": "Technical Application Management", + "fieldPath": "technicalApplicationManagement", + "enabled": true + }, + { + "id": "supplierProduct", + "name": "Supplier Product", + "fieldPath": "supplierProduct", + "enabled": true + } + ] + }, + { + "id": "applicationManagement", + "name": "Application Management", + "description": "Application management classification fields", + "fields": [ + { + "id": "governanceModel", + "name": "ICT Governance Model", + "fieldPath": "governanceModel", + "enabled": true + }, + { + "id": "applicationType", + "name": "Application Management - Application Type", + "fieldPath": "applicationType", + "enabled": true + }, + { + "id": "applicationManagementHosting", + "name": "Application Management - Hosting", + "fieldPath": "applicationManagementHosting", + "enabled": true + }, + { + "id": "applicationManagementTAM", + "name": "Application Management - TAM", + "fieldPath": "applicationManagementTAM", + "enabled": true + }, + { + "id": "dynamicsFactor", + "name": "Application Management - Dynamics Factor", + "fieldPath": "dynamicsFactor", + "enabled": true + }, + { + "id": "complexityFactor", + "name": "Application Management - Complexity Factor", + "fieldPath": "complexityFactor", + "enabled": true + }, + { + "id": "numberOfUsers", + "name": "Application Management - Number of Users", + "fieldPath": "numberOfUsers", + "enabled": true + } + ] + } + ] +} \ No newline at end of file diff --git a/backend/data/~$BIA.xlsx b/backend/data/~$BIA.xlsx new file mode 100644 index 0000000..e6681ab Binary files /dev/null and b/backend/data/~$BIA.xlsx differ diff --git a/backend/package.json b/backend/package.json index 911b99e..d5b7acf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,12 +2,14 @@ "name": "zira-backend", "version": "1.0.0", "description": "ZiRA Classificatie Tool Backend", + "type": "module", "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "generate-schema": "tsx scripts/generate-schema.ts" + "generate-schema": "tsx scripts/generate-schema.ts", + "migrate:sqlite-to-postgres": "tsx scripts/migrate-sqlite-to-postgres.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.32.1", @@ -19,6 +21,7 @@ "express-rate-limit": "^7.4.1", "helmet": "^8.0.0", "openai": "^6.15.0", + "pg": "^8.13.1", "winston": "^3.17.0", "xlsx": "^0.18.5" }, @@ -28,6 +31,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.9.0", + "@types/pg": "^8.11.10", "@types/xlsx": "^0.0.35", "tsx": "^4.19.2", "typescript": "^5.6.3" diff --git a/backend/scripts/migrate-sqlite-to-postgres.ts b/backend/scripts/migrate-sqlite-to-postgres.ts new file mode 100644 index 0000000..e0d997a --- /dev/null +++ b/backend/scripts/migrate-sqlite-to-postgres.ts @@ -0,0 +1,184 @@ +/** + * Migration script: SQLite to PostgreSQL + * + * Migrates data from SQLite databases to PostgreSQL. + * + * Usage: + * DATABASE_URL=postgresql://user:pass@host:port/db tsx scripts/migrate-sqlite-to-postgres.ts + */ + +import Database from 'better-sqlite3'; +import { Pool } from 'pg'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as fs from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const SQLITE_CACHE_DB = join(__dirname, '../../data/cmdb-cache.db'); +const SQLITE_CLASSIFICATIONS_DB = join(__dirname, '../../data/classifications.db'); + +async function migrate() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('Error: DATABASE_URL environment variable is required'); + console.error('Example: DATABASE_URL=postgresql://user:pass@localhost:5432/cmdb'); + process.exit(1); + } + + console.log('Starting migration from SQLite to PostgreSQL...'); + console.log(`PostgreSQL: ${databaseUrl.replace(/:[^:@]+@/, ':****@')}`); + + const pg = new Pool({ connectionString: databaseUrl }); + + try { + // Test connection + await pg.query('SELECT 1'); + console.log('✓ PostgreSQL connection successful'); + + // Migrate cache database + if (fs.existsSync(SQLITE_CACHE_DB)) { + console.log('\nMigrating cache database...'); + await migrateCacheDatabase(pg); + } else { + console.log('\n⚠ Cache database not found, skipping...'); + } + + // Migrate classifications database + if (fs.existsSync(SQLITE_CLASSIFICATIONS_DB)) { + console.log('\nMigrating classifications database...'); + await migrateClassificationsDatabase(pg); + } else { + console.log('\n⚠ Classifications database not found, skipping...'); + } + + console.log('\n✓ Migration completed successfully!'); + } catch (error) { + console.error('\n✗ Migration failed:', error); + process.exit(1); + } finally { + await pg.end(); + } +} + +async function migrateCacheDatabase(pg: Pool) { + const sqlite = new Database(SQLITE_CACHE_DB, { readonly: true }); + + try { + // Migrate cached_objects + const objects = sqlite.prepare('SELECT * FROM cached_objects').all() as any[]; + console.log(` Migrating ${objects.length} cached objects...`); + + for (const obj of objects) { + await pg.query( + `INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + object_key = EXCLUDED.object_key, + label = EXCLUDED.label, + data = EXCLUDED.data, + jira_updated_at = EXCLUDED.jira_updated_at, + cached_at = EXCLUDED.cached_at`, + [ + obj.id, + obj.object_key, + obj.object_type, + obj.label, + obj.data, // Already JSON string, PostgreSQL will parse it + obj.jira_updated_at, + obj.jira_created_at, + obj.cached_at, + ] + ); + } + + // Migrate object_relations + const relations = sqlite.prepare('SELECT * FROM object_relations').all() as any[]; + console.log(` Migrating ${relations.length} relations...`); + + for (const rel of relations) { + await pg.query( + `INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (source_id, target_id, attribute_name) DO UPDATE SET + source_type = EXCLUDED.source_type, + target_type = EXCLUDED.target_type`, + [ + rel.source_id, + rel.target_id, + rel.attribute_name, + rel.source_type, + rel.target_type, + ] + ); + } + + // Migrate sync_metadata + const metadata = sqlite.prepare('SELECT * FROM sync_metadata').all() as any[]; + console.log(` Migrating ${metadata.length} metadata entries...`); + + for (const meta of metadata) { + await pg.query( + `INSERT INTO sync_metadata (key, value, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at`, + [meta.key, meta.value, meta.updated_at] + ); + } + + console.log(' ✓ Cache database migrated'); + } finally { + sqlite.close(); + } +} + +async function migrateClassificationsDatabase(pg: Pool) { + const sqlite = new Database(SQLITE_CLASSIFICATIONS_DB, { readonly: true }); + + try { + // Migrate classification_history + const history = sqlite.prepare('SELECT * FROM classification_history').all() as any[]; + console.log(` Migrating ${history.length} classification history entries...`); + + for (const entry of history) { + await pg.query( + `INSERT INTO classification_history (application_id, application_name, changes, source, timestamp, user_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT DO NOTHING`, + [ + entry.application_id, + entry.application_name, + entry.changes, + entry.source, + entry.timestamp, + entry.user_id, + ] + ); + } + + // Migrate session_state + const sessions = sqlite.prepare('SELECT * FROM session_state').all() as any[]; + console.log(` Migrating ${sessions.length} session state entries...`); + + for (const session of sessions) { + await pg.query( + `INSERT INTO session_state (key, value, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at`, + [session.key, session.value, session.updated_at] + ); + } + + console.log(' ✓ Classifications database migrated'); + } finally { + sqlite.close(); + } +} + +// Run migration +migrate().catch(console.error); diff --git a/backend/src/data/management-parameters.json b/backend/src/data/management-parameters.json deleted file mode 100644 index 10a6f3c..0000000 --- a/backend/src/data/management-parameters.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "version": "2024.1", - "source": "Zuyderland ICMT - Application Management Framework", - "lastUpdated": "2024-12-19", - "referenceData": { - "applicationStatuses": [ - { - "key": "status", - "name": "Status", - "description": "Algemene status", - "order": 0, - "color": "#6b7280", - "includeInFilter": true - }, - { - "key": "prod", - "name": "In Production", - "description": "Productie - actief in gebruik", - "order": 1, - "color": "#22c55e", - "includeInFilter": true - }, - { - "key": "impl", - "name": "Implementation", - "description": "In implementatie", - "order": 2, - "color": "#3b82f6", - "includeInFilter": true - }, - { - "key": "poc", - "name": "Proof of Concept", - "description": "Proefproject", - "order": 3, - "color": "#8b5cf6", - "includeInFilter": true - }, - { - "key": "eos", - "name": "End of support", - "description": "Geen ondersteuning meer van leverancier", - "order": 4, - "color": "#f97316", - "includeInFilter": true - }, - { - "key": "eol", - "name": "End of life", - "description": "Einde levensduur, wordt uitgefaseerd", - "order": 5, - "color": "#ef4444", - "includeInFilter": true - }, - { - "key": "deprecated", - "name": "Deprecated", - "description": "Verouderd, wordt uitgefaseerd", - "order": 6, - "color": "#f97316", - "includeInFilter": true - }, - { - "key": "shadow", - "name": "Shadow IT", - "description": "Niet-geautoriseerde IT", - "order": 7, - "color": "#eab308", - "includeInFilter": true - }, - { - "key": "closed", - "name": "Closed", - "description": "Afgesloten", - "order": 8, - "color": "#6b7280", - "includeInFilter": true - }, - { - "key": "undefined", - "name": "Undefined", - "description": "Niet gedefinieerd", - "order": 9, - "color": "#9ca3af", - "includeInFilter": true - } - ], - "dynamicsFactors": [ - { - "key": "1", - "name": "Stabiel", - "description": "Weinig wijzigingen, uitgekristalliseerd systeem, < 2 releases/jaar", - "order": 1, - "color": "#22c55e" - }, - { - "key": "2", - "name": "Gemiddeld", - "description": "Regelmatige wijzigingen, 2-4 releases/jaar, incidentele projecten", - "order": 2, - "color": "#eab308" - }, - { - "key": "3", - "name": "Hoog", - "description": "Veel wijzigingen, > 4 releases/jaar, continue doorontwikkeling", - "order": 3, - "color": "#f97316" - }, - { - "key": "4", - "name": "Zeer hoog", - "description": "Continu in beweging, grote transformatieprojecten, veel nieuwe functionaliteit", - "order": 4, - "color": "#ef4444" - } - ], - "complexityFactors": [ - { - "key": "1", - "name": "Laag", - "description": "Standalone applicatie, geen/weinig integraties, standaard configuratie", - "order": 1, - "color": "#22c55e" - }, - { - "key": "2", - "name": "Gemiddeld", - "description": "Enkele integraties, beperkt maatwerk, standaard governance", - "order": 2, - "color": "#eab308" - }, - { - "key": "3", - "name": "Hoog", - "description": "Veel integraties, significant maatwerk, meerdere stakeholdergroepen", - "order": 3, - "color": "#f97316" - }, - { - "key": "4", - "name": "Zeer hoog", - "description": "Platform met meerdere workloads, uitgebreide governance, veel maatwerk", - "order": 4, - "color": "#ef4444" - } - ], - "numberOfUsers": [ - { - "key": "1", - "name": "< 100", - "minUsers": 0, - "maxUsers": 99, - "order": 1 - }, - { - "key": "2", - "name": "100 - 500", - "minUsers": 100, - "maxUsers": 500, - "order": 2 - }, - { - "key": "3", - "name": "500 - 2.000", - "minUsers": 500, - "maxUsers": 2000, - "order": 3 - }, - { - "key": "4", - "name": "2.000 - 5.000", - "minUsers": 2000, - "maxUsers": 5000, - "order": 4 - }, - { - "key": "5", - "name": "5.000 - 10.000", - "minUsers": 5000, - "maxUsers": 10000, - "order": 5 - }, - { - "key": "6", - "name": "10.000 - 15.000", - "minUsers": 10000, - "maxUsers": 15000, - "order": 6 - }, - { - "key": "7", - "name": "> 15.000", - "minUsers": 15000, - "maxUsers": null, - "order": 7 - } - ], - "governanceModels": [ - { - "key": "A", - "name": "Centraal Beheer", - "shortDescription": "ICMT voert volledig beheer uit", - "description": "Volledige dienstverlening door ICMT. Dit is het standaardmodel voor kernapplicaties.", - "applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.", - "icmtInvolvement": "Volledig", - "businessInvolvement": "Minimaal", - "supplierInvolvement": "Via ICMT", - "order": 1, - "color": "#3b82f6" - }, - { - "key": "B", - "name": "Federatief Beheer", - "shortDescription": "ICMT + business delen beheer", - "description": "ICMT en business delen de verantwoordelijkheid. Geschikt voor applicaties met een sterke key user organisatie.", - "applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.", - "icmtInvolvement": "Gedeeld", - "businessInvolvement": "Gedeeld", - "supplierInvolvement": "Via ICMT/Business", - "order": 2, - "color": "#8b5cf6" - }, - { - "key": "C", - "name": "Uitbesteed met ICMT-Regie", - "shortDescription": "Leverancier beheert, ICMT regisseert", - "description": "Leverancier voert beheer uit, ICMT houdt regie. Dit is het standaardmodel voor SaaS waar ICMT contractpartij is.", - "applicability": "SaaS-applicaties waar ICMT het contract beheert. Voorbeelden: AFAS, diverse zorg-SaaS oplossingen. De mate van FAB-dienstverlening hangt af van de BIA-classificatie.", - "icmtInvolvement": "Regie", - "businessInvolvement": "Gebruiker", - "supplierInvolvement": "Volledig beheer", - "contractHolder": "ICMT", - "order": 3, - "color": "#06b6d4" - }, - { - "key": "D", - "name": "Uitbesteed met Business-Regie", - "shortDescription": "Leverancier beheert, business regisseert", - "description": "Business onderhoudt de leveranciersrelatie. ICMT heeft beperkte betrokkenheid.", - "applicability": "SaaS-applicaties waar de business zelf het contract en de leveranciersrelatie beheert. Voorbeelden: niche SaaS tools, afdelingsspecifieke oplossingen, tools waar de business expertise heeft die ICMT niet heeft.", - "icmtInvolvement": "Beperkt", - "businessInvolvement": "Regie", - "supplierInvolvement": "Volledig beheer", - "contractHolder": "Business", - "order": 4, - "color": "#14b8a6" - }, - { - "key": "E", - "name": "Volledig Decentraal Beheer", - "shortDescription": "Business voert volledig beheer uit", - "description": "Business voert zelf beheer uit. ICMT heeft minimale betrokkenheid.", - "applicability": "Afdelingsspecifieke tools met beperkte impact, Shadow IT die in kaart is gebracht. Voorbeelden: standalone afdelingstools, pilotapplicaties, persoonlijke productiviteitstools.", - "icmtInvolvement": "Minimaal", - "businessInvolvement": "Volledig", - "supplierInvolvement": "Direct met business", - "order": 5, - "color": "#6b7280" - } - ] - }, - "visualizations": { - "capacityMatrix": { - "description": "Matrix voor capaciteitsplanning gebaseerd op Dynamiek x Complexiteit", - "formula": "Beheerlast = Dynamiek * Complexiteit * log(Gebruikers)", - "weightings": { - "dynamics": 1.0, - "complexity": 1.2, - "users": 0.3 - } - }, - "governanceDecisionTree": { - "description": "Beslisboom voor keuze regiemodel", - "factors": [ - "BIA-classificatie", - "Hosting type (SaaS/On-prem)", - "Contracthouder", - "Key user maturity" - ] - } - } -} diff --git a/backend/src/generated/db-schema-postgres.sql b/backend/src/generated/db-schema-postgres.sql new file mode 100644 index 0000000..f436d1d --- /dev/null +++ b/backend/src/generated/db-schema-postgres.sql @@ -0,0 +1,56 @@ +-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY +-- Generated from Jira Assets Schema via REST API +-- PostgreSQL version +-- Generated at: 2026-01-09T02:12:50.973Z +-- +-- Re-generate with: npm run generate-schema + +-- ============================================================================= +-- Core Tables +-- ============================================================================= + +-- Cached CMDB objects (all types stored in single table with JSON data) +CREATE TABLE IF NOT EXISTS cached_objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type TEXT NOT NULL, + label TEXT NOT NULL, + data JSONB NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL +); + +-- Object relations (references between objects) +CREATE TABLE IF NOT EXISTS object_relations ( + id SERIAL PRIMARY KEY, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_name) +); + +-- Sync metadata (tracks sync state) +CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- ============================================================================= +-- Indices for Performance +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); +CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); +CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); +CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); +CREATE INDEX IF NOT EXISTS idx_objects_data_gin ON cached_objects USING GIN (data); + +CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); +CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); +CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); +CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); +CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); diff --git a/backend/src/index.ts b/backend/src/index.ts index d32def7..aedd157 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -73,7 +73,7 @@ app.use((req, res, next) => { // Health check app.get('/health', async (req, res) => { const jiraConnected = await dataService.testConnection(); - const cacheStatus = dataService.getCacheStatus(); + const cacheStatus = await dataService.getCacheStatus(); res.json({ status: 'ok', diff --git a/backend/src/routes/applications.ts b/backend/src/routes/applications.ts index a59b83d..e3dee91 100644 --- a/backend/src/routes/applications.ts +++ b/backend/src/routes/applications.ts @@ -4,6 +4,8 @@ import { databaseService } from '../services/database.js'; import { cmdbService } from '../services/cmdbService.js'; import { logger } from '../services/logger.js'; import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js'; +import { findBIAMatch, loadBIAData, clearBIACache, calculateSimilarity } from '../services/biaMatchingService.js'; +import { calculateApplicationCompleteness } from '../services/dataCompletenessConfig.js'; import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js'; import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js'; @@ -51,6 +53,270 @@ router.get('/team-dashboard', async (req: Request, res: Response) => { } }); +// Get team portfolio health metrics +router.get('/team-portfolio-health', async (req: Request, res: Response) => { + try { + const excludedStatusesParam = req.query.excludedStatuses as string | undefined; + let excludedStatuses: ApplicationStatus[] = []; + + if (excludedStatusesParam && excludedStatusesParam.trim().length > 0) { + // Parse comma-separated statuses + excludedStatuses = excludedStatusesParam + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0) as ApplicationStatus[]; + } else { + // Default to excluding 'Closed' and 'Deprecated' if not specified + excludedStatuses = ['Closed', 'Deprecated']; + } + + const data = await dataService.getTeamPortfolioHealth(excludedStatuses); + res.json(data); + } catch (error) { + logger.error('Failed to get team portfolio health', error); + res.status(500).json({ error: 'Failed to get team portfolio health' }); + } +}); + +// Get Business Importance vs BIA comparison +// NOTE: This must come BEFORE the /:id route to avoid route conflicts +router.get('/business-importance-comparison', async (req: Request, res: Response) => { + try { + logger.info('Business Importance comparison endpoint called'); + const data = await dataService.getBusinessImportanceComparison(); + logger.info(`Business Importance comparison: returning ${data.applications.length} applications`); + res.json(data); + } catch (error) { + logger.error('Failed to get business importance comparison', error); + res.status(500).json({ error: 'Failed to get business importance comparison' }); + } +}); + +// Test BIA data loading (for debugging) +router.get('/bia-test', async (req: Request, res: Response) => { + try { + if (req.query.clear === 'true') { + clearBIACache(); + } + const biaData = loadBIAData(); + res.json({ + recordCount: biaData.length, + records: biaData.slice(0, 20), // First 20 records + sample: biaData.slice(0, 5).map(r => `${r.applicationName} -> ${r.biaValue}`), + }); + } catch (error) { + logger.error('Failed to test BIA data loading', error); + res.status(500).json({ error: 'Failed to test BIA data loading', details: error instanceof Error ? error.message : String(error) }); + } +}); + +// Comprehensive debug endpoint for BIA matching +router.get('/bia-debug', async (req: Request, res: Response) => { + try { + clearBIACache(); + const biaData = loadBIAData(); + + // Get a few sample applications + const searchResult = await dataService.searchApplications({}, 1, 50); + const sampleApps = searchResult.applications.slice(0, 10); + + // Test specific examples mentioned by user + const testNames = ['Aanmeldzuilen', 'PregnaOne', 'BeagleBoxx']; + const testApps = searchResult.applications.filter(app => + testNames.some(name => + app.name.toLowerCase().includes(name.toLowerCase()) || + (app.searchReference && app.searchReference.toLowerCase().includes(name.toLowerCase())) + ) + ); + + const debugResults = []; + + // Test each sample app + for (const app of [...sampleApps, ...testApps]) { + const matchResult = findBIAMatch(app.name, app.searchReference); + + // Find all potential matches in Excel data for detailed analysis + const normalizedAppName = app.name.toLowerCase().trim(); + const normalizedSearchRef = app.searchReference ? app.searchReference.toLowerCase().trim() : null; + + const potentialMatches = biaData.map(record => { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + const exactNameMatch = normalizedAppName === normalizedRecordName; + const exactSearchRefMatch = normalizedSearchRef === normalizedRecordName; + const startsWithApp = normalizedRecordName.startsWith(normalizedAppName) || normalizedAppName.startsWith(normalizedRecordName); + const containsApp = normalizedRecordName.includes(normalizedAppName) || normalizedAppName.includes(normalizedRecordName); + + // Calculate similarity using the exported function + const similarity = calculateSimilarity(normalizedAppName, normalizedRecordName); + + return { + excelName: record.applicationName, + excelBIA: record.biaValue, + exactNameMatch, + exactSearchRefMatch, + startsWithApp, + containsApp, + similarity: similarity, + }; + }).filter(m => m.exactNameMatch || m.exactSearchRefMatch || m.startsWithApp || m.containsApp || m.similarity > 0.5); + + debugResults.push({ + appName: app.name, + searchReference: app.searchReference, + matchResult: matchResult, + potentialMatches: potentialMatches.slice(0, 5), // Top 5 potential matches + allExcelRecords: biaData.length, + }); + } + + res.json({ + excelDataLoaded: biaData.length, + sampleExcelRecords: biaData.slice(0, 20), + debugResults, + testNames: testNames.map(name => { + const matchingExcel = biaData.filter(r => + r.applicationName.toLowerCase().includes(name.toLowerCase()) || + name.toLowerCase().includes(r.applicationName.toLowerCase()) + ); + return { + name, + matchingExcelRecords: matchingExcel, + }; + }), + }); + } catch (error) { + logger.error('Failed to debug BIA matching', error); + res.status(500).json({ + error: 'Failed to debug BIA matching', + details: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } +}); + +// Get BIA comparison data +router.get('/bia-comparison', async (req: Request, res: Response) => { + try { + // Clear cache and reload BIA data to ensure fresh data + const { loadBIAData, clearBIACache } = await import('../services/biaMatchingService.js'); + clearBIACache(); + + // Load fresh data + const testBIAData = loadBIAData(); + logger.info(`BIA comparison: Loaded ${testBIAData.length} records from Excel file`); + if (testBIAData.length === 0) { + logger.error('BIA comparison: No Excel data loaded - check if BIA.xlsx exists and is readable'); + logger.error('This could indicate:'); + logger.error(' 1. Excel file not found at backend/data/BIA.xlsx'); + logger.error(' 2. Column C (index 2) or K (index 10) not found'); + logger.error(' 3. No valid BIA values (A-F) in column K'); + } else { + logger.info(`BIA comparison: Sample Excel records: ${testBIAData.slice(0, 5).map(r => `"${r.applicationName}"->${r.biaValue}`).join(', ')}`); + } + + // Get all applications (no filters) + const searchResult = await dataService.searchApplications({}, 1, 10000); + logger.info(`BIA comparison: Found ${searchResult.applications.length} applications to compare`); + + // Get all Business Impact Analyses for mapping + const biaReferences = await dataService.getBusinessImpactAnalyses(); + const biaMap = new Map(); + biaReferences.forEach(bia => { + // Map by the letter (A-F) - typically the name starts with the letter + const letter = bia.name.charAt(0).toUpperCase(); + if (/^[A-F]$/.test(letter)) { + biaMap.set(letter, bia); + } + // Also try to match by key or name containing the letter + if (bia.name.toUpperCase().includes(' - ')) { + const parts = bia.name.split(' - '); + if (parts.length > 0 && /^[A-F]$/.test(parts[0].trim().toUpperCase())) { + biaMap.set(parts[0].trim().toUpperCase(), bia); + } + } + }); + + const applications = searchResult.applications; + const comparisonItems = []; + + let matched = 0; + let mismatched = 0; + let notFound = 0; + let noExcelBIA = 0; + + for (const app of applications) { + // Find BIA match in Excel + const matchResult = findBIAMatch(app.name, app.searchReference); + + // Log first few matches for debugging + if (comparisonItems.length < 5) { + logger.debug(`BIA match for "${app.name}": ${matchResult.biaValue || 'NOT FOUND'} (type: ${matchResult.matchType || 'none'})`); + } + + // Extract BIA letter from current BIA name (handle formats like "A", "A - Test/Archief", etc.) + let currentBIALetter: string | null = null; + if (app.businessImpactAnalyse?.name) { + const match = app.businessImpactAnalyse.name.match(/^([A-F])/); + if (match) { + currentBIALetter = match[1].toUpperCase(); + } else { + // Fallback to first character + currentBIALetter = app.businessImpactAnalyse.name.charAt(0).toUpperCase(); + if (!/^[A-F]$/.test(currentBIALetter)) { + currentBIALetter = null; + } + } + } + + const excelBIALetter = matchResult.biaValue; + + let matchStatus: 'match' | 'mismatch' | 'not_found' | 'no_excel_bia'; + + if (!excelBIALetter) { + matchStatus = 'no_excel_bia'; + noExcelBIA++; + } else if (!currentBIALetter) { + matchStatus = 'not_found'; + notFound++; + } else if (currentBIALetter === excelBIALetter) { + matchStatus = 'match'; + matched++; + } else { + matchStatus = 'mismatch'; + mismatched++; + } + + comparisonItems.push({ + id: app.id, + key: app.key, + name: app.name, + searchReference: app.searchReference, + currentBIA: app.businessImpactAnalyse, + excelBIA: excelBIALetter, + excelApplicationName: matchResult.excelApplicationName, + matchStatus, + matchType: matchResult.matchType, + matchConfidence: matchResult.matchConfidence, + allMatches: matchResult.allMatches, + }); + } + + res.json({ + applications: comparisonItems, + summary: { + total: applications.length, + matched, + mismatched, + notFound, + noExcelBIA, + }, + }); + } catch (error) { + logger.error('Failed to get BIA comparison', error); + res.status(500).json({ error: 'Failed to get BIA comparison' }); + } +}); + // Get application by ID // Query params: // - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection) @@ -60,7 +326,7 @@ router.get('/:id', async (req: Request, res: Response) => { const mode = req.query.mode as string | undefined; // Don't treat special routes as application IDs - if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') { + if (id === 'team-dashboard' || id === 'team-portfolio-health' || id === 'business-importance-comparison' || id === 'bia-comparison' || id === 'bia-test' || id === 'calculate-effort' || id === 'search') { res.status(404).json({ error: 'Route not found' }); return; } @@ -75,7 +341,14 @@ router.get('/:id', async (req: Request, res: Response) => { return; } - res.json(application); + // Calculate data completeness percentage + const completenessPercentage = calculateApplicationCompleteness(application); + const applicationWithCompleteness = { + ...application, + dataCompletenessPercentage: Math.round(completenessPercentage * 10) / 10, // Round to 1 decimal + }; + + res.json(applicationWithCompleteness); } catch (error) { logger.error('Failed to get application', error); res.status(500).json({ error: 'Failed to get application' }); @@ -183,7 +456,7 @@ router.put('/:id', async (req: Request, res: Response) => { source: actualUpdates.source || 'MANUAL', timestamp: new Date(), }; - databaseService.saveClassificationResult(classificationResult); + await databaseService.saveClassificationResult(classificationResult); // Return updated application const updatedApp = result.data || await dataService.getApplicationById(id); @@ -279,7 +552,7 @@ router.post('/calculate-effort', async (req: Request, res: Response) => { router.get('/:id/history', async (req: Request, res: Response) => { try { const { id } = req.params; - const history = databaseService.getClassificationsByApplicationId(id); + const history = await databaseService.getClassificationsByApplicationId(id); res.json(history); } catch (error) { logger.error('Failed to get classification history', error); @@ -352,7 +625,7 @@ router.get('/:id/related/:objectType', async (req: Request, res: Response) => { const objects = relatedObjects.map(obj => { // Extract attributes from the object const attributes: Record = {}; - const objData = obj as Record; + const objData = obj as unknown as Record; // If specific attributes are requested, extract those if (requestedAttrs.length > 0) { diff --git a/backend/src/routes/cache.ts b/backend/src/routes/cache.ts index 20d748a..c95d5b3 100644 --- a/backend/src/routes/cache.ts +++ b/backend/src/routes/cache.ts @@ -14,15 +14,38 @@ import type { CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); // Get cache status -router.get('/status', (req: Request, res: Response) => { +router.get('/status', async (req: Request, res: Response) => { try { - const cacheStats = cacheStore.getStats(); - const syncStatus = syncEngine.getStatus(); + const cacheStats = await cacheStore.getStats(); + const syncStatus = await syncEngine.getStatus(); + + // Compare cache count with Jira count for ApplicationComponent + let jiraComparison: { jiraCount?: number; cacheCount: number; difference?: number } | undefined; + if (cacheStats.objectsByType['ApplicationComponent'] !== undefined) { + try { + const { jiraAssetsClient } = await import('../services/jiraAssetsClient.js'); + const { OBJECT_TYPES } = await import('../generated/jira-schema.js'); + const typeDef = OBJECT_TYPES['ApplicationComponent']; + if (typeDef) { + const searchResult = await jiraAssetsClient.searchObjects(`objectType = "${typeDef.name}"`, 1, 1); + const jiraCount = searchResult.totalCount; + const cacheCount = cacheStats.objectsByType['ApplicationComponent'] || 0; + jiraComparison = { + jiraCount, + cacheCount, + difference: jiraCount - cacheCount, + }; + } + } catch (err) { + logger.debug('Could not fetch Jira count for comparison', err); + } + } res.json({ cache: cacheStats, sync: syncStatus, supportedTypes: Object.keys(OBJECT_TYPES), + jiraComparison, }); } catch (error) { logger.error('Failed to get cache status', error); @@ -87,7 +110,7 @@ router.post('/sync/:objectType', async (req: Request, res: Response) => { }); // Clear cache for a specific type -router.delete('/clear/:objectType', (req: Request, res: Response) => { +router.delete('/clear/:objectType', async (req: Request, res: Response) => { try { const { objectType } = req.params; @@ -101,7 +124,7 @@ router.delete('/clear/:objectType', (req: Request, res: Response) => { logger.info(`Clearing cache for ${objectType}`); - const deleted = cacheStore.clearObjectType(objectType as CMDBObjectTypeName); + const deleted = await cacheStore.clearObjectType(objectType as CMDBObjectTypeName); res.json({ status: 'cleared', @@ -115,11 +138,11 @@ router.delete('/clear/:objectType', (req: Request, res: Response) => { }); // Clear entire cache -router.delete('/clear', (req: Request, res: Response) => { +router.delete('/clear', async (req: Request, res: Response) => { try { logger.info('Clearing entire cache'); - cacheStore.clearAll(); + await cacheStore.clearAll(); res.json({ status: 'cleared', diff --git a/backend/src/routes/classifications.ts b/backend/src/routes/classifications.ts index b964cae..7cee8be 100644 --- a/backend/src/routes/classifications.ts +++ b/backend/src/routes/classifications.ts @@ -68,10 +68,10 @@ router.get('/function/:code', (req: Request, res: Response) => { }); // Get classification history -router.get('/history', (req: Request, res: Response) => { +router.get('/history', async (req: Request, res: Response) => { try { const limit = parseInt(req.query.limit as string) || 50; - const history = databaseService.getClassificationHistory(limit); + const history = await databaseService.getClassificationHistory(limit); res.json(history); } catch (error) { logger.error('Failed to get classification history', error); @@ -80,9 +80,9 @@ router.get('/history', (req: Request, res: Response) => { }); // Get classification stats -router.get('/stats', (req: Request, res: Response) => { +router.get('/stats', async (req: Request, res: Response) => { try { - const dbStats = databaseService.getStats(); + const dbStats = await databaseService.getStats(); res.json(dbStats); } catch (error) { logger.error('Failed to get classification stats', error); diff --git a/backend/src/routes/configuration.ts b/backend/src/routes/configuration.ts index c205184..a9253bb 100644 --- a/backend/src/routes/configuration.ts +++ b/backend/src/routes/configuration.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'url'; import { logger } from '../services/logger.js'; import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js'; import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js'; +import type { DataCompletenessConfig } from '../types/index.js'; // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); @@ -15,6 +16,7 @@ const router = Router(); // Path to the configuration files const CONFIG_FILE_PATH = join(__dirname, '../../data/effort-calculation-config.json'); const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json'); +const COMPLETENESS_CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json'); /** * Get the current effort calculation configuration (legacy) @@ -122,5 +124,143 @@ router.put('/effort-calculation-v25', async (req: Request, res: Response) => { } }); +/** + * Get the data completeness configuration + */ +router.get('/data-completeness', async (req: Request, res: Response) => { + try { + // Try to read from JSON file, fallback to default config + try { + const fileContent = await readFile(COMPLETENESS_CONFIG_FILE_PATH, 'utf-8'); + const config = JSON.parse(fileContent) as DataCompletenessConfig; + res.json(config); + } catch (fileError) { + // If file doesn't exist, return default config + const defaultConfig: DataCompletenessConfig = { + metadata: { + version: '1.0.0', + description: 'Configuration for Data Completeness Score fields', + lastUpdated: new Date().toISOString(), + }, + categories: { + general: { + name: 'General', + description: 'General application information fields', + fields: [ + { name: 'Organisation', fieldPath: 'organisation', enabled: true }, + { name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true }, + { name: 'Status', fieldPath: 'status', enabled: true }, + { name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true }, + { name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true }, + { name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true }, + { name: 'Business Owner', fieldPath: 'businessOwner', enabled: true }, + { name: 'System Owner', fieldPath: 'systemOwner', enabled: true }, + { name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true }, + { name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true }, + ], + }, + applicationManagement: { + name: 'Application Management', + description: 'Application management classification fields', + fields: [ + { name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true }, + { name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true }, + { name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true }, + { name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true }, + { name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true }, + { name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true }, + { name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true }, + ], + }, + }, + }; + res.json(defaultConfig); + } + } catch (error) { + logger.error('Failed to get data completeness configuration', error); + res.status(500).json({ error: 'Failed to get configuration' }); + } +}); + +/** + * Update the data completeness configuration + */ +router.put('/data-completeness', async (req: Request, res: Response) => { + try { + const config = req.body as DataCompletenessConfig; + + // Validate the configuration structure + if (!config.categories || !Array.isArray(config.categories)) { + res.status(400).json({ error: 'Invalid configuration: categories must be an array' }); + return; + } + + if (config.categories.length === 0) { + res.status(400).json({ error: 'Invalid configuration: must have at least one category' }); + return; + } + + // Validate each category + for (const category of config.categories) { + if (!category.id || typeof category.id !== 'string') { + res.status(400).json({ error: 'Invalid configuration: each category must have an id' }); + return; + } + + if (!category.name || typeof category.name !== 'string') { + res.status(400).json({ error: 'Invalid configuration: each category must have a name' }); + return; + } + + if (!Array.isArray(category.fields)) { + res.status(400).json({ error: 'Invalid configuration: category fields must be arrays' }); + return; + } + + // Validate each field + for (const field of category.fields) { + if (!field.id || typeof field.id !== 'string') { + res.status(400).json({ error: 'Invalid configuration: each field must have an id' }); + return; + } + + if (!field.name || typeof field.name !== 'string') { + res.status(400).json({ error: 'Invalid configuration: each field must have a name' }); + return; + } + + if (!field.fieldPath || typeof field.fieldPath !== 'string') { + res.status(400).json({ error: 'Invalid configuration: each field must have a fieldPath' }); + return; + } + + if (typeof field.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid configuration: each field must have an enabled boolean' }); + return; + } + } + } + + // Update metadata + config.metadata = { + ...config.metadata, + lastUpdated: new Date().toISOString(), + }; + + // Write to JSON file + await writeFile(COMPLETENESS_CONFIG_FILE_PATH, JSON.stringify(config, null, 2), 'utf-8'); + + // Clear the cache so the new config is loaded on next request + const { clearDataCompletenessConfigCache } = await import('../services/dataCompletenessConfig.js'); + clearDataCompletenessConfigCache(); + + logger.info('Data completeness configuration updated'); + res.json({ success: true, message: 'Configuration saved successfully' }); + } catch (error) { + logger.error('Failed to update data completeness configuration', error); + res.status(500).json({ error: 'Failed to save configuration' }); + } +}); + export default router; diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index d208e4c..ff02c97 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -3,8 +3,11 @@ import { dataService } from '../services/dataService.js'; import { databaseService } from '../services/database.js'; import { syncEngine } from '../services/syncEngine.js'; import { logger } from '../services/logger.js'; -import { validateApplicationConfiguration } from '../services/effortCalculation.js'; +import { validateApplicationConfiguration, calculateRequiredEffortWithMinMax } from '../services/effortCalculation.js'; import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; const router = Router(); @@ -36,11 +39,11 @@ router.get('/stats', async (req: Request, res: Response) => { // Default to true to include distributions, but allow disabling for performance const includeDistributions = req.query.distributions !== 'false'; const stats = await dataService.getStats(includeDistributions); - const dbStats = databaseService.getStats(); + const dbStats = await databaseService.getStats(); // Get cache status - const cacheStatus = dataService.getCacheStatus(); - const syncStatus = syncEngine.getStatus(); + const cacheStatus = await dataService.getCacheStatus(); + const syncStatus = await syncEngine.getStatus(); const responseData = { ...stats, @@ -89,10 +92,10 @@ router.get('/stats', async (req: Request, res: Response) => { }); // Get recent classifications -router.get('/recent', (req: Request, res: Response) => { +router.get('/recent', async (req: Request, res: Response) => { try { const limit = parseInt(req.query.limit as string) || 10; - const history = databaseService.getClassificationHistory(limit); + const history = await databaseService.getClassificationHistory(limit); res.json(history); } catch (error) { logger.error('Failed to get recent classifications', error); @@ -197,4 +200,1190 @@ router.get('/governance-analysis', async (req: Request, res: Response) => { } }); +// Get technical debt heatmap data +router.get('/technical-debt', async (req: Request, res: Response) => { + try { + logger.info('Technical Debt: Fetching applications with EOL/EOS/Deprecated status...'); + + // Only fetch applications with technical debt statuses + const technicalDebtStatuses: ApplicationStatus[] = ['End of life', 'End of support', 'Deprecated']; + + // Use batched fetching to avoid timeouts + const pageSize = 50; + let allApplications: Array<{ + id: string; + key: string; + name: string; + status: ApplicationStatus | null; + businessImpactAnalyse?: ReferenceValue | null; + }> = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + // Fetch applications in batches + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses: technicalDebtStatuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`Technical Debt: Total applications to process: ${totalCount}`); + } + + allApplications = allApplications.concat(searchResult.applications as typeof allApplications); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + // Safety limit to prevent infinite loops + if (currentPage > 100) { + logger.warn('Technical Debt: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`Technical Debt: Error fetching page ${currentPage}`, fetchError); + hasMore = false; + } + } + + logger.info(`Technical Debt: Fetched ${allApplications.length} applications`); + + // Process applications and calculate risk levels + const processedApplications: Array<{ + id: string; + key: string; + name: string; + status: string | null; + businessImpactAnalyse: string | null; + riskLevel: 'critical' | 'high' | 'medium' | 'low'; + }> = []; + + const byStatus: Record = {}; + const byBIA: Record = {}; + const byRiskLevel: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + }; + + for (const app of allApplications) { + // Get full application details for BIA + const fullApp = await dataService.getApplicationById(app.id); + if (!fullApp) continue; + + // Extract BIA level (e.g., "F" from "BIA-2024-0042 (Klasse F)") + let biaLevel: string | null = null; + if (fullApp.businessImpactAnalyse?.name) { + const biaMatch = fullApp.businessImpactAnalyse.name.match(/Klasse\s+([A-F])/i); + if (biaMatch) { + biaLevel = biaMatch[1].toUpperCase(); + } else { + // Try to extract single letter if format is different + const singleLetterMatch = fullApp.businessImpactAnalyse.name.match(/\b([A-F])\b/i); + if (singleLetterMatch) { + biaLevel = singleLetterMatch[1].toUpperCase(); + } + } + } + + // Calculate risk level + let riskLevel: 'critical' | 'high' | 'medium' | 'low' = 'low'; + if (app.status && biaLevel) { + const isHighBIA = ['F', 'E', 'D'].includes(biaLevel); + const isEOL = app.status === 'End of life'; + const isEOS = app.status === 'End of support'; + const isDeprecated = app.status === 'Deprecated'; + + if (isHighBIA && (isEOL || isEOS || isDeprecated)) { + if (isEOL && ['F', 'E'].includes(biaLevel)) { + riskLevel = 'critical'; + } else if (isEOL || (isEOS && biaLevel === 'F')) { + riskLevel = 'critical'; + } else if (isHighBIA) { + riskLevel = 'high'; + } + } else if (isEOL || isEOS) { + riskLevel = 'high'; + } else if (isDeprecated) { + riskLevel = 'medium'; + } + } else if (app.status === 'End of life' || app.status === 'End of support') { + riskLevel = 'high'; + } else if (app.status === 'Deprecated') { + riskLevel = 'medium'; + } + + processedApplications.push({ + id: app.id, + key: app.key, + name: app.name, + status: app.status, + businessImpactAnalyse: biaLevel, + riskLevel, + }); + + // Count statistics + if (app.status) { + byStatus[app.status] = (byStatus[app.status] || 0) + 1; + } + if (biaLevel) { + byBIA[biaLevel] = (byBIA[biaLevel] || 0) + 1; + } + byRiskLevel[riskLevel] = (byRiskLevel[riskLevel] || 0) + 1; + } + + logger.info(`Technical Debt: Processed ${processedApplications.length} applications`); + + res.json({ + totalApplications: processedApplications.length, + applications: processedApplications, + byStatus, + byBIA, + byRiskLevel, + }); + } catch (error) { + logger.error('Failed to get technical debt data', error); + res.status(500).json({ error: 'Failed to get technical debt data' }); + } +}); + +// Get lifecycle pipeline data +router.get('/lifecycle-pipeline', async (req: Request, res: Response) => { + try { + logger.info('Lifecycle Pipeline: Fetching applications by lifecycle stage...'); + + // Fetch applications in the lifecycle stages + const lifecycleStatuses: ApplicationStatus[] = [ + 'Implementation', + 'Proof of Concept', + 'In Production', + 'End of support', + 'End of life', + 'Deprecated', + 'Shadow IT', + 'Undefined', + 'Closed', + ]; + + // Use batched fetching to avoid timeouts + const pageSize = 50; + let allApplications: Array<{ + id: string; + key: string; + name: string; + status: ApplicationStatus | null; + }> = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + // Fetch applications in batches + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses: lifecycleStatuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`Lifecycle Pipeline: Total applications to process: ${totalCount}`); + } + + allApplications = allApplications.concat(searchResult.applications as typeof allApplications); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + // Safety limit to prevent infinite loops + if (currentPage > 100) { + logger.warn('Lifecycle Pipeline: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`Lifecycle Pipeline: Error fetching page ${currentPage}`, fetchError); + hasMore = false; + } + } + + logger.info(`Lifecycle Pipeline: Fetched ${allApplications.length} applications`); + + // Process applications and group by lifecycle stage + // Stages: implementation, poc, production, eos_eol (bundled), deprecated, shadow_undefined (bundled), closed + const processedApplications: Array<{ + id: string; + key: string; + name: string; + status: string; + lifecycleStage: 'implementation' | 'poc' | 'production' | 'eos_eol' | 'deprecated' | 'shadow_undefined' | 'closed'; + }> = []; + + const byStage = { + implementation: 0, + poc: 0, + production: 0, + eos_eol: 0, + deprecated: 0, + shadow_undefined: 0, + closed: 0, + }; + + for (const app of allApplications) { + if (!app.status) continue; + + let lifecycleStage: 'implementation' | 'poc' | 'production' | 'eos_eol' | 'deprecated' | 'shadow_undefined' | 'closed' | null = null; + + switch (app.status) { + case 'Implementation': + lifecycleStage = 'implementation'; + break; + case 'Proof of Concept': + lifecycleStage = 'poc'; + break; + case 'In Production': + lifecycleStage = 'production'; + break; + case 'End of support': + case 'End of life': + lifecycleStage = 'eos_eol'; + break; + case 'Deprecated': + lifecycleStage = 'deprecated'; + break; + case 'Shadow IT': + case 'Undefined': + lifecycleStage = 'shadow_undefined'; + break; + case 'Closed': + lifecycleStage = 'closed'; + break; + } + + if (lifecycleStage) { + processedApplications.push({ + id: app.id, + key: app.key, + name: app.name, + status: app.status, + lifecycleStage, + }); + + byStage[lifecycleStage]++; + } + } + + logger.info(`Lifecycle Pipeline: Processed ${processedApplications.length} applications`); + + res.json({ + totalApplications: processedApplications.length, + byStage, + applications: processedApplications, + }); + } catch (error) { + logger.error('Failed to get lifecycle pipeline data', error); + res.status(500).json({ error: 'Failed to get lifecycle pipeline data' }); + } +}); + +// Get data completeness score +router.get('/data-completeness', async (req: Request, res: Response) => { + try { + logger.info('Data Completeness: Fetching all applications...'); + + // Fetch all applications (excluding Closed and Deprecated for active applications) + const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined']; + + // Use batched fetching + const pageSize = 50; + let allApplications: ApplicationDetails[] = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`Data Completeness: Total applications to process: ${totalCount}`); + } + + // Get full details for each application + const appDetails = await Promise.all( + searchResult.applications.map(app => dataService.getApplicationById(app.id)) + ); + + allApplications = allApplications.concat(appDetails.filter((app): app is ApplicationDetails => app !== null)); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + if (currentPage > 100) { + logger.warn('Data Completeness: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`Data Completeness: Error fetching page ${currentPage}`, fetchError); + hasMore = false; + } + } + + logger.info(`Data Completeness: Processing ${allApplications.length} applications`); + + // Load completeness configuration + const { getDataCompletenessConfig } = await import('../services/dataCompletenessConfig.js'); + const completenessConfig = getDataCompletenessConfig(); + + // Helper function to get value from ApplicationDetails using field path + const getFieldValue = (app: ApplicationDetails, fieldPath: string): any => { + const paths = fieldPath.split('.'); + let value: any = app; + for (const path of paths) { + if (value === null || value === undefined) return null; + value = (value as any)[path]; + } + return value; + }; + + // Helper function to check if a field is filled based on fieldPath + const isFieldFilled = (app: ApplicationDetails, fieldPath: string): boolean => { + const value = getFieldValue(app, fieldPath); + + // Special handling for arrays (e.g., applicationFunctions) + if (Array.isArray(value)) { + return value.length > 0; + } + + // For reference values (objects with objectId) + if (value && typeof value === 'object' && 'objectId' in value) { + return !!value.objectId; + } + + // For primitive values + return value !== null && value !== undefined && value !== ''; + }; + + // Build field maps from config (organized by category) + const categoryFieldMap = new Map(); + const fieldPathMap = new Map(); + + completenessConfig.categories.forEach(category => { + const enabledFields = category.fields.filter(f => f.enabled); + categoryFieldMap.set(category.id, enabledFields.map(f => ({ name: f.name, fieldPath: f.fieldPath }))); + enabledFields.forEach(field => { + fieldPathMap.set(field.name, { fieldPath: field.fieldPath, categoryId: category.id, categoryName: category.name }); + }); + }); + + // Calculate total enabled fields across all categories + const TOTAL_FIELDS = Array.from(categoryFieldMap.values()).reduce((sum, fields) => sum + fields.length, 0); + + // Initialize category-based tracking + const categoryScores: Record = {}; + completenessConfig.categories.forEach(category => { + categoryScores[category.id] = { filled: 0, total: 0 }; + }); + + // Calculate per application + const byApplication: Array<{ + id: string; + key: string; + name: string; + team: string | null; + subteam: string | null; + generalScore: number; // For backward compatibility (first category or 'general') + applicationManagementScore: number; // For backward compatibility (second category or 'applicationManagement') + overallScore: number; + filledFields: number; + totalFields: number; + categoryScores: Record; // Dynamic category scores + }> = []; + + // Calculate per field + const byField: Record = {}; + Array.from(fieldPathMap.entries()).forEach(([fieldName, fieldInfo]) => { + byField[fieldName] = { filled: 0, total: 0, categoryId: fieldInfo.categoryId }; + }); + + // Calculate per team (category-based) + const byTeam: Record; + categoryTotal: Record; + applicationCount: number; + }> = {}; + + // Find default categories for backward compatibility + const generalCategory = completenessConfig.categories.find(c => c.id === 'general') || completenessConfig.categories[0]; + const appMgmtCategory = completenessConfig.categories.find(c => c.id === 'applicationManagement') || completenessConfig.categories[1]; + + for (const app of allApplications) { + // Count filled fields per category + const appCategoryScores: Record = {}; + completenessConfig.categories.forEach(category => { + appCategoryScores[category.id] = { filled: 0, total: 0 }; + const categoryFields = categoryFieldMap.get(category.id) || []; + categoryFields.forEach(({ name, fieldPath }) => { + const filled = isFieldFilled(app, fieldPath); + appCategoryScores[category.id].total++; + if (filled) { + appCategoryScores[category.id].filled++; + byField[name].filled++; + } + byField[name].total++; + }); + }); + + // Calculate category percentages + const appCategoryPercentages: Record = {}; + completenessConfig.categories.forEach(category => { + const score = appCategoryScores[category.id]; + appCategoryPercentages[category.id] = score.total > 0 ? (score.filled / score.total) * 100 : 0; + }); + + // Calculate total filled across all categories + const totalFilled = Object.values(appCategoryScores).reduce((sum, score) => sum + score.filled, 0); + const overallScore = TOTAL_FIELDS > 0 ? (totalFilled / TOTAL_FIELDS) * 100 : 0; + + // Get team name + const teamName = app.applicationTeam?.name || app.applicationSubteam?.name || null; + const subteamName = app.applicationSubteam?.name || null; + + // For backward compatibility, calculate general and appMgmt scores + const generalScore = generalCategory ? appCategoryPercentages[generalCategory.id] : 0; + const appMgmtScore = appMgmtCategory ? appCategoryPercentages[appMgmtCategory.id] : 0; + + byApplication.push({ + id: app.id, + key: app.key, + name: app.name, + team: teamName, + subteam: subteamName, + generalScore, + applicationManagementScore: appMgmtScore, + overallScore, + filledFields: totalFilled, + totalFields: TOTAL_FIELDS, + categoryScores: appCategoryPercentages, + }); + + // Aggregate by team + const teamKey = teamName || 'Niet toegekend'; + if (!byTeam[teamKey]) { + byTeam[teamKey] = { + categoryFilled: {}, + categoryTotal: {}, + applicationCount: 0, + }; + completenessConfig.categories.forEach(category => { + byTeam[teamKey].categoryFilled[category.id] = 0; + byTeam[teamKey].categoryTotal[category.id] = 0; + }); + } + + completenessConfig.categories.forEach(category => { + byTeam[teamKey].categoryFilled[category.id] += appCategoryScores[category.id].filled; + byTeam[teamKey].categoryTotal[category.id] += appCategoryScores[category.id].total; + categoryScores[category.id].filled += appCategoryScores[category.id].filled; + categoryScores[category.id].total += appCategoryScores[category.id].total; + }); + + byTeam[teamKey].applicationCount++; + } + + // Calculate overall scores per category + const overallCategoryScores: Record = {}; + completenessConfig.categories.forEach(category => { + const score = categoryScores[category.id]; + overallCategoryScores[category.id] = score.total > 0 ? (score.filled / score.total) * 100 : 0; + }); + + // For backward compatibility + const overallGeneralScore = generalCategory ? overallCategoryScores[generalCategory.id] : 0; + const overallAppMgmtScore = appMgmtCategory ? overallCategoryScores[appMgmtCategory.id] : 0; + const overallTotalFilled = Object.values(categoryScores).reduce((sum, score) => sum + score.filled, 0); + const overallTotalFields = Object.values(categoryScores).reduce((sum, score) => sum + score.total, 0); + const overallScore = overallTotalFields > 0 ? (overallTotalFilled / overallTotalFields) * 100 : 0; + + // Format byField for response (preserve order from config) + const byFieldArray: Array<{ + field: string; + category: string; + categoryName: string; + filled: number; + total: number; + percentage: number; + }> = []; + + // Build array in the order fields appear in config categories + completenessConfig.categories.forEach(category => { + category.fields.forEach(field => { + if (field.enabled && byField[field.name]) { + byFieldArray.push({ + field: field.name, + category: category.id, + categoryName: category.name, + filled: byField[field.name].filled, + total: byField[field.name].total, + percentage: byField[field.name].total > 0 ? (byField[field.name].filled / byField[field.name].total) * 100 : 0, + }); + } + }); + }); + + // Format byTeam for response (backward compatible + dynamic) + const byTeamArray = Object.entries(byTeam).map(([team, data]) => { + const teamCategoryScores: Record = {}; + completenessConfig.categories.forEach(category => { + teamCategoryScores[category.id] = data.categoryTotal[category.id] > 0 + ? (data.categoryFilled[category.id] / data.categoryTotal[category.id]) * 100 + : 0; + }); + + const teamTotalFilled = Object.values(data.categoryFilled).reduce((sum, val) => sum + val, 0); + const teamTotalFields = Object.values(data.categoryTotal).reduce((sum, val) => sum + val, 0); + + return { + team, + generalScore: generalCategory ? teamCategoryScores[generalCategory.id] : 0, + applicationManagementScore: appMgmtCategory ? teamCategoryScores[appMgmtCategory.id] : 0, + overallScore: teamTotalFields > 0 ? (teamTotalFilled / teamTotalFields) * 100 : 0, + applicationCount: data.applicationCount, + filledFields: teamTotalFilled, + totalFields: teamTotalFields, + categoryScores: teamCategoryScores, + }; + }); + + logger.info(`Data Completeness: Calculated scores for ${allApplications.length} applications`); + + res.json({ + overall: { + generalScore: overallGeneralScore, // Backward compatibility + applicationManagementScore: overallAppMgmtScore, // Backward compatibility + overallScore, + totalApplications: allApplications.length, + filledFields: overallTotalFilled, + totalFields: overallTotalFields, + categoryScores: overallCategoryScores, // Dynamic category scores + }, + byField: byFieldArray, + byApplication, + byTeam: byTeamArray, + }); + } catch (error) { + logger.error('Failed to get data completeness', error); + res.status(500).json({ error: 'Failed to get data completeness' }); + } +}); + +// Get ZiRA Domain Coverage data +router.get('/zira-domain-coverage', async (req: Request, res: Response) => { + try { + logger.info('ZiRA Domain Coverage: Fetching all applications...'); + + // Fetch all active applications + const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined']; + + // Use batched fetching + const pageSize = 50; + let allApplications: ApplicationDetails[] = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`ZiRA Domain Coverage: Total applications to process: ${totalCount}`); + } + + // Get full details for each application to access applicationFunctions + const appDetails = await Promise.all( + searchResult.applications.map(app => dataService.getApplicationById(app.id)) + ); + + allApplications = allApplications.concat(appDetails.filter((app): app is ApplicationDetails => app !== null)); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + if (currentPage > 100) { + logger.warn('ZiRA Domain Coverage: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`ZiRA Domain Coverage: Error fetching page ${currentPage}`, fetchError); + hasMore = false; + } + } + + logger.info(`ZiRA Domain Coverage: Processing ${allApplications.length} applications`); + + // Get all available ZiRA functions + const allFunctions = await dataService.getApplicationFunctions(); + const functionCategories = await dataService.getApplicationFunctionCategories(); + + // Create a map of function ID to function data + const functionMap = new Map(); + allFunctions.forEach(func => { + functionMap.set(func.objectId, func); + }); + + // Count applications per function + const functionCounts: Record; + category?: ReferenceValue; + }> = {}; + + // Initialize all functions with 0 count + allFunctions.forEach(func => { + // Extract category from applicationFunctionCategory if it's a ReferenceValue + const category = func.applicationFunctionCategory + ? (typeof func.applicationFunctionCategory === 'object' && 'objectId' in func.applicationFunctionCategory + ? func.applicationFunctionCategory as ReferenceValue + : undefined) + : undefined; + + functionCounts[func.objectId] = { + function: func, + applicationCount: 0, + applications: [], + category, + }; + }); + + // Count applications for each function + for (const app of allApplications) { + if (app.applicationFunctions && app.applicationFunctions.length > 0) { + for (const func of app.applicationFunctions) { + if (functionCounts[func.objectId]) { + functionCounts[func.objectId].applicationCount++; + functionCounts[func.objectId].applications.push({ + id: app.id, + key: app.key, + name: app.name, + }); + } + } + } + } + + // Convert to array and categorize + const functionCoverage = Object.values(functionCounts).map(item => ({ + functionId: item.function.objectId, + functionKey: item.function.key, + functionName: item.function.name, + functionDescription: item.function.description || null, + category: item.category ? { + objectId: item.category.objectId, + key: item.category.key, + name: item.category.name, + } : null, + applicationCount: item.applicationCount, + applications: item.applications, + coverageStatus: item.applicationCount === 0 + ? 'gap' + : item.applicationCount < 3 + ? 'low' + : item.applicationCount < 10 + ? 'medium' + : 'well-supported', + })); + + // Group by category + const byCategory: Record = {}; + + // Add uncategorized functions + const uncategorizedFunctions = functionCoverage.filter(f => !f.category); + if (uncategorizedFunctions.length > 0) { + byCategory['_uncategorized'] = { + category: { objectId: '_uncategorized', key: 'UNCAT', name: 'Niet gecategoriseerd' }, + functions: uncategorizedFunctions, + totalFunctions: uncategorizedFunctions.length, + coveredFunctions: uncategorizedFunctions.filter(f => f.applicationCount > 0).length, + gapFunctions: uncategorizedFunctions.filter(f => f.applicationCount === 0).length, + }; + } + + // Group by category + functionCategories.forEach(category => { + const categoryFunctions = functionCoverage.filter(f => + f.category && String(f.category.objectId) === String(category.objectId) + ); + if (categoryFunctions.length > 0) { + byCategory[String(category.objectId)] = { + category: { + objectId: String(category.objectId), + key: category.key, + name: category.name, + }, + functions: categoryFunctions, + totalFunctions: categoryFunctions.length, + coveredFunctions: categoryFunctions.filter(f => f.applicationCount > 0).length, + gapFunctions: categoryFunctions.filter(f => f.applicationCount === 0).length, + }; + } + }); + + // Calculate overall statistics + const totalFunctions = functionCoverage.length; + const coveredFunctions = functionCoverage.filter(f => f.applicationCount > 0).length; + const gapFunctions = functionCoverage.filter(f => f.applicationCount === 0).length; + const lowCoverageFunctions = functionCoverage.filter(f => f.coverageStatus === 'low').length; + const mediumCoverageFunctions = functionCoverage.filter(f => f.coverageStatus === 'medium').length; + const wellSupportedFunctions = functionCoverage.filter(f => f.coverageStatus === 'well-supported').length; + + logger.info(`ZiRA Domain Coverage: Analyzed ${totalFunctions} functions, ${gapFunctions} gaps, ${wellSupportedFunctions} well-supported`); + + res.json({ + overall: { + totalFunctions, + coveredFunctions, + gapFunctions, + lowCoverageFunctions, + mediumCoverageFunctions, + wellSupportedFunctions, + coveragePercentage: totalFunctions > 0 ? (coveredFunctions / totalFunctions) * 100 : 0, + }, + byCategory: Object.values(byCategory), + allFunctions: functionCoverage.sort((a, b) => { + // Sort by category first, then by application count (descending) + if (a.category && b.category) { + if (a.category.name !== b.category.name) { + return a.category.name.localeCompare(b.category.name, 'nl', { sensitivity: 'base' }); + } + } else if (a.category && !b.category) { + return -1; + } else if (!a.category && b.category) { + return 1; + } + return b.applicationCount - a.applicationCount; + }), + }); + } catch (error) { + logger.error('Failed to get ZiRA domain coverage', error); + res.status(500).json({ error: 'Failed to get ZiRA domain coverage' }); + } +}); + +// Get FTE per ZiRA Domain +router.get('/fte-per-zira-domain', async (req: Request, res: Response) => { + try { + logger.info('FTE per ZiRA Domain: Fetching all applications...'); + + // Fetch all active applications + const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined']; + + // Use batched fetching + const pageSize = 50; + let allApplications: ApplicationDetails[] = []; + let currentPage = 1; + let totalCount = 0; + let hasMore = true; + + while (hasMore) { + try { + const searchResult = await dataService.searchApplications( + { statuses }, + currentPage, + pageSize + ); + + if (currentPage === 1) { + totalCount = searchResult.totalCount; + logger.info(`FTE per ZiRA Domain: Total applications to process: ${totalCount}`); + } + + // Get full details for each application to access applicationFunctions and calculate FTE + const appDetails = await Promise.all( + searchResult.applications.map(app => dataService.getApplicationById(app.id)) + ); + + allApplications = allApplications.concat(appDetails.filter((app): app is ApplicationDetails => app !== null)); + hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount; + currentPage++; + + if (currentPage > 100) { + logger.warn('FTE per ZiRA Domain: Reached page limit, stopping fetch'); + break; + } + } catch (fetchError) { + logger.error(`FTE per ZiRA Domain: Error fetching page ${currentPage}`, fetchError); + hasMore = false; + } + } + + logger.info(`FTE per ZiRA Domain: Processing ${allApplications.length} applications`); + + // Load ZiRA taxonomy to map functions to domains + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const taxonomyPath = join(__dirname, '../data/zira-taxonomy.json'); + const taxonomy = JSON.parse(readFileSync(taxonomyPath, 'utf-8')); + + // Create a map from function code to domain + const functionToDomainMap = new Map(); + taxonomy.domains.forEach((domain: { code: string; name: string; description: string; functions: Array<{ code: string }> }) => { + domain.functions.forEach((func: { code: string }) => { + functionToDomainMap.set(func.code, { + code: domain.code, + name: domain.name, + description: domain.description, + }); + }); + }); + + // Get all application functions to map their keys/codes to ZiRA function codes + const allFunctions = await dataService.getApplicationFunctions(); + + logger.info(`FTE per ZiRA Domain: Found ${allFunctions.length} application functions`); + logger.info(`FTE per ZiRA Domain: Taxonomy has ${functionToDomainMap.size} ZiRA function codes`); + + // Log sample function keys to understand the format + if (allFunctions.length > 0) { + logger.info(`FTE per ZiRA Domain: Sample function keys (first 10):`); + allFunctions.slice(0, 10).forEach(func => { + logger.info(` - Key: "${func.key}", Name: "${func.name}", ObjectId: ${func.objectId}`); + }); + + // Log sample ZiRA codes from taxonomy + const sampleZiraCodes = Array.from(functionToDomainMap.keys()).slice(0, 10); + logger.info(`FTE per ZiRA Domain: Sample ZiRA codes from taxonomy (first 10): ${sampleZiraCodes.join(', ')}`); + } + + // Create a map from function objectId/key to ZiRA function code + // Function keys might be like "STU-001" or "ICMT-269758" - we need to extract the ZiRA code + const functionIdToZiraCodeMap = new Map(); + + // Strategy 1: Try direct key match (key might be exactly the ZiRA code) + allFunctions.forEach(func => { + if (functionToDomainMap.has(func.key)) { + functionIdToZiraCodeMap.set(func.objectId, func.key); + } + }); + + // Strategy 2: Try to extract ZiRA code from key pattern (e.g., "STU-001", "ZRG-CON-001") + allFunctions.forEach(func => { + if (!functionIdToZiraCodeMap.has(func.objectId)) { + // Try to extract ZiRA code from key (e.g., "STU-001", "ZRG-CON-001") + // Pattern matches: "STU-001", "ZRG-CON-001", "GEN-WRK-001", etc. + const keyMatch = func.key.match(/^([A-Z]+-[A-Z]+-\d+|[A-Z]+-\d+)/); + if (keyMatch) { + const potentialCode = keyMatch[1]; + // Verify this code exists in the taxonomy + if (functionToDomainMap.has(potentialCode)) { + functionIdToZiraCodeMap.set(func.objectId, potentialCode); + } + } + } + }); + + // Strategy 3: Try to match by name if key didn't match + // Some functions might have keys like "ICMT-269758" but the name contains the ZiRA code + allFunctions.forEach(func => { + if (!functionIdToZiraCodeMap.has(func.objectId)) { + // Try to find ZiRA code in the function name + // Look for patterns like "STU-001", "ZRG-CON-001" in the name + const nameMatch = func.name.match(/([A-Z]+-[A-Z]+-\d+|[A-Z]+-\d+)/); + if (nameMatch) { + const potentialCode = nameMatch[1]; + if (functionToDomainMap.has(potentialCode)) { + functionIdToZiraCodeMap.set(func.objectId, potentialCode); + } + } + } + }); + + // Strategy 4: Try partial key match (e.g., if key is "ICMT-STU-001", extract "STU-001") + // Also handle keys like "ICMT-269758" where we need to find the ZiRA code in the name + allFunctions.forEach(func => { + if (!functionIdToZiraCodeMap.has(func.objectId)) { + // Look for ZiRA code pattern anywhere in the key (might have prefix like "ICMT-STU-001") + const partialMatch = func.key.match(/([A-Z]+-[A-Z]+-\d+|[A-Z]+-\d+)/); + if (partialMatch) { + const potentialCode = partialMatch[1]; + if (functionToDomainMap.has(potentialCode)) { + functionIdToZiraCodeMap.set(func.objectId, potentialCode); + } + } + } + }); + + // Strategy 5: If still no match, try to match function name to ZiRA function name + // This is a fallback - match by function name similarity + allFunctions.forEach(func => { + if (!functionIdToZiraCodeMap.has(func.objectId)) { + // Try to find a ZiRA function with a similar name + // This is less reliable but might catch some cases + const funcNameLower = func.name.toLowerCase(); + for (const [ziraCode, domain] of functionToDomainMap.entries()) { + // Get the function name from taxonomy + for (const domainData of taxonomy.domains) { + const ziraFunc = domainData.functions.find((f: { code: string }) => f.code === ziraCode); + if (ziraFunc && ziraFunc.name) { + const ziraNameLower = ziraFunc.name.toLowerCase(); + // Check if names are similar (exact match or contains) + if (funcNameLower === ziraNameLower || + funcNameLower.includes(ziraNameLower) || + ziraNameLower.includes(funcNameLower)) { + functionIdToZiraCodeMap.set(func.objectId, ziraCode); + break; + } + } + } + if (functionIdToZiraCodeMap.has(func.objectId)) break; + } + } + }); + + logger.info(`FTE per ZiRA Domain: Mapped ${functionIdToZiraCodeMap.size} functions to ZiRA codes`); + + // Group applications by domain and function, then calculate FTE + const domainFTE: Record; + }>; + applications: Array<{ + id: string; + key: string; + name: string; + fte: number; + minFte: number | null; + maxFte: number | null; + }>; + }> = {}; + + // Create a map from ZiRA function code to function details + const ziraFunctionMap = new Map(); + taxonomy.domains.forEach((domain: { code: string; name: string; description: string; functions: Array<{ code: string; name: string; description: string }> }) => { + domain.functions.forEach((func: { code: string; name: string; description: string }) => { + ziraFunctionMap.set(func.code, { + code: func.code, + name: func.name, + description: func.description, + domainCode: domain.code, + }); + }); + }); + + // Initialize all domains from taxonomy + taxonomy.domains.forEach((domain: { code: string; name: string; description: string }) => { + domainFTE[domain.code] = { + domain: { + code: domain.code, + name: domain.name, + description: domain.description, + }, + totalFTE: 0, + minFTE: 0, + maxFTE: 0, + applicationCount: 0, + functions: {}, + applications: [], + }; + }); + + // Process each application + let appsWithFTE = 0; + let appsWithFunctions = 0; + let appsWithMappedFunctions = 0; + let appsSkippedNoFTE = 0; + let appsSkippedNoMapping = 0; + + for (const app of allApplications) { + // Calculate FTE for this application + const effortResult = calculateRequiredEffortWithMinMax(app); + const appFTE = effortResult.finalEffort || 0; + const appMinFTE = effortResult.minFTE || 0; + const appMaxFTE = effortResult.maxFTE || 0; + + // Skip applications with no FTE + if (appFTE === 0 && appMinFTE === 0 && appMaxFTE === 0) { + appsSkippedNoFTE++; + continue; + } + + appsWithFTE++; + + // Find which domains and functions this application belongs to + const appDomainFunctionMap = new Map>(); // domainCode -> Set of function codes + + if (app.applicationFunctions && app.applicationFunctions.length > 0) { + appsWithFunctions++; + for (const func of app.applicationFunctions) { + // Get ZiRA code from function + const ziraCode = functionIdToZiraCodeMap.get(func.objectId); + if (ziraCode) { + const domain = functionToDomainMap.get(ziraCode); + if (domain) { + if (!appDomainFunctionMap.has(domain.code)) { + appDomainFunctionMap.set(domain.code, new Set()); + } + appDomainFunctionMap.get(domain.code)!.add(ziraCode); + } + } + } + } + + // If application has no mapped functions, skip it + if (appDomainFunctionMap.size === 0) { + appsSkippedNoMapping++; + continue; + } + + appsWithMappedFunctions++; + + // Calculate total number of function-domain combinations + let totalCombinations = 0; + appDomainFunctionMap.forEach(funcSet => { + totalCombinations += funcSet.size; + }); + + // Distribute FTE across function-domain combinations + const ftePerCombination = appFTE / totalCombinations; + const minFtePerCombination = appMinFTE / totalCombinations; + const maxFtePerCombination = appMaxFTE / totalCombinations; + + // Add to domain and function level + appDomainFunctionMap.forEach((functionCodes, domainCode) => { + if (domainFTE[domainCode]) { + const ftePerFunction = ftePerCombination; + const minFtePerFunction = minFtePerCombination; + const maxFtePerFunction = maxFtePerCombination; + + functionCodes.forEach(functionCode => { + // Initialize function if not exists + if (!domainFTE[domainCode].functions[functionCode]) { + const funcInfo = ziraFunctionMap.get(functionCode); + if (funcInfo) { + domainFTE[domainCode].functions[functionCode] = { + function: { + code: funcInfo.code, + name: funcInfo.name, + description: funcInfo.description, + }, + totalFTE: 0, + minFTE: 0, + maxFTE: 0, + applicationCount: 0, + applications: [], + }; + } + } + + // Add to function level + if (domainFTE[domainCode].functions[functionCode]) { + domainFTE[domainCode].functions[functionCode].totalFTE += ftePerFunction; + domainFTE[domainCode].functions[functionCode].minFTE += minFtePerFunction; + domainFTE[domainCode].functions[functionCode].maxFTE += maxFtePerFunction; + domainFTE[domainCode].functions[functionCode].applicationCount++; + + domainFTE[domainCode].functions[functionCode].applications.push({ + id: app.id, + key: app.key, + name: app.name, + fte: ftePerFunction, + minFte: minFtePerFunction > 0 ? minFtePerFunction : null, + maxFte: maxFtePerFunction > 0 ? maxFtePerFunction : null, + }); + } + + // Add to domain level (aggregate) + domainFTE[domainCode].totalFTE += ftePerFunction; + domainFTE[domainCode].minFTE += minFtePerFunction; + domainFTE[domainCode].maxFTE += maxFtePerFunction; + }); + + // Count unique applications per domain (only count once per domain) + const alreadyCounted = domainFTE[domainCode].applications.some(a => a.id === app.id); + if (!alreadyCounted) { + domainFTE[domainCode].applicationCount++; + domainFTE[domainCode].applications.push({ + id: app.id, + key: app.key, + name: app.name, + fte: ftePerCombination * functionCodes.size, // Total FTE for this domain + minFte: minFtePerCombination * functionCodes.size > 0 ? minFtePerCombination * functionCodes.size : null, + maxFte: maxFtePerCombination * functionCodes.size > 0 ? maxFtePerCombination * functionCodes.size : null, + }); + } + } + }); + } + + // Convert functions to arrays and sort + const domainFTEArray = Object.values(domainFTE) + .filter(d => d.totalFTE > 0 || d.applicationCount > 0) + .map(domain => ({ + ...domain, + functions: Object.values(domain.functions) + .sort((a, b) => b.totalFTE - a.totalFTE), + })) + .sort((a, b) => b.totalFTE - a.totalFTE); + + // Calculate totals + const totalFTE = domainFTEArray.reduce((sum, d) => sum + d.totalFTE, 0); + const totalMinFTE = domainFTEArray.reduce((sum, d) => sum + d.minFTE, 0); + const totalMaxFTE = domainFTEArray.reduce((sum, d) => sum + d.maxFTE, 0); + const totalApplications = domainFTEArray.reduce((sum, d) => sum + d.applicationCount, 0); + + logger.info(`FTE per ZiRA Domain: Calculated FTE for ${domainFTEArray.length} domains, total FTE: ${totalFTE.toFixed(2)}`); + logger.info(`FTE per ZiRA Domain: Stats - Apps with FTE: ${appsWithFTE}, Apps with functions: ${appsWithFunctions}, Apps with mapped functions: ${appsWithMappedFunctions}, Skipped (no FTE): ${appsSkippedNoFTE}, Skipped (no mapping): ${appsSkippedNoMapping}`); + + res.json({ + overall: { + totalFTE, + totalMinFTE, + totalMaxFTE, + totalApplications, + domainCount: domainFTEArray.length, + }, + byDomain: domainFTEArray, + }); + } catch (error) { + logger.error('Failed to get FTE per ZiRA domain', error); + res.status(500).json({ error: 'Failed to get FTE per ZiRA domain' }); + } +}); + export default router; diff --git a/backend/src/routes/objects.ts b/backend/src/routes/objects.ts index a495b44..4e5aa17 100644 --- a/backend/src/routes/objects.ts +++ b/backend/src/routes/objects.ts @@ -51,7 +51,7 @@ router.get('/:type', async (req: Request, res: Response) => { searchTerm: search, }); - const count = cmdbService.countObjects(type as CMDBObjectTypeName); + const count = await cmdbService.countObjects(type as CMDBObjectTypeName); res.json({ objectType: type, diff --git a/backend/src/routes/schema.ts b/backend/src/routes/schema.ts index 2dce0c2..6cc8f1c 100644 --- a/backend/src/routes/schema.ts +++ b/backend/src/routes/schema.ts @@ -1,6 +1,10 @@ import { Router } from 'express'; import { OBJECT_TYPES, SCHEMA_GENERATED_AT, SCHEMA_OBJECT_TYPE_COUNT, SCHEMA_TOTAL_ATTRIBUTES } from '../generated/jira-schema.js'; import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js'; +import { dataService } from '../services/dataService.js'; +import { logger } from '../services/logger.js'; +import { jiraAssetsClient } from '../services/jiraAssetsClient.js'; +import type { CMDBObjectTypeName } from '../generated/jira-types.js'; const router = Router(); @@ -27,13 +31,15 @@ interface SchemaResponse { totalAttributes: number; }; objectTypes: Record; + cacheCounts?: Record; // Cache counts by type name (from objectsByType) + jiraCounts?: Record; // Actual counts from Jira Assets API } /** * GET /api/schema * Returns the complete Jira Assets schema with object types, attributes, and links */ -router.get('/', (req, res) => { +router.get('/', async (req, res) => { try { // Build links between object types const objectTypesWithLinks: Record = {}; @@ -72,6 +78,41 @@ router.get('/', (req, res) => { } } + // Get cache counts (objectsByType) if available + let cacheCounts: Record | undefined; + try { + const cacheStatus = await dataService.getCacheStatus(); + cacheCounts = cacheStatus.objectsByType; + } catch (err) { + logger.debug('Could not fetch cache counts for schema response', err); + // Continue without cache counts - not critical + } + + // Fetch actual counts from Jira Assets for all object types + // This ensures the counts match exactly what's in Jira Assets + const jiraCounts: Record = {}; + const typeNames = Object.keys(OBJECT_TYPES) as CMDBObjectTypeName[]; + + logger.info(`Schema: Fetching object counts from Jira Assets for ${typeNames.length} object types...`); + + // Fetch counts in parallel for better performance + const countPromises = typeNames.map(async (typeName) => { + try { + const count = await jiraAssetsClient.getObjectCount(typeName); + jiraCounts[typeName] = count; + return { typeName, count }; + } catch (error) { + logger.warn(`Schema: Failed to get count for ${typeName}`, error); + // Use 0 as fallback if API call fails + jiraCounts[typeName] = 0; + return { typeName, count: 0 }; + } + }); + + await Promise.all(countPromises); + + logger.info(`Schema: Fetched counts for ${Object.keys(jiraCounts).length} object types from Jira Assets`); + const response: SchemaResponse = { metadata: { generatedAt: SCHEMA_GENERATED_AT, @@ -79,6 +120,8 @@ router.get('/', (req, res) => { totalAttributes: SCHEMA_TOTAL_ATTRIBUTES, }, objectTypes: objectTypesWithLinks, + cacheCounts, + jiraCounts, }; res.json(response); diff --git a/backend/src/services/biaMatchingService.ts b/backend/src/services/biaMatchingService.ts new file mode 100644 index 0000000..4591c7f --- /dev/null +++ b/backend/src/services/biaMatchingService.ts @@ -0,0 +1,678 @@ +/** + * BIA Matching Service + * + * Provides functionality to: + * - Load BIA data from Excel file + * - Match applications with Excel BIA values using smart algorithms + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as XLSX from 'xlsx'; +import { logger } from './logger.js'; + +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// BIA Excel data cache +export interface BIARecord { + applicationName: string; + biaValue: string; +} + +export interface BIAMatchResult { + biaValue: string | null; + excelApplicationName: string | null; + matchType: 'exact' | 'search_reference' | 'fuzzy' | null; + matchConfidence?: number; + allMatches?: Array<{ + excelApplicationName: string; + biaValue: string; + matchType: 'exact' | 'search_reference' | 'partial_starts' | 'partial_contains' | 'fuzzy'; + confidence: number; + }>; +} + +let biaDataCache: BIARecord[] | null = null; +let biaDataCacheTimestamp: number = 0; +const BIA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Clear the BIA data cache (useful for debugging or forcing reload) + */ +export function clearBIACache(): void { + biaDataCache = null; + biaDataCacheTimestamp = 0; + logger.info('BIA data cache cleared'); +} + +/** + * Load BIA data from Excel file + */ +export function loadBIAData(): BIARecord[] { + const now = Date.now(); + // Return cached data if still valid AND has records + // Don't use cache if it's empty (indicates previous load failure) + if (biaDataCache && biaDataCache.length > 0 && (now - biaDataCacheTimestamp) < BIA_CACHE_TTL) { + logger.debug(`Using cached BIA data (${biaDataCache.length} records, cached ${Math.round((now - biaDataCacheTimestamp) / 1000)}s ago)`); + return biaDataCache; + } + + // Clear cache if it's empty or expired + if (biaDataCache && biaDataCache.length === 0) { + logger.warn('Cache contains 0 records, clearing and reloading from Excel file'); + biaDataCache = null; + biaDataCacheTimestamp = 0; + } + + logger.info('Loading BIA data from Excel file (cache expired, empty, or not available)'); + + // Try multiple possible paths for BIA.xlsx + const possiblePaths = [ + join(__dirname, '../../data/BIA.xlsx'), // From dist/services/ -> backend/data/ + join(process.cwd(), 'backend/data/BIA.xlsx'), // From project root + join(process.cwd(), 'data/BIA.xlsx'), // From current working directory + join(__dirname, '../../../backend/data/BIA.xlsx'), // Alternative path + ]; + + let biaFilePath: string | null = null; + for (const path of possiblePaths) { + if (existsSync(path)) { + biaFilePath = path; + logger.info(`Found BIA.xlsx at: ${path}`); + break; + } else { + logger.debug(`BIA.xlsx not found at: ${path}`); + } + } + + if (!biaFilePath) { + logger.error(`BIA.xlsx file not found in any of the following locations:`); + possiblePaths.forEach(p => logger.error(` - ${p}`)); + logger.error(`Current working directory: ${process.cwd()}`); + logger.error(`__dirname: ${__dirname}`); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } + + logger.info(`Loading BIA data from: ${biaFilePath}`); + + try { + // Read file using readFileSync and then parse with XLSX.read + // This works better in ES modules than XLSX.readFile + const fileBuffer = readFileSync(biaFilePath); + const workbook = XLSX.read(fileBuffer, { type: 'buffer' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; + + logger.info(`Loaded Excel file: ${data.length} rows, first row has ${data[0]?.length || 0} columns`); + if (data.length > 0 && data[0]) { + logger.info(`First row (header?): Column C (index 2) = "${data[0][2] || '(empty)'}", Column K (index 10) = "${data[0][10] || '(empty)'}"`); + logger.info(`First row all columns (first 12): ${data[0].slice(0, 12).map((cell: any, idx: number) => `[${String.fromCharCode(65 + idx)}: ${String(cell || '').substring(0, 20)}]`).join(' | ')}`); + } + if (data.length > 1 && data[1]) { + logger.info(`Second row (first data?): Column C = "${data[1][2] || '(empty)'}", Column K = "${data[1][10] || '(empty)'}"`); + } + if (data.length > 2 && data[2]) { + logger.info(`Third row: Column C = "${data[2][2] || '(empty)'}", Column K = "${data[2][10] || '(empty)'}"`); + } + + // User confirmed: Column C (index 2) = "BIA - Informatiemiddel", Column K (index 10) = "BIA - Bruto risicoscore" + // ALWAYS use these column positions - don't try to detect + const applicationNameColumnIndex = 2; // Column C + const biaValueColumnIndex = 10; // Column K + + // Find header row by checking if column C contains "BIA - Informatiemiddel" + let headerRowIndex = -1; + for (let i = 0; i < Math.min(5, data.length); i++) { + const row = data[i]; + if (!row || row.length < 11) continue; // Need at least column K (index 10) + + const cellC = String(row[2] || '').trim().toLowerCase(); + if (cellC.includes('bia') && cellC.includes('informatiemiddel')) { + headerRowIndex = i; + logger.info(`Found header row at index ${i}: Column C = "${row[2]}", Column K = "${row[10] || '(empty)'}"`); + break; + } + } + + // If header not found, assume row 0 is header + if (headerRowIndex === -1) { + headerRowIndex = 0; + logger.warn(`Header row not found, assuming row 0 is header. Column C = "${data[0]?.[2] || '(empty)'}", Column K = "${data[0]?.[10] || '(empty)'}"`); + } + + logger.info(`Using BIA columns: Application name at column C (index ${applicationNameColumnIndex}), BIA value at column K (index ${biaValueColumnIndex})`); + logger.info(`Header row: ${headerRowIndex}, will start reading data from row ${headerRowIndex + 1}`); + + // Extract data starting from the row after the header + const records: BIARecord[] = []; + let skippedRows = 0; + let rowsWithoutBIA = 0; + + for (let i = headerRowIndex + 1; i < data.length; i++) { + const row = data[i]; + if (!row || row.length <= applicationNameColumnIndex) { + skippedRows++; + continue; + } + + const applicationName = String(row[applicationNameColumnIndex] || '').trim(); + + if (!applicationName || applicationName.length === 0) { + skippedRows++; + continue; // Skip empty rows + } + + // Get BIA value from column K (index 10) + let biaValue = ''; + if (row.length > biaValueColumnIndex) { + biaValue = String(row[biaValueColumnIndex] || '').trim().toUpperCase(); + } else { + rowsWithoutBIA++; + logger.debug(`Row ${i + 1} does not have enough columns for BIA value (need column K, index ${biaValueColumnIndex}, but row has only ${row.length} columns). App name: "${applicationName}"`); + continue; + } + + // Extract just the letter if the value contains more than just A-F (e.g., "A - Test/Archief") + if (biaValue && !/^[A-F]$/.test(biaValue)) { + const match = biaValue.match(/^([A-F])/); + if (match) { + biaValue = match[1]; + } else { + // If no A-F found, skip this row + rowsWithoutBIA++; + logger.debug(`Row ${i + 1}: BIA value "${row[biaValueColumnIndex]}" does not contain A-F. App name: "${applicationName}"`); + continue; + } + } + + // Only add record if we have both application name and valid BIA value (A-F) + if (applicationName && biaValue && /^[A-F]$/.test(biaValue)) { + records.push({ + applicationName: applicationName, + biaValue: biaValue, + }); + } else if (applicationName && !biaValue) { + rowsWithoutBIA++; + logger.debug(`Row ${i + 1}: Application "${applicationName}" has no BIA value in column K`); + } + } + + logger.info(`Processed ${data.length - headerRowIndex - 1} data rows: ${records.length} valid records, ${skippedRows} empty rows skipped, ${rowsWithoutBIA} rows without valid BIA value`); + + logger.info(`Loaded ${records.length} BIA records from Excel file`); + if (records.length > 0) { + logger.info(`Sample BIA records (first 10):`); + records.slice(0, 10).forEach((r, idx) => { + logger.info(` ${idx + 1}. "${r.applicationName}" -> BIA: ${r.biaValue}`); + }); + if (records.length > 10) { + logger.info(` ... and ${records.length - 10} more records`); + } + } else { + logger.error('No BIA records loaded from Excel file - check file format and column detection'); + logger.error(`Header row index: ${headerRowIndex}, Application name column: C (index ${applicationNameColumnIndex}), BIA value column: K (index ${biaValueColumnIndex})`); + logger.error(`Total rows in Excel: ${data.length}, checking rows from ${headerRowIndex + 1} to ${data.length - 1}`); + logger.error(`Skipped ${skippedRows} empty rows, ${rowsWithoutBIA} rows without valid BIA value`); + + // Log a few sample rows to help debug + if (data.length > headerRowIndex + 1) { + logger.error('Sample rows from Excel:'); + for (let sampleRow = headerRowIndex + 1; sampleRow < Math.min(headerRowIndex + 6, data.length); sampleRow++) { + const row = data[sampleRow]; + if (row) { + const appName = String(row[2] || '').trim(); + const biaVal = String(row[10] || '').trim(); + logger.error(` Row ${sampleRow + 1}: Column C = "${appName || '(empty)'}", Column K = "${biaVal || '(empty)'}"`); + } + } + } + } + biaDataCache = records; + biaDataCacheTimestamp = now; + return records; + } catch (error) { + logger.error('Failed to load BIA data from Excel', error); + biaDataCache = []; + biaDataCacheTimestamp = now; + return []; + } +} + +/** + * Calculate Levenshtein distance for fuzzy matching + */ +function levenshteinDistance(str1: string, str2: string): number { + const matrix: number[][] = []; + const len1 = str1.length; + const len2 = str2.length; + + if (len1 === 0) return len2; + if (len2 === 0) return len1; + + for (let i = 0; i <= len1; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= len2; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } + + return matrix[len1][len2]; +} + +/** + * Calculate similarity score (0-1, where 1 is identical) + */ +export function calculateSimilarity(str1: string, str2: string): number { + const maxLen = Math.max(str1.length, str2.length); + if (maxLen === 0) return 1; + const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase()); + return 1 - (distance / maxLen); +} + +/** + * Tokenize a string into words (handles special characters, hyphens, etc.) + */ +function tokenize(str: string): string[] { + return str + .toLowerCase() + .replace(/[^\w\s-]/g, ' ') // Replace special chars with space + .split(/[\s-]+/) // Split on spaces and hyphens + .filter(t => t.length > 0); // Remove empty tokens +} + +/** + * Calculate word-based similarity (percentage of matching words) + */ +function wordBasedSimilarity(str1: string, str2: string): number { + const tokens1 = new Set(tokenize(str1)); + const tokens2 = new Set(tokenize(str2)); + + if (tokens1.size === 0 && tokens2.size === 0) return 1; + if (tokens1.size === 0 || tokens2.size === 0) return 0; + + const intersection = new Set([...tokens1].filter(t => tokens2.has(t))); + const union = new Set([...tokens1, ...tokens2]); + + return intersection.size / union.size; // Jaccard similarity +} + +/** + * Find BIA match for an application using smart matching algorithm + * + * Matching priority: + * 1. Exact match on application name (case-insensitive) + * 2. Exact match on search reference (if available) + * 3. Partial match (starts with / contains) + * 4. Word-based matching (token matching) + * 5. Fuzzy match using Levenshtein distance (threshold 0.6) + * + * If multiple matches are found, the best one is selected based on: + * - Match type priority (exact > partial > word-based > fuzzy) + * - Confidence/similarity score + * - Length similarity (prefer matches with similar length) + */ +export function findBIAMatch( + applicationName: string, + searchReference: string | null +): BIAMatchResult { + const biaData = loadBIAData(); + if (biaData.length === 0) { + logger.warn(`No BIA data available for lookup of "${applicationName}" (biaData.length = 0)`); + return { + biaValue: null, + excelApplicationName: null, + matchType: null, + }; + } + + logger.info(`[BIA MATCH] Searching for "${applicationName}"${searchReference ? ` (searchRef: "${searchReference}")` : ''} in ${biaData.length} Excel records`); + + const normalizedAppName = applicationName.toLowerCase().trim(); + const normalizedSearchRef = searchReference ? searchReference.toLowerCase().trim() : null; + + // Log first few Excel records for debugging + if (biaData.length > 0) { + logger.info(`[BIA MATCH] Sample Excel records: ${biaData.slice(0, 5).map(r => `"${r.applicationName}"`).join(', ')}`); + } + + // Step 1: Try exact match on name (case-insensitive) + logger.info(`[BIA MATCH] Step 1: Trying exact name match for "${normalizedAppName}"`); + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + if (normalizedAppName === normalizedRecordName) { + logger.info(`[BIA MATCH] ✓ Found exact BIA match on name: "${applicationName}" = "${record.applicationName}" -> BIA: ${record.biaValue}`); + return { + biaValue: record.biaValue, + excelApplicationName: record.applicationName, + matchType: 'exact', + }; + } + } + logger.info(`[BIA MATCH] Step 1: No exact name match found`); + + // Step 2: Try exact match on search reference (if available) + if (normalizedSearchRef) { + logger.info(`[BIA MATCH] Step 2: Trying exact search reference match for "${normalizedSearchRef}"`); + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + if (normalizedSearchRef === normalizedRecordName) { + logger.info(`[BIA MATCH] ✓ Found exact BIA match on search reference: "${searchReference}" = "${record.applicationName}" -> BIA: ${record.biaValue}`); + return { + biaValue: record.biaValue, + excelApplicationName: record.applicationName, + matchType: 'search_reference', + }; + } + } + logger.info(`[BIA MATCH] Step 2: No exact search reference match found`); + } else { + logger.info(`[BIA MATCH] Step 2: Skipped (no search reference available)`); + } + + // Step 2.5: Try partial match (one name contains the other or starts with the other) + // This handles cases like "Aanmeldzuilen" matching "Aanmeldzuilen LogisP" or "Awareways" matching "Awareways E-Learning" + logger.info(`[BIA MATCH] Step 2.5: Trying partial match (starts with / contains) for "${normalizedAppName}"`); + let bestPartialMatch: { value: string; recordName: string; confidence: number; type: 'partial_starts' | 'partial_contains' } | null = null; + const allPartialMatches: Array<{ value: string; recordName: string; type: 'partial_starts' | 'partial_contains'; confidence: number }> = []; + + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + + // Check if one name starts with the other (strongest signal) + if (normalizedAppName.startsWith(normalizedRecordName) || normalizedRecordName.startsWith(normalizedAppName)) { + // Calculate confidence based on length ratio + const shorter = Math.min(normalizedAppName.length, normalizedRecordName.length); + const longer = Math.max(normalizedAppName.length, normalizedRecordName.length); + const baseConfidence = shorter / longer; + + // For "starts with" matches, boost confidence if the shorter name is at least 5 characters + const confidence = shorter >= 5 ? Math.max(baseConfidence, 0.45) : baseConfidence; + + allPartialMatches.push({ + value: record.biaValue, + recordName: record.applicationName, + type: 'partial_starts', + confidence, + }); + + if (!bestPartialMatch || confidence > bestPartialMatch.confidence) { + bestPartialMatch = { + value: record.biaValue, + recordName: record.applicationName, + confidence, + type: 'partial_starts', + }; + } + } + // Also check if one contains the other (weaker signal, but still valid) + else if (normalizedAppName.includes(normalizedRecordName) || normalizedRecordName.includes(normalizedAppName)) { + const shorter = Math.min(normalizedAppName.length, normalizedRecordName.length); + const longer = Math.max(normalizedAppName.length, normalizedRecordName.length); + const confidence = (shorter / longer) * 0.8; // Lower confidence for contains vs starts with + + allPartialMatches.push({ + value: record.biaValue, + recordName: record.applicationName, + type: 'partial_contains', + confidence, + }); + + if (!bestPartialMatch || confidence > bestPartialMatch.confidence) { + bestPartialMatch = { + value: record.biaValue, + recordName: record.applicationName, + confidence, + type: 'partial_contains', + }; + } + } + } + + if (allPartialMatches.length > 0) { + logger.info(`[BIA MATCH] Step 2.5: Found ${allPartialMatches.length} partial matches: ${allPartialMatches.slice(0, 5).map(m => `"${m.recordName}" (${m.type}, conf: ${(m.confidence * 100).toFixed(1)}%)`).join(', ')}`); + if (allPartialMatches.length > 1) { + logger.info(`[BIA MATCH] Multiple partial matches found! All matches: ${allPartialMatches.map(m => `"${m.recordName}" (${(m.confidence * 100).toFixed(1)}%)`).join(', ')}`); + } + } else { + logger.info(`[BIA MATCH] Step 2.5: No partial matches found`); + } + + // Lower threshold for "starts with" matches (0.4 instead of 0.5) to handle cases like + // "Awareways" matching "Awareways E-Learning" where confidence is 9/21 = 0.43 + if (bestPartialMatch && bestPartialMatch.confidence >= 0.4) { + logger.info(`[BIA MATCH] ✓ Found partial BIA match: "${applicationName}" -> "${bestPartialMatch.recordName}": ${bestPartialMatch.value} (confidence: ${(bestPartialMatch.confidence * 100).toFixed(1)}%)`); + + // Sort all matches by confidence (descending) for transparency + const sortedMatches = allPartialMatches + .filter(m => m.confidence >= 0.4) + .sort((a, b) => b.confidence - a.confidence) + .map(m => ({ + excelApplicationName: m.recordName, + biaValue: m.value, + matchType: m.type as 'partial_starts' | 'partial_contains', + confidence: m.confidence, + })); + + return { + biaValue: bestPartialMatch.value, + excelApplicationName: bestPartialMatch.recordName, + matchType: 'fuzzy', // Use fuzzy type for partial matches + matchConfidence: bestPartialMatch.confidence, + allMatches: sortedMatches.length > 1 ? sortedMatches : undefined, + }; + } else if (bestPartialMatch) { + logger.info(`[BIA MATCH] Step 2.5: Best partial match confidence (${(bestPartialMatch.confidence * 100).toFixed(1)}%) below threshold (40%)`); + } + + // Step 2.6: Try word-based matching (token matching) + // This helps with cases where words match but order differs, or extra words are present + logger.info(`[BIA MATCH] Step 2.6: Trying word-based matching for "${normalizedAppName}"`); + let bestWordMatch: { value: string; recordName: string; confidence: number } | null = null; + const allWordMatches: Array<{ value: string; recordName: string; confidence: number }> = []; + const wordThreshold = 0.5; // 50% of words must match + + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + const wordSimilarity = wordBasedSimilarity(normalizedAppName, normalizedRecordName); + + if (wordSimilarity >= wordThreshold) { + // Combine word similarity with length similarity for better scoring + const lengthSimilarity = 1 - Math.abs(normalizedAppName.length - normalizedRecordName.length) / Math.max(normalizedAppName.length, normalizedRecordName.length); + const confidence = (wordSimilarity * 0.7) + (lengthSimilarity * 0.3); + + allWordMatches.push({ + value: record.biaValue, + recordName: record.applicationName, + confidence, + }); + + if (!bestWordMatch || confidence > bestWordMatch.confidence) { + bestWordMatch = { + value: record.biaValue, + recordName: record.applicationName, + confidence, + }; + } + } + } + + if (allWordMatches.length > 0) { + logger.info(`[BIA MATCH] Step 2.6: Found ${allWordMatches.length} word-based matches: ${allWordMatches.slice(0, 5).map(m => `"${m.recordName}" (${(m.confidence * 100).toFixed(1)}%)`).join(', ')}`); + if (allWordMatches.length > 1) { + logger.info(`[BIA MATCH] Multiple word-based matches found! All matches: ${allWordMatches.map(m => `"${m.recordName}" (${(m.confidence * 100).toFixed(1)}%)`).join(', ')}`); + } + } + + // Step 3: Try fuzzy matching with threshold + // Note: Fuzzy matches are shown to users with confidence percentage so they can verify + // Basic safeguard: Require some word overlap OR high similarity to prevent completely unrelated matches + logger.info(`[BIA MATCH] Step 3: Trying fuzzy match (Levenshtein distance) for "${normalizedAppName}"`); + let bestMatch: { value: string; similarity: number; recordName: string } | null = null; + const threshold = 0.6; // 60% similarity threshold + const minWordSimilarity = 0.05; // Require at least 5% word overlap OR high similarity (80%+) + const highSimilarityThreshold = 0.8; // If similarity is this high, word overlap not required + const allFuzzyMatches: Array<{ value: string; recordName: string; similarity: number }> = []; + + // Try fuzzy match on name + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + const similarity = calculateSimilarity(normalizedAppName, normalizedRecordName); + const wordSim = wordBasedSimilarity(normalizedAppName, normalizedRecordName); + + // Accept if: (high similarity) OR (medium similarity + word overlap) + // This allows high-confidence matches even without word overlap, but requires word overlap for medium confidence + const isHighSimilarity = similarity >= highSimilarityThreshold; + const hasWordOverlap = wordSim >= minWordSimilarity; + const isAcceptable = similarity >= threshold && (isHighSimilarity || hasWordOverlap); + + if (isAcceptable) { + allFuzzyMatches.push({ + value: record.biaValue, + recordName: record.applicationName, + similarity, + }); + if (!bestMatch || similarity > bestMatch.similarity) { + bestMatch = { + value: record.biaValue, + similarity: similarity, + recordName: record.applicationName, + }; + } + } else if (similarity >= threshold) { + logger.debug(`[BIA MATCH] Rejected fuzzy match "${record.applicationName}" (similarity: ${(similarity * 100).toFixed(1)}%, word similarity: ${(wordSim * 100).toFixed(1)}% - insufficient word overlap and similarity below high threshold)`); + } + } + + // Also try fuzzy match on search reference if available + if (normalizedSearchRef) { + logger.info(`[BIA MATCH] Step 3: Also trying fuzzy match on search reference "${normalizedSearchRef}"`); + for (const record of biaData) { + const normalizedRecordName = record.applicationName.toLowerCase().trim(); + const similarity = calculateSimilarity(normalizedSearchRef, normalizedRecordName); + const wordSim = wordBasedSimilarity(normalizedSearchRef, normalizedRecordName); + + // Same logic: high similarity OR (medium similarity + word overlap) + const isHighSimilarity = similarity >= highSimilarityThreshold; + const hasWordOverlap = wordSim >= minWordSimilarity; + const isAcceptable = similarity >= threshold && (isHighSimilarity || hasWordOverlap); + + if (isAcceptable) { + allFuzzyMatches.push({ + value: record.biaValue, + recordName: record.applicationName, + similarity, + }); + if (!bestMatch || similarity > bestMatch.similarity) { + bestMatch = { + value: record.biaValue, + similarity: similarity, + recordName: record.applicationName, + }; + } + } else if (similarity >= threshold) { + logger.debug(`[BIA MATCH] Rejected fuzzy match "${record.applicationName}" via search ref (similarity: ${(similarity * 100).toFixed(1)}%, word similarity: ${(wordSim * 100).toFixed(1)}% - insufficient word overlap and similarity below high threshold)`); + } + } + } + + if (allFuzzyMatches.length > 0) { + logger.info(`[BIA MATCH] Step 3: Found ${allFuzzyMatches.length} fuzzy matches above threshold: ${allFuzzyMatches.slice(0, 5).map(m => `"${m.recordName}" (${(m.similarity * 100).toFixed(1)}%)`).join(', ')}`); + if (allFuzzyMatches.length > 1) { + logger.info(`[BIA MATCH] Multiple fuzzy matches found! All matches: ${allFuzzyMatches.map(m => `"${m.recordName}" (${(m.similarity * 100).toFixed(1)}%)`).join(', ')}`); + } + } else { + logger.info(`[BIA MATCH] Step 3: No fuzzy matches found above threshold (${(threshold * 100).toFixed(0)}%)`); + } + + // Choose the best match from all available options + // Priority: partial match > word-based match > fuzzy match + if (bestPartialMatch && bestPartialMatch.confidence >= 0.4) { + const sortedMatches = allPartialMatches + .filter(m => m.confidence >= 0.4) + .sort((a, b) => b.confidence - a.confidence) + .map(m => ({ + excelApplicationName: m.recordName, + biaValue: m.value, + matchType: m.type as 'partial_starts' | 'partial_contains', + confidence: m.confidence, + })); + + logger.info(`[BIA MATCH] ✓ Selected partial match: "${applicationName}" -> "${bestPartialMatch.recordName}": ${bestPartialMatch.value} (confidence: ${(bestPartialMatch.confidence * 100).toFixed(1)}%)`); + return { + biaValue: bestPartialMatch.value, + excelApplicationName: bestPartialMatch.recordName, + matchType: 'fuzzy', + matchConfidence: bestPartialMatch.confidence, + allMatches: sortedMatches.length > 1 ? sortedMatches : undefined, + }; + } else if (bestWordMatch && bestWordMatch.confidence >= 0.5) { + const sortedMatches = allWordMatches + .filter(m => m.confidence >= 0.5) + .sort((a, b) => b.confidence - a.confidence) + .map(m => ({ + excelApplicationName: m.recordName, + biaValue: m.value, + matchType: 'fuzzy' as const, + confidence: m.confidence, + })); + + logger.info(`[BIA MATCH] ✓ Selected word-based match: "${applicationName}" -> "${bestWordMatch.recordName}": ${bestWordMatch.value} (confidence: ${(bestWordMatch.confidence * 100).toFixed(1)}%)`); + return { + biaValue: bestWordMatch.value, + excelApplicationName: bestWordMatch.recordName, + matchType: 'fuzzy', + matchConfidence: bestWordMatch.confidence, + allMatches: sortedMatches.length > 1 ? sortedMatches : undefined, + }; + } else if (bestMatch) { + const sortedMatches = allFuzzyMatches + .sort((a, b) => b.similarity - a.similarity) + .map(m => ({ + excelApplicationName: m.recordName, + biaValue: m.value, + matchType: 'fuzzy' as const, + confidence: m.similarity, + })); + + logger.info(`[BIA MATCH] ✓ Selected fuzzy match: "${applicationName}" -> "${bestMatch.recordName}": ${bestMatch.value} (similarity: ${(bestMatch.similarity * 100).toFixed(1)}%)`); + return { + biaValue: bestMatch.value, + excelApplicationName: bestMatch.recordName, + matchType: 'fuzzy', + matchConfidence: bestMatch.similarity, + allMatches: sortedMatches.length > 1 ? sortedMatches : undefined, + }; + } + + logger.warn(`[BIA MATCH] ✗ No BIA match found for "${applicationName}"${searchReference ? ` (searchRef: "${searchReference}")` : ''} after checking ${biaData.length} Excel records`); + logger.warn(`[BIA MATCH] Normalized app name: "${normalizedAppName}"`); + if (normalizedSearchRef) { + logger.warn(`[BIA MATCH] Normalized search ref: "${normalizedSearchRef}"`); + } + logger.warn(`[BIA MATCH] Sample Excel names to compare: ${biaData.slice(0, 10).map(r => `"${r.applicationName.toLowerCase().trim()}"`).join(', ')}`); + + return { + biaValue: null, + excelApplicationName: null, + matchType: null, + }; +} diff --git a/backend/src/services/cacheStore.ts b/backend/src/services/cacheStore.ts index f7fe425..906761b 100644 --- a/backend/src/services/cacheStore.ts +++ b/backend/src/services/cacheStore.ts @@ -1,17 +1,18 @@ /** - * CacheStore - SQLite cache operations for CMDB objects + * CacheStore - Database-agnostic cache operations for CMDB objects * * Provides fast local storage for CMDB data synced from Jira Assets. - * Uses the generated schema for type-safe operations. + * Uses database adapter pattern to support both SQLite and PostgreSQL. */ -import Database from 'better-sqlite3'; import { join, dirname } from 'path'; import * as path from 'path'; import * as fs from 'fs'; import { logger } from './logger.js'; import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js'; import { getReferenceAttributes } from '../generated/jira-schema.js'; +import { createDatabaseAdapter } from './database/factory.js'; +import type { DatabaseAdapter } from './database/interface.js'; // Get current directory for ESM const currentFileUrl = new URL(import.meta.url); @@ -37,75 +38,150 @@ export interface QueryOptions { } class CacheStore { - private db: Database.Database; + private db: DatabaseAdapter; private initialized: boolean = false; + private initializationPromise: Promise | null = null; + private isPostgres: boolean = false; constructor() { - // Ensure data directory exists - const dataDir = dirname(CACHE_DB_PATH); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - } - - this.db = new Database(CACHE_DB_PATH); - this.initialize(); + // Create database adapter based on environment + this.db = createDatabaseAdapter( + process.env.DATABASE_TYPE, + process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql' + ? undefined + : CACHE_DB_PATH + ); + this.isPostgres = (process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'); + // Start initialization but don't wait for it + this.initializationPromise = this.initialize(); } - private initialize(): void { + /** + * Ensure database is initialized before executing queries + */ + private async ensureInitialized(): Promise { + if (this.initialized) return; + if (this.initializationPromise) { + await this.initializationPromise; + return; + } + // If for some reason initialization wasn't started, start it now + this.initializationPromise = this.initialize(); + await this.initializationPromise; + } + + private async initialize(): Promise { if (this.initialized) return; - // Read and execute the generated schema - const schemaPath = join(__dirname, '../generated/db-schema.sql'); - - if (fs.existsSync(schemaPath)) { - const schema = fs.readFileSync(schemaPath, 'utf-8'); - this.db.exec(schema); - logger.info('CacheStore: Database schema initialized from generated file'); - } else { - // Fallback: create tables directly - this.db.exec(` - CREATE TABLE IF NOT EXISTS cached_objects ( - id TEXT PRIMARY KEY, - object_key TEXT NOT NULL UNIQUE, - object_type TEXT NOT NULL, - label TEXT NOT NULL, - data JSON NOT NULL, - jira_updated_at TEXT, - jira_created_at TEXT, - cached_at TEXT NOT NULL - ); + try { + // Determine which schema file to use + const schemaPath = this.isPostgres + ? join(__dirname, '../generated/db-schema-postgres.sql') + : join(__dirname, '../generated/db-schema.sql'); + + if (fs.existsSync(schemaPath)) { + const schema = fs.readFileSync(schemaPath, 'utf-8'); + await this.db.exec(schema); + logger.info(`CacheStore: Database schema initialized from generated file (${this.isPostgres ? 'PostgreSQL' : 'SQLite'})`); + } else { + // Fallback: create tables directly + const schema = this.isPostgres ? this.getPostgresFallbackSchema() : this.getSqliteFallbackSchema(); + await this.db.exec(schema); + logger.info(`CacheStore: Database schema initialized (fallback, ${this.isPostgres ? 'PostgreSQL' : 'SQLite'})`); + } - CREATE TABLE IF NOT EXISTS object_relations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source_id TEXT NOT NULL, - target_id TEXT NOT NULL, - attribute_name TEXT NOT NULL, - source_type TEXT NOT NULL, - target_type TEXT NOT NULL, - UNIQUE(source_id, target_id, attribute_name) - ); - - CREATE TABLE IF NOT EXISTS sync_metadata ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); - CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); - CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); - CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); - - CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); - CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); - CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); - CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); - CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); - `); - logger.info('CacheStore: Database schema initialized (fallback)'); + this.initialized = true; + logger.info('CacheStore: Database initialization complete'); + } catch (error) { + logger.error('CacheStore: Failed to initialize database schema', error); + // Don't throw - allow app to continue, but queries will fail gracefully + logger.warn('CacheStore: Continuing without database initialization - cache operations may fail'); } + } - this.initialized = true; + private getSqliteFallbackSchema(): string { + return ` + CREATE TABLE IF NOT EXISTS cached_objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type TEXT NOT NULL, + label TEXT NOT NULL, + data JSON NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS object_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_name) + ); + + CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); + CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); + CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); + CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); + + CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); + CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); + CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); + CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); + CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); + `; + } + + private getPostgresFallbackSchema(): string { + return ` + CREATE TABLE IF NOT EXISTS cached_objects ( + id TEXT PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + object_type TEXT NOT NULL, + label TEXT NOT NULL, + data JSONB NOT NULL, + jira_updated_at TEXT, + jira_created_at TEXT, + cached_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS object_relations ( + id SERIAL PRIMARY KEY, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + attribute_name TEXT NOT NULL, + source_type TEXT NOT NULL, + target_type TEXT NOT NULL, + UNIQUE(source_id, target_id, attribute_name) + ); + + CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type); + CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key); + CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at); + CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label); + CREATE INDEX IF NOT EXISTS idx_objects_data_gin ON cached_objects USING GIN (data); + + CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id); + CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id); + CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type); + CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type); + CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name); + `; } // ========================================================================== @@ -115,19 +191,26 @@ class CacheStore { /** * Get a single object by ID */ - getObject(typeName: CMDBObjectTypeName, id: string): T | null { - const stmt = this.db.prepare(` - SELECT data FROM cached_objects - WHERE id = ? AND object_type = ? - `); - const row = stmt.get(id, typeName) as { data: string } | undefined; - - if (!row) return null; - + async getObject(typeName: CMDBObjectTypeName, id: string): Promise { try { - return JSON.parse(row.data) as T; + await this.ensureInitialized(); + const row = await this.db.queryOne<{ data: string | object }>(` + SELECT data FROM cached_objects + WHERE id = ? AND object_type = ? + `, [id, typeName]); + + if (!row) return null; + + try { + // PostgreSQL returns JSONB as object, SQLite as string + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; + } catch (error) { + logger.error(`CacheStore: Failed to parse object ${id}`, error); + return null; + } } catch (error) { - logger.error(`CacheStore: Failed to parse object ${id}`, error); + logger.error(`CacheStore: Failed to get object ${id}`, error); return null; } } @@ -135,17 +218,18 @@ class CacheStore { /** * Get a single object by object key (e.g., "ICMT-123") */ - getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): T | null { - const stmt = this.db.prepare(` + async getObjectByKey(typeName: CMDBObjectTypeName, objectKey: string): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ data: string | object }>(` SELECT data FROM cached_objects WHERE object_key = ? AND object_type = ? - `); - const row = stmt.get(objectKey, typeName) as { data: string } | undefined; + `, [objectKey, typeName]); if (!row) return null; try { - return JSON.parse(row.data) as T; + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; } catch (error) { logger.error(`CacheStore: Failed to parse object ${objectKey}`, error); return null; @@ -155,68 +239,81 @@ class CacheStore { /** * Get all objects of a specific type */ - getObjects( + async getObjects( typeName: CMDBObjectTypeName, options?: QueryOptions - ): T[] { - const limit = options?.limit || 10000; - const offset = options?.offset || 0; - const orderBy = options?.orderBy || 'label'; - const orderDir = options?.orderDir || 'ASC'; + ): Promise { + try { + await this.ensureInitialized(); + const limit = options?.limit || 10000; + const offset = options?.offset || 0; + const orderBy = options?.orderBy || 'label'; + const orderDir = options?.orderDir || 'ASC'; - const stmt = this.db.prepare(` - SELECT data FROM cached_objects - WHERE object_type = ? - ORDER BY ${orderBy} ${orderDir} - LIMIT ? OFFSET ? - `); - - const rows = stmt.all(typeName, limit, offset) as { data: string }[]; - - return rows.map(row => { - try { - return JSON.parse(row.data) as T; - } catch { - return null; - } - }).filter((obj): obj is T => obj !== null); + // Sanitize orderBy to prevent SQL injection + const safeOrderBy = ['id', 'object_key', 'object_type', 'label', 'cached_at'].includes(orderBy) + ? orderBy + : 'label'; + const safeOrderDir = orderDir === 'DESC' ? 'DESC' : 'ASC'; + + const rows = await this.db.query<{ data: string | object }>(` + SELECT data FROM cached_objects + WHERE object_type = ? + ORDER BY ${safeOrderBy} ${safeOrderDir} + LIMIT ? OFFSET ? + `, [typeName, limit, offset]); + + return rows.map(row => { + try { + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; + } catch { + return null; + } + }).filter((obj): obj is T => obj !== null); + } catch (error) { + logger.error(`CacheStore: Failed to get objects for type ${typeName}`, error); + return []; + } } /** * Count objects of a specific type */ - countObjects(typeName: CMDBObjectTypeName): number { - const stmt = this.db.prepare(` + async countObjects(typeName: CMDBObjectTypeName): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ count: number }>(` SELECT COUNT(*) as count FROM cached_objects WHERE object_type = ? - `); - const row = stmt.get(typeName) as { count: number }; - return row.count; + `, [typeName]); + return row?.count || 0; } /** * Search objects by label (case-insensitive) */ - searchByLabel( + async searchByLabel( typeName: CMDBObjectTypeName, searchTerm: string, options?: QueryOptions - ): T[] { + ): Promise { + await this.ensureInitialized(); const limit = options?.limit || 100; const offset = options?.offset || 0; - const stmt = this.db.prepare(` + // Use database-agnostic case-insensitive search + const likeOperator = this.isPostgres ? 'ILIKE' : 'LIKE'; + const rows = await this.db.query<{ data: string | object }>(` SELECT data FROM cached_objects - WHERE object_type = ? AND label LIKE ? + WHERE object_type = ? AND label ${likeOperator} ? ORDER BY label ASC LIMIT ? OFFSET ? - `); - - const rows = stmt.all(typeName, `%${searchTerm}%`, limit, offset) as { data: string }[]; + `, [typeName, `%${searchTerm}%`, limit, offset]); return rows.map(row => { try { - return JSON.parse(row.data) as T; + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; } catch { return null; } @@ -226,23 +323,24 @@ class CacheStore { /** * Search across all object types */ - searchAllTypes(searchTerm: string, options?: QueryOptions): CMDBObject[] { + async searchAllTypes(searchTerm: string, options?: QueryOptions): Promise { + await this.ensureInitialized(); const limit = options?.limit || 100; const offset = options?.offset || 0; - const stmt = this.db.prepare(` + // Use database-agnostic case-insensitive search + const likeOperator = this.isPostgres ? 'ILIKE' : 'LIKE'; + const rows = await this.db.query<{ data: string | object }>(` SELECT data FROM cached_objects - WHERE label LIKE ? OR object_key LIKE ? + WHERE label ${likeOperator} ? OR object_key ${likeOperator} ? ORDER BY object_type, label ASC LIMIT ? OFFSET ? - `); - - const pattern = `%${searchTerm}%`; - const rows = stmt.all(pattern, pattern, limit, offset) as { data: string }[]; + `, [`%${searchTerm}%`, `%${searchTerm}%`, limit, offset]); return rows.map(row => { try { - return JSON.parse(row.data) as CMDBObject; + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as CMDBObject; } catch { return null; } @@ -252,8 +350,9 @@ class CacheStore { /** * Upsert a single object */ - upsertObject(typeName: CMDBObjectTypeName, object: T): void { - const stmt = this.db.prepare(` + async upsertObject(typeName: CMDBObjectTypeName, object: T): Promise { + await this.ensureInitialized(); + await this.db.execute(` INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET @@ -262,9 +361,7 @@ class CacheStore { data = excluded.data, jira_updated_at = excluded.jira_updated_at, cached_at = excluded.cached_at - `); - - stmt.run( + `, [ object.id, object.objectKey, typeName, @@ -273,30 +370,29 @@ class CacheStore { object._jiraUpdatedAt || null, object._jiraCreatedAt || null, new Date().toISOString() - ); + ]); } /** * Batch upsert objects (much faster for bulk operations) */ - batchUpsertObjects(typeName: CMDBObjectTypeName, objects: T[]): void { + async batchUpsertObjects(typeName: CMDBObjectTypeName, objects: T[]): Promise { + await this.ensureInitialized(); if (objects.length === 0) return; - const stmt = this.db.prepare(` - INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - object_key = excluded.object_key, - label = excluded.label, - data = excluded.data, - jira_updated_at = excluded.jira_updated_at, - cached_at = excluded.cached_at - `); - - const now = new Date().toISOString(); - const batchInsert = this.db.transaction((objs: T[]) => { - for (const obj of objs) { - stmt.run( + await this.db.transaction(async (db) => { + const now = new Date().toISOString(); + for (const obj of objects) { + await db.execute(` + INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + object_key = excluded.object_key, + label = excluded.label, + data = excluded.data, + jira_updated_at = excluded.jira_updated_at, + cached_at = excluded.cached_at + `, [ obj.id, obj.objectKey, typeName, @@ -305,61 +401,60 @@ class CacheStore { obj._jiraUpdatedAt || null, obj._jiraCreatedAt || null, now - ); + ]); } }); - batchInsert(objects); logger.debug(`CacheStore: Batch upserted ${objects.length} ${typeName} objects`); } /** * Delete an object by ID */ - deleteObject(typeName: CMDBObjectTypeName, id: string): boolean { - const stmt = this.db.prepare(` + async deleteObject(typeName: CMDBObjectTypeName, id: string): Promise { + await this.ensureInitialized(); + const changes = await this.db.execute(` DELETE FROM cached_objects WHERE id = ? AND object_type = ? - `); - const result = stmt.run(id, typeName); + `, [id, typeName]); // Also delete related relations - this.deleteRelationsForObject(id); + await this.deleteRelationsForObject(id); - return result.changes > 0; + return changes > 0; } /** * Clear all objects of a specific type */ - clearObjectType(typeName: CMDBObjectTypeName): number { + async clearObjectType(typeName: CMDBObjectTypeName): Promise { + await this.ensureInitialized(); // First get all IDs to delete relations - const idsStmt = this.db.prepare(` + const ids = await this.db.query<{ id: string }>(` SELECT id FROM cached_objects WHERE object_type = ? - `); - const ids = idsStmt.all(typeName) as { id: string }[]; + `, [typeName]); // Delete relations for (const { id } of ids) { - this.deleteRelationsForObject(id); + await this.deleteRelationsForObject(id); } // Delete objects - const stmt = this.db.prepare(` + const changes = await this.db.execute(` DELETE FROM cached_objects WHERE object_type = ? - `); - const result = stmt.run(typeName); + `, [typeName]); - logger.info(`CacheStore: Cleared ${result.changes} ${typeName} objects`); - return result.changes; + logger.info(`CacheStore: Cleared ${changes} ${typeName} objects`); + return changes; } /** * Clear entire cache */ - clearAll(): void { - this.db.exec('DELETE FROM cached_objects'); - this.db.exec('DELETE FROM object_relations'); + async clearAll(): Promise { + await this.ensureInitialized(); + await this.db.execute('DELETE FROM cached_objects'); + await this.db.execute('DELETE FROM object_relations'); logger.info('CacheStore: Cleared all cached data'); } @@ -370,62 +465,60 @@ class CacheStore { /** * Store a relation between two objects */ - upsertRelation( + async upsertRelation( sourceId: string, targetId: string, attributeName: string, sourceType: string, targetType: string - ): void { - const stmt = this.db.prepare(` + ): Promise { + await this.ensureInitialized(); + await this.db.execute(` INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) VALUES (?, ?, ?, ?, ?) ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET source_type = excluded.source_type, target_type = excluded.target_type - `); - - stmt.run(sourceId, targetId, attributeName, sourceType, targetType); + `, [sourceId, targetId, attributeName, sourceType, targetType]); } /** * Batch upsert relations */ - batchUpsertRelations(relations: Array<{ + async batchUpsertRelations(relations: Array<{ sourceId: string; targetId: string; attributeName: string; sourceType: string; targetType: string; - }>): void { + }>): Promise { + await this.ensureInitialized(); if (relations.length === 0) return; - const stmt = this.db.prepare(` - INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET - source_type = excluded.source_type, - target_type = excluded.target_type - `); - - const batchInsert = this.db.transaction((rels: typeof relations) => { - for (const rel of rels) { - stmt.run(rel.sourceId, rel.targetId, rel.attributeName, rel.sourceType, rel.targetType); + await this.db.transaction(async (db) => { + for (const rel of relations) { + await db.execute(` + INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET + source_type = excluded.source_type, + target_type = excluded.target_type + `, [rel.sourceId, rel.targetId, rel.attributeName, rel.sourceType, rel.targetType]); } }); - batchInsert(relations); logger.debug(`CacheStore: Batch upserted ${relations.length} relations`); } /** * Get related objects (outbound references from an object) */ - getRelatedObjects( + async getRelatedObjects( sourceId: string, targetTypeName: CMDBObjectTypeName, attributeName?: string - ): T[] { + ): Promise { + await this.ensureInitialized(); let query = ` SELECT co.data FROM cached_objects co JOIN object_relations rel ON co.id = rel.target_id @@ -438,12 +531,12 @@ class CacheStore { params.push(attributeName); } - const stmt = this.db.prepare(query); - const rows = stmt.all(...params) as { data: string }[]; + const rows = await this.db.query<{ data: string | object }>(query, params); return rows.map(row => { try { - return JSON.parse(row.data) as T; + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; } catch { return null; } @@ -453,11 +546,12 @@ class CacheStore { /** * Get objects that reference the given object (inbound references) */ - getReferencingObjects( + async getReferencingObjects( targetId: string, sourceTypeName: CMDBObjectTypeName, attributeName?: string - ): T[] { + ): Promise { + await this.ensureInitialized(); let query = ` SELECT co.data FROM cached_objects co JOIN object_relations rel ON co.id = rel.source_id @@ -470,12 +564,12 @@ class CacheStore { params.push(attributeName); } - const stmt = this.db.prepare(query); - const rows = stmt.all(...params) as { data: string }[]; + const rows = await this.db.query<{ data: string | object }>(query, params); return rows.map(row => { try { - return JSON.parse(row.data) as T; + const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; + return data as T; } catch { return null; } @@ -485,18 +579,19 @@ class CacheStore { /** * Delete all relations for an object */ - deleteRelationsForObject(objectId: string): void { - const stmt = this.db.prepare(` + async deleteRelationsForObject(objectId: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` DELETE FROM object_relations WHERE source_id = ? OR target_id = ? - `); - stmt.run(objectId, objectId); + `, [objectId, objectId]); } /** * Extract and store relations from an object based on its type schema */ - extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): void { + async extractAndStoreRelations(typeName: CMDBObjectTypeName, object: T): Promise { + await this.ensureInitialized(); const refAttributes = getReferenceAttributes(typeName); const relations: Array<{ sourceId: string; @@ -540,7 +635,7 @@ class CacheStore { } if (relations.length > 0) { - this.batchUpsertRelations(relations); + await this.batchUpsertRelations(relations); } } @@ -551,36 +646,36 @@ class CacheStore { /** * Get sync metadata value */ - getSyncMetadata(key: string): string | null { - const stmt = this.db.prepare(` + async getSyncMetadata(key: string): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ value: string }>(` SELECT value FROM sync_metadata WHERE key = ? - `); - const row = stmt.get(key) as { value: string } | undefined; + `, [key]); return row?.value || null; } /** * Set sync metadata value */ - setSyncMetadata(key: string, value: string): void { - const stmt = this.db.prepare(` + async setSyncMetadata(key: string, value: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` INSERT INTO sync_metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `); - stmt.run(key, value, new Date().toISOString()); + `, [key, value, new Date().toISOString()]); } /** * Delete sync metadata */ - deleteSyncMetadata(key: string): void { - const stmt = this.db.prepare(` + async deleteSyncMetadata(key: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` DELETE FROM sync_metadata WHERE key = ? - `); - stmt.run(key); + `, [key]); } // ========================================================================== @@ -590,14 +685,14 @@ class CacheStore { /** * Get cache statistics */ - getStats(): CacheStats { + async getStats(): Promise { + await this.ensureInitialized(); // Count by type - const typeCountStmt = this.db.prepare(` + const typeCounts = await this.db.query<{ object_type: string; count: number }>(` SELECT object_type, COUNT(*) as count FROM cached_objects GROUP BY object_type `); - const typeCounts = typeCountStmt.all() as { object_type: string; count: number }[]; const objectsByType: Record = {}; let totalObjects = 0; @@ -607,25 +702,22 @@ class CacheStore { } // Count relations - const relCountStmt = this.db.prepare(` + const relCountRow = await this.db.queryOne<{ count: number }>(` SELECT COUNT(*) as count FROM object_relations `); - const relCount = (relCountStmt.get() as { count: number }).count; + const relCount = relCountRow?.count || 0; // Get sync metadata - const lastFullSync = this.getSyncMetadata('lastFullSync'); - const lastIncrementalSync = this.getSyncMetadata('lastIncrementalSync'); + const lastFullSync = await this.getSyncMetadata('lastFullSync'); + const lastIncrementalSync = await this.getSyncMetadata('lastIncrementalSync'); // Check if cache is warm (has Application Components) const isWarm = (objectsByType['ApplicationComponent'] || 0) > 0; - // Get database file size + // Get database size let dbSizeBytes = 0; - try { - const stats = fs.statSync(CACHE_DB_PATH); - dbSizeBytes = stats.size; - } catch { - // Ignore + if (this.db.getSizeBytes) { + dbSizeBytes = await this.db.getSizeBytes(); } return { @@ -642,19 +734,19 @@ class CacheStore { /** * Check if cache is warm (has data) */ - isWarm(): boolean { - const count = this.countObjects('ApplicationComponent'); + async isWarm(): Promise { + await this.ensureInitialized(); + const count = await this.countObjects('ApplicationComponent'); return count > 0; } /** * Close database connection */ - close(): void { - this.db.close(); + async close(): Promise { + await this.db.close(); } } // Export singleton instance export const cacheStore = new CacheStore(); - diff --git a/backend/src/services/claude.ts b/backend/src/services/claude.ts index 58d96d1..6cbfc80 100644 --- a/backend/src/services/claude.ts +++ b/backend/src/services/claude.ts @@ -4,10 +4,9 @@ import { config } from '../config/env.js'; import { logger } from './logger.js'; import type { ApplicationDetails, AISuggestion, ZiraTaxonomy, ReferenceValue, ChatMessage, ChatConversation, ChatResponse } from '../types/index.js'; import { dataService } from './dataService.js'; -import { readFileSync, existsSync } from 'fs'; +import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import * as XLSX from 'xlsx'; import { randomUUID } from 'crypto'; // AI Provider type @@ -48,233 +47,19 @@ try { ziraTaxonomy = { version: '', source: '', lastUpdated: '', domains: [] }; } -// BIA Excel data cache -interface BIARecord { - applicationName: string; - biaValue: string; +// Find BIA value for application using the unified matching service +// This uses the same matching logic as the BIA Sync Dashboard for consistency +async function findBIAValue(applicationName: string, searchReference?: string | null): Promise { + // Use the unified matching service (imported at top of file) + const { findBIAMatch } = await import('./biaMatchingService.js'); + const matchResult = findBIAMatch(applicationName, searchReference || null); + return matchResult.biaValue || null; } -let biaDataCache: BIARecord[] | null = null; -let biaDataCacheTimestamp: number = 0; -const BIA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -// Load BIA data from Excel file -function loadBIAData(): BIARecord[] { - const now = Date.now(); - // Return cached data if still valid - if (biaDataCache && (now - biaDataCacheTimestamp) < BIA_CACHE_TTL) { - return biaDataCache; - } - - // Path to BIA.xlsx: from compiled location (dist/services/) go up 2 levels to backend/, then into data/ - const biaFilePath = join(__dirname, '../../data/BIA.xlsx'); - - if (!existsSync(biaFilePath)) { - logger.warn(`BIA.xlsx file not found at ${biaFilePath}, skipping BIA lookup`); - biaDataCache = []; - biaDataCacheTimestamp = now; - return []; - } - - logger.debug(`Loading BIA data from: ${biaFilePath}`); - - try { - const workbook = XLSX.readFile(biaFilePath); - const sheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[sheetName]; - const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][]; - - // Find the header row and determine column indices dynamically - let headerRowIndex = -1; - let applicationNameColumnIndex = -1; - let biaValueColumnIndex = -1; - - // First, find the header row by looking for "BIA - Informatiemiddel" and "BIA - Bruto risicoscore" - for (let i = 0; i < Math.min(10, data.length); i++) { - const row = data[i]; - if (!row || row.length < 3) continue; - - // Search for "BIA - Informatiemiddel" (application name column) - for (let col = 0; col < row.length; col++) { - const cellValue = String(row[col] || '').trim().toLowerCase(); - if (cellValue.includes('bia') && cellValue.includes('informatiemiddel')) { - applicationNameColumnIndex = col; - headerRowIndex = i; - break; - } - } - - // If we found the application name column, now find "BIA - Bruto risicoscore" - if (headerRowIndex !== -1 && applicationNameColumnIndex !== -1) { - for (let col = 0; col < row.length; col++) { - const cellValue = String(row[col] || '').trim().toLowerCase(); - if (cellValue.includes('bia') && cellValue.includes('bruto') && cellValue.includes('risicoscore')) { - biaValueColumnIndex = col; - break; - } - } - break; - } - } - - if (headerRowIndex === -1 || applicationNameColumnIndex === -1) { - logger.warn('Could not find "BIA - Informatiemiddel" column in BIA.xlsx'); - biaDataCache = []; - biaDataCacheTimestamp = now; - return []; - } - - if (biaValueColumnIndex === -1) { - logger.warn('Could not find "BIA - Bruto risicoscore" column in BIA.xlsx'); - biaDataCache = []; - biaDataCacheTimestamp = now; - return []; - } - - logger.info(`Found BIA columns: Application name at column ${applicationNameColumnIndex + 1} (${String.fromCharCode(65 + applicationNameColumnIndex)}), BIA value at column ${biaValueColumnIndex + 1} (${String.fromCharCode(65 + biaValueColumnIndex)})`); - - // Extract data starting from the row after the header - const records: BIARecord[] = []; - for (let i = headerRowIndex + 1; i < data.length; i++) { - const row = data[i]; - if (row && row.length > applicationNameColumnIndex) { - const applicationName = String(row[applicationNameColumnIndex] || '').trim(); - - if (!applicationName || applicationName.length === 0) { - continue; // Skip empty rows - } - - // Get BIA value from the dynamically found column - let biaValue = ''; - if (row.length > biaValueColumnIndex) { - biaValue = String(row[biaValueColumnIndex] || '').trim().toUpperCase(); - } else { - logger.debug(`Row ${i} does not have enough columns for BIA value (need column ${biaValueColumnIndex + 1})`); - } - - // Extract just the letter if the value contains more than just A-F (e.g., "A - Test/Archief") - if (biaValue && !/^[A-F]$/.test(biaValue)) { - const match = biaValue.match(/^([A-F])/); - if (match) { - biaValue = match[1]; - } - } - - // Only add record if we have both application name and BIA value - if (applicationName && /^[A-F]$/.test(biaValue)) { - records.push({ - applicationName: applicationName, - biaValue: biaValue, - }); - } - } - } - - logger.info(`Loaded ${records.length} BIA records from Excel file`); - biaDataCache = records; - biaDataCacheTimestamp = now; - return records; - } catch (error) { - logger.error('Failed to load BIA data from Excel', error); - biaDataCache = []; - biaDataCacheTimestamp = now; - return []; - } -} - -// Calculate Levenshtein distance for fuzzy matching -function levenshteinDistance(str1: string, str2: string): number { - const matrix: number[][] = []; - const len1 = str1.length; - const len2 = str2.length; - - if (len1 === 0) return len2; - if (len2 === 0) return len1; - - for (let i = 0; i <= len1; i++) { - matrix[i] = [i]; - } - - for (let j = 0; j <= len2; j++) { - matrix[0][j] = j; - } - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + cost // substitution - ); - } - } - - return matrix[len1][len2]; -} - -// Calculate similarity score (0-1, where 1 is identical) -function calculateSimilarity(str1: string, str2: string): number { - const maxLen = Math.max(str1.length, str2.length); - if (maxLen === 0) return 1; - const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase()); - return 1 - (distance / maxLen); -} - -// Find BIA value for application using exact match first, then fuzzy matching -function findBIAValue(applicationName: string): string | null { - const biaData = loadBIAData(); - if (biaData.length === 0) { - logger.debug(`No BIA data available for lookup of "${applicationName}"`); - return null; - } - - const normalizedAppName = applicationName.toLowerCase().trim(); - - // Step 1: Try exact match (case-insensitive) - for (const record of biaData) { - const normalizedRecordName = record.applicationName.toLowerCase().trim(); - if (normalizedAppName === normalizedRecordName) { - logger.info(`Found exact BIA match for "${applicationName}" -> "${record.applicationName}": ${record.biaValue}`); - return record.biaValue; - } - } - - // Step 2: Try partial match (one name contains the other) - for (const record of biaData) { - const normalizedRecordName = record.applicationName.toLowerCase().trim(); - if (normalizedAppName.includes(normalizedRecordName) || normalizedRecordName.includes(normalizedAppName)) { - logger.info(`Found partial BIA match for "${applicationName}" -> "${record.applicationName}": ${record.biaValue}`); - return record.biaValue; - } - } - - // Step 3: Try fuzzy matching with lower threshold - let bestMatch: { value: string; similarity: number; recordName: string } | null = null; - const threshold = 0.6; // Lowered threshold to 60% for better matching - - for (const record of biaData) { - const normalizedRecordName = record.applicationName.toLowerCase().trim(); - const similarity = calculateSimilarity(normalizedAppName, normalizedRecordName); - - if (similarity >= threshold) { - if (!bestMatch || similarity > bestMatch.similarity) { - bestMatch = { - value: record.biaValue, - similarity: similarity, - recordName: record.applicationName, - }; - } - } - } - - if (bestMatch) { - logger.info(`Found fuzzy BIA match for "${applicationName}" -> "${bestMatch.recordName}": ${bestMatch.value} (similarity: ${(bestMatch.similarity * 100).toFixed(1)}%)`); - return bestMatch.value; - } - - logger.debug(`No BIA match found for "${applicationName}" (checked ${biaData.length} records)`); - return null; +async function findBIAMatchResult(applicationName: string, searchReference?: string | null) { + // Use the unified matching service (imported at top of file) + const { findBIAMatch } = await import('./biaMatchingService.js'); + return findBIAMatch(applicationName, searchReference || null); } // Get Governance Models with additional attributes (Remarks, Application) @@ -984,8 +769,11 @@ class AIService { const parsed = JSON.parse(jsonText); - // Check for BIA value in Excel file using fuzzy matching - const excelBIAValue = findBIAValue(application.name); + // Check for BIA value in Excel file using unified matching service + // This uses the same matching logic as the BIA Sync Dashboard + const biaMatchResult = await findBIAMatchResult(application.name, application.searchReference); + const excelBIAValue = biaMatchResult.biaValue; + const excelApplicationName = biaMatchResult.excelApplicationName; // Parse BIA classification from AI response let biaClassification = parsed.beheerclassificatie?.bia_classificatie ? { @@ -996,11 +784,40 @@ class AIService { // Override BIA classification if found in Excel file - Excel value ALWAYS takes precedence if (excelBIAValue) { const originalAIValue = biaClassification?.value || 'geen'; + const matchType = biaMatchResult.matchType; + const matchConfidence = biaMatchResult.matchConfidence; + + // Build match info message with type and confidence + let matchInfo = ''; + if (excelApplicationName) { + if (matchType === 'exact' || matchType === 'search_reference') { + matchInfo = `exacte match met "${excelApplicationName}" uit Excel`; + } else if (matchType === 'fuzzy' && matchConfidence !== undefined) { + const confidencePercent = Math.round(matchConfidence * 100); + matchInfo = `fuzzy match met "${excelApplicationName}" uit Excel (${confidencePercent}% overeenkomst)`; + } else { + matchInfo = `match met "${excelApplicationName}" uit Excel`; + } + } else { + matchInfo = `match gevonden voor "${application.name}"`; + } + + // Add warning for fuzzy matches with low confidence + let warning = ''; + if (matchType === 'fuzzy' && matchConfidence !== undefined) { + const confidencePercent = Math.round(matchConfidence * 100); + if (confidencePercent < 75) { + warning = ` ⚠️ Let op: Dit is een fuzzy match met ${confidencePercent}% overeenkomst. Controleer of "${excelApplicationName}" inderdaad overeenkomt met "${application.name}".`; + } + } + biaClassification = { value: excelBIAValue, - reasoning: `Gevonden in BIA.xlsx export (match met "${application.name}"). Originele AI suggestie: ${originalAIValue}. Excel waarde heeft voorrang.`, + reasoning: `Gevonden in BIA.xlsx export (${matchInfo}). Originele AI suggestie: ${originalAIValue}. Excel waarde heeft voorrang.${warning}`, }; - logger.info(`✓ OVERRIDING BIA classification for "${application.name}": Excel value "${excelBIAValue}" (AI suggested: "${originalAIValue}")`); + + const matchTypeLabel = matchType === 'exact' ? 'exact' : matchType === 'search_reference' ? 'search reference' : matchType === 'fuzzy' ? `fuzzy (${Math.round((matchConfidence || 0) * 100)}%)` : 'unknown'; + logger.info(`✓ OVERRIDING BIA classification for "${application.name}": Excel value "${excelBIAValue}" from "${excelApplicationName || 'unknown'}" (${matchTypeLabel} match, AI suggested: "${originalAIValue}")`); } else { logger.debug(`No Excel BIA value found for "${application.name}", using AI suggestion: ${biaClassification?.value || 'geen'}`); } diff --git a/backend/src/services/cmdbService.ts b/backend/src/services/cmdbService.ts index fc3e764..3c7dce9 100644 --- a/backend/src/services/cmdbService.ts +++ b/backend/src/services/cmdbService.ts @@ -60,7 +60,7 @@ class CMDBService { } // Try cache first - const cached = cacheStore.getObject(typeName, id); + const cached = await cacheStore.getObject(typeName, id); if (cached) { return cached; } @@ -89,14 +89,14 @@ class CMDBService { const parsed = jiraAssetsClient.parseObject(result.objects[0]); if (parsed) { - cacheStore.upsertObject(typeName, parsed); - cacheStore.extractAndStoreRelations(typeName, parsed); + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); } return parsed; } // Try cache first - const cached = cacheStore.getObjectByKey(typeName, objectKey); + const cached = await cacheStore.getObjectByKey(typeName, objectKey); if (cached) { return cached; } @@ -119,14 +119,14 @@ class CMDBService { const parsed = jiraAssetsClient.parseObject(jiraObj); if (parsed) { - cacheStore.upsertObject(typeName, parsed); - cacheStore.extractAndStoreRelations(typeName, parsed); + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); } return parsed; } catch (error) { // If object was deleted from Jira, remove it from our cache if (error instanceof JiraObjectNotFoundError) { - const deleted = cacheStore.deleteObject(typeName, id); + const deleted = await cacheStore.deleteObject(typeName, id); if (deleted) { logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`); } @@ -145,13 +145,13 @@ class CMDBService { options?: SearchOptions ): Promise { if (options?.searchTerm) { - return cacheStore.searchByLabel(typeName, options.searchTerm, { + return await cacheStore.searchByLabel(typeName, options.searchTerm, { limit: options.limit, offset: options.offset, }); } - return cacheStore.getObjects(typeName, { + return await cacheStore.getObjects(typeName, { limit: options?.limit, offset: options?.offset, }); @@ -160,15 +160,15 @@ class CMDBService { /** * Count objects of a type in cache */ - countObjects(typeName: CMDBObjectTypeName): number { - return cacheStore.countObjects(typeName); + async countObjects(typeName: CMDBObjectTypeName): Promise { + return await cacheStore.countObjects(typeName); } /** * Search across all object types */ async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise { - return cacheStore.searchAllTypes(searchTerm, { limit: options?.limit }); + return await cacheStore.searchAllTypes(searchTerm, { limit: options?.limit }); } /** @@ -179,7 +179,7 @@ class CMDBService { attributeName: string, targetTypeName: CMDBObjectTypeName ): Promise { - return cacheStore.getRelatedObjects(sourceId, targetTypeName, attributeName); + return await cacheStore.getRelatedObjects(sourceId, targetTypeName, attributeName); } /** @@ -190,7 +190,7 @@ class CMDBService { sourceTypeName: CMDBObjectTypeName, attributeName?: string ): Promise { - return cacheStore.getReferencingObjects(targetId, sourceTypeName, attributeName); + return await cacheStore.getReferencingObjects(targetId, sourceTypeName, attributeName); } // ========================================================================== @@ -396,29 +396,29 @@ class CMDBService { /** * Get cache statistics */ - getCacheStats(): CacheStats { - return cacheStore.getStats(); + async getCacheStats(): Promise { + return await cacheStore.getStats(); } /** * Check if cache has data */ - isCacheWarm(): boolean { - return cacheStore.isWarm(); + async isCacheWarm(): Promise { + return await cacheStore.isWarm(); } /** * Clear cache for a specific type */ - clearCacheForType(typeName: CMDBObjectTypeName): void { - cacheStore.clearObjectType(typeName); + async clearCacheForType(typeName: CMDBObjectTypeName): Promise { + await cacheStore.clearObjectType(typeName); } /** * Clear entire cache */ - clearCache(): void { - cacheStore.clearAll(); + async clearCache(): Promise { + await cacheStore.clearAll(); } // ========================================================================== @@ -429,7 +429,11 @@ class CMDBService { * Set user token for current request */ setUserToken(token: string | null): void { - jiraAssetsClient.setRequestToken(token); + if (token) { + jiraAssetsClient.setRequestToken(token); + } else { + jiraAssetsClient.clearRequestToken(); + } } /** diff --git a/backend/src/services/dataCompletenessConfig.ts b/backend/src/services/dataCompletenessConfig.ts new file mode 100644 index 0000000..1ee54f7 --- /dev/null +++ b/backend/src/services/dataCompletenessConfig.ts @@ -0,0 +1,190 @@ +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { logger } from './logger.js'; +import type { DataCompletenessConfig, CompletenessFieldConfig } from '../types/index.js'; + +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Path to the configuration file +const CONFIG_FILE_PATH = join(__dirname, '../../data/data-completeness-config.json'); + +// Cache for loaded configuration +let cachedConfig: DataCompletenessConfig | null = null; + +/** + * Get the default completeness configuration + */ +function getDefaultConfig(): DataCompletenessConfig { + return { + metadata: { + version: '2.0.0', + description: 'Configuration for Data Completeness Score fields', + lastUpdated: new Date().toISOString(), + }, + categories: [ + { + id: 'general', + name: 'General', + description: 'General application information fields', + fields: [ + { id: 'organisation', name: 'Organisation', fieldPath: 'organisation', enabled: true }, + { id: 'applicationFunctions', name: 'ApplicationFunction', fieldPath: 'applicationFunctions', enabled: true }, + { id: 'status', name: 'Status', fieldPath: 'status', enabled: true }, + { id: 'businessImpactAnalyse', name: 'Business Impact Analyse', fieldPath: 'businessImpactAnalyse', enabled: true }, + { id: 'hostingType', name: 'Application Component Hosting Type', fieldPath: 'hostingType', enabled: true }, + { id: 'supplierProduct', name: 'Supplier Product', fieldPath: 'supplierProduct', enabled: true }, + { id: 'businessOwner', name: 'Business Owner', fieldPath: 'businessOwner', enabled: true }, + { id: 'systemOwner', name: 'System Owner', fieldPath: 'systemOwner', enabled: true }, + { id: 'functionalApplicationManagement', name: 'Functional Application Management', fieldPath: 'functionalApplicationManagement', enabled: true }, + { id: 'technicalApplicationManagement', name: 'Technical Application Management', fieldPath: 'technicalApplicationManagement', enabled: true }, + ], + }, + { + id: 'applicationManagement', + name: 'Application Management', + description: 'Application management classification fields', + fields: [ + { id: 'governanceModel', name: 'ICT Governance Model', fieldPath: 'governanceModel', enabled: true }, + { id: 'applicationType', name: 'Application Management - Application Type', fieldPath: 'applicationType', enabled: true }, + { id: 'applicationManagementHosting', name: 'Application Management - Hosting', fieldPath: 'applicationManagementHosting', enabled: true }, + { id: 'applicationManagementTAM', name: 'Application Management - TAM', fieldPath: 'applicationManagementTAM', enabled: true }, + { id: 'dynamicsFactor', name: 'Application Management - Dynamics Factor', fieldPath: 'dynamicsFactor', enabled: true }, + { id: 'complexityFactor', name: 'Application Management - Complexity Factor', fieldPath: 'complexityFactor', enabled: true }, + { id: 'numberOfUsers', name: 'Application Management - Number of Users', fieldPath: 'numberOfUsers', enabled: true }, + ], + }, + ], + }; +} + +/** + * Get the data completeness configuration + * Uses cache if available, otherwise loads from file or returns default + */ +export function getDataCompletenessConfig(): DataCompletenessConfig { + // Return cached config if available + if (cachedConfig) { + return cachedConfig; + } + + // Try to load from file + if (existsSync(CONFIG_FILE_PATH)) { + try { + const fileContent = readFileSync(CONFIG_FILE_PATH, 'utf-8'); + cachedConfig = JSON.parse(fileContent) as DataCompletenessConfig; + logger.info('Loaded data completeness configuration from file'); + return cachedConfig; + } catch (error) { + logger.warn('Failed to load data completeness configuration from file, using default', error); + } + } + + // Return default config + cachedConfig = getDefaultConfig(); + return cachedConfig; +} + +/** + * Clear the cached configuration (call after updating the config file) + */ +export function clearDataCompletenessConfigCache(): void { + cachedConfig = null; +} + +/** + * Get enabled fields for a category by ID + */ +export function getEnabledFieldsForCategory(categoryId: string): CompletenessFieldConfig[] { + const config = getDataCompletenessConfig(); + const category = config.categories.find(c => c.id === categoryId); + if (!category) return []; + return category.fields.filter(field => field.enabled); +} + +/** + * Get all enabled fields across all categories + */ +export function getAllEnabledFields(): { field: CompletenessFieldConfig; categoryId: string; categoryName: string }[] { + const config = getDataCompletenessConfig(); + const result: { field: CompletenessFieldConfig; categoryId: string; categoryName: string }[] = []; + + config.categories.forEach(category => { + category.fields + .filter(f => f.enabled) + .forEach(f => result.push({ field: f, categoryId: category.id, categoryName: category.name })); + }); + + return result; +} + +/** + * Helper function to get value from ApplicationDetails using field path + */ +function getFieldValue(app: any, fieldPath: string): any { + const paths = fieldPath.split('.'); + let value: any = app; + for (const path of paths) { + if (value === null || value === undefined) return null; + value = (value as any)[path]; + } + return value; +} + +/** + * Helper function to check if a field is filled based on fieldPath + */ +function isFieldFilled(app: any, fieldPath: string): boolean { + const value = getFieldValue(app, fieldPath); + + // Special handling for arrays (e.g., applicationFunctions) + if (Array.isArray(value)) { + return value.length > 0; + } + + // For reference values (objects with objectId) + if (value && typeof value === 'object' && 'objectId' in value) { + return !!value.objectId; + } + + // For primitive values + return value !== null && value !== undefined && value !== ''; +} + +/** + * Calculate data completeness score for a single application + * Returns the overall percentage (0-100) + */ +export function calculateApplicationCompleteness(app: any): number { + const config = getDataCompletenessConfig(); + + // Build field maps from config + const categoryFieldMap = new Map(); + + config.categories.forEach(category => { + const enabledFields = category.fields.filter(f => f.enabled); + categoryFieldMap.set(category.id, enabledFields.map(f => ({ name: f.name, fieldPath: f.fieldPath }))); + }); + + // Calculate total enabled fields across all categories + const TOTAL_FIELDS = Array.from(categoryFieldMap.values()).reduce((sum, fields) => sum + fields.length, 0); + + if (TOTAL_FIELDS === 0) { + return 0; + } + + // Count filled fields + let totalFilled = 0; + config.categories.forEach(category => { + const categoryFields = categoryFieldMap.get(category.id) || []; + categoryFields.forEach(({ fieldPath }) => { + if (isFieldFilled(app, fieldPath)) { + totalFilled++; + } + }); + }); + + return (totalFilled / TOTAL_FIELDS) * 100; +} diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts index 302ac62..9c5833e 100644 --- a/backend/src/services/dataService.ts +++ b/backend/src/services/dataService.ts @@ -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 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'); + + // 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 = { '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 = 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 { + return await cacheStore.getStats(); }, /** * Check if cache is warm */ - isCacheWarm(): boolean { - return cacheStore.isWarm(); + async isCacheWarm(): Promise { + 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'); + + // 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(); + 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, + }, + }; + }, }; diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts index 28b94b2..7440916 100644 --- a/backend/src/services/database.ts +++ b/backend/src/services/database.ts @@ -1,25 +1,63 @@ -import Database from 'better-sqlite3'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { logger } from './logger.js'; import type { ClassificationResult } from '../types/index.js'; +import { createClassificationsDatabaseAdapter } from './database/factory.js'; +import type { DatabaseAdapter } from './database/interface.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const DB_PATH = join(__dirname, '../../data/classifications.db'); - class DatabaseService { - private db: Database.Database; + private db: DatabaseAdapter; + private initialized: boolean = false; + private initializationPromise: Promise | null = null; constructor() { - this.db = new Database(DB_PATH); - this.initialize(); + this.db = createClassificationsDatabaseAdapter(); + // Start initialization but don't wait for it + this.initializationPromise = this.initialize(); } - private initialize(): void { + /** + * Ensure database is initialized before executing queries + */ + private async ensureInitialized(): Promise { + if (this.initialized) return; + if (this.initializationPromise) { + await this.initializationPromise; + return; + } + // If for some reason initialization wasn't started, start it now + this.initializationPromise = this.initialize(); + await this.initializationPromise; + } + + private async initialize(): Promise { + if (this.initialized) return; // Create tables if they don't exist - this.db.exec(` + const isPostgres = process.env.DATABASE_TYPE === 'postgres' || process.env.DATABASE_TYPE === 'postgresql'; + + const schema = isPostgres ? ` + CREATE TABLE IF NOT EXISTS classification_history ( + id SERIAL PRIMARY KEY, + application_id TEXT NOT NULL, + application_name TEXT NOT NULL, + changes TEXT NOT NULL, + source TEXT NOT NULL, + timestamp TEXT NOT NULL, + user_id TEXT + ); + + CREATE TABLE IF NOT EXISTS session_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_classification_app_id ON classification_history(application_id); + CREATE INDEX IF NOT EXISTS idx_classification_timestamp ON classification_history(timestamp); + ` : ` CREATE TABLE IF NOT EXISTS classification_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, application_id TEXT NOT NULL, @@ -38,35 +76,35 @@ class DatabaseService { CREATE INDEX IF NOT EXISTS idx_classification_app_id ON classification_history(application_id); CREATE INDEX IF NOT EXISTS idx_classification_timestamp ON classification_history(timestamp); - `); + `; + await this.db.exec(schema); + this.initialized = true; logger.info('Database initialized'); } - saveClassificationResult(result: ClassificationResult): void { - const stmt = this.db.prepare(` + async saveClassificationResult(result: ClassificationResult): Promise { + await this.ensureInitialized(); + await this.db.execute(` INSERT INTO classification_history (application_id, application_name, changes, source, timestamp, user_id) VALUES (?, ?, ?, ?, ?, ?) - `); - - stmt.run( + `, [ result.applicationId, result.applicationName, JSON.stringify(result.changes), result.source, result.timestamp.toISOString(), result.userId || null - ); + ]); } - getClassificationHistory(limit: number = 50): ClassificationResult[] { - const stmt = this.db.prepare(` + async getClassificationHistory(limit: number = 50): Promise { + await this.ensureInitialized(); + const rows = await this.db.query(` SELECT * FROM classification_history ORDER BY timestamp DESC LIMIT ? - `); - - const rows = stmt.all(limit) as any[]; + `, [limit]); return rows.map((row) => ({ applicationId: row.application_id, @@ -78,14 +116,13 @@ class DatabaseService { })); } - getClassificationsByApplicationId(applicationId: string): ClassificationResult[] { - const stmt = this.db.prepare(` + async getClassificationsByApplicationId(applicationId: string): Promise { + await this.ensureInitialized(); + const rows = await this.db.query(` SELECT * FROM classification_history WHERE application_id = ? ORDER BY timestamp DESC - `); - - const rows = stmt.all(applicationId) as any[]; + `, [applicationId]); return rows.map((row) => ({ applicationId: row.application_id, @@ -97,47 +134,48 @@ class DatabaseService { })); } - saveSessionState(key: string, value: any): void { - const stmt = this.db.prepare(` + async saveSessionState(key: string, value: any): Promise { + await this.ensureInitialized(); + const now = new Date().toISOString(); + const valueStr = JSON.stringify(value); + + await this.db.execute(` INSERT INTO session_state (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ? - `); - - const now = new Date().toISOString(); - const valueStr = JSON.stringify(value); - stmt.run(key, valueStr, now, valueStr, now); + `, [key, valueStr, now, valueStr, now]); } - getSessionState(key: string): T | null { - const stmt = this.db.prepare(` + async getSessionState(key: string): Promise { + await this.ensureInitialized(); + const row = await this.db.queryOne<{ value: string }>(` SELECT value FROM session_state WHERE key = ? - `); - - const row = stmt.get(key) as { value: string } | undefined; + `, [key]); + if (row) { return JSON.parse(row.value) as T; } return null; } - clearSessionState(key: string): void { - const stmt = this.db.prepare(` + async clearSessionState(key: string): Promise { + await this.ensureInitialized(); + await this.db.execute(` DELETE FROM session_state WHERE key = ? - `); - stmt.run(key); + `, [key]); } - getStats(): { totalClassifications: number; bySource: Record } { - const totalStmt = this.db.prepare(` + async getStats(): Promise<{ totalClassifications: number; bySource: Record }> { + await this.ensureInitialized(); + const totalRow = await this.db.queryOne<{ count: number }>(` SELECT COUNT(*) as count FROM classification_history `); - const total = (totalStmt.get() as { count: number }).count; + const total = totalRow?.count || 0; - const bySourceStmt = this.db.prepare(` + const bySourceRows = await this.db.query<{ source: string; count: number }>(` SELECT source, COUNT(*) as count FROM classification_history GROUP BY source `); - const bySourceRows = bySourceStmt.all() as { source: string; count: number }[]; + const bySource: Record = {}; bySourceRows.forEach((row) => { bySource[row.source] = row.count; @@ -146,8 +184,8 @@ class DatabaseService { return { totalClassifications: total, bySource }; } - close(): void { - this.db.close(); + async close(): Promise { + await this.db.close(); } } diff --git a/backend/src/services/database/factory.ts b/backend/src/services/database/factory.ts new file mode 100644 index 0000000..44eb5c1 --- /dev/null +++ b/backend/src/services/database/factory.ts @@ -0,0 +1,79 @@ +/** + * Database Factory + * + * Creates the appropriate database adapter based on environment configuration. + */ + +import { logger } from '../logger.js'; +import { PostgresAdapter } from './postgresAdapter.js'; +import { SqliteAdapter } from './sqliteAdapter.js'; +import type { DatabaseAdapter } from './interface.js'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Create a database adapter based on environment variables + */ +export function createDatabaseAdapter(dbType?: string, dbPath?: string): DatabaseAdapter { + const type = dbType || process.env.DATABASE_TYPE || 'sqlite'; + const databaseUrl = process.env.DATABASE_URL; + + if (type === 'postgres' || type === 'postgresql') { + if (!databaseUrl) { + // Try to construct from individual components + const host = process.env.DATABASE_HOST || 'localhost'; + const port = process.env.DATABASE_PORT || '5432'; + const name = process.env.DATABASE_NAME || 'cmdb'; + const user = process.env.DATABASE_USER || 'cmdb'; + const password = process.env.DATABASE_PASSWORD || ''; + const ssl = process.env.DATABASE_SSL === 'true' ? '?sslmode=require' : ''; + + const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`; + logger.info('Creating PostgreSQL adapter with constructed connection string'); + return new PostgresAdapter(constructedUrl); + } + + logger.info('Creating PostgreSQL adapter'); + return new PostgresAdapter(databaseUrl); + } + + // Default to SQLite + const defaultPath = dbPath || join(__dirname, '../../data/cmdb-cache.db'); + logger.info(`Creating SQLite adapter with path: ${defaultPath}`); + return new SqliteAdapter(defaultPath); +} + +/** + * Create a database adapter for the classifications database + */ +export function createClassificationsDatabaseAdapter(): DatabaseAdapter { + const type = process.env.DATABASE_TYPE || 'sqlite'; + const databaseUrl = process.env.CLASSIFICATIONS_DATABASE_URL || process.env.DATABASE_URL; + + if (type === 'postgres' || type === 'postgresql') { + if (!databaseUrl) { + // Try to construct from individual components + const host = process.env.DATABASE_HOST || 'localhost'; + const port = process.env.DATABASE_PORT || '5432'; + const name = process.env.CLASSIFICATIONS_DATABASE_NAME || process.env.DATABASE_NAME || 'cmdb'; + const user = process.env.DATABASE_USER || 'cmdb'; + const password = process.env.DATABASE_PASSWORD || ''; + const ssl = process.env.DATABASE_SSL === 'true' ? '?sslmode=require' : ''; + + const constructedUrl = `postgresql://${user}:${password}@${host}:${port}/${name}${ssl}`; + logger.info('Creating PostgreSQL adapter for classifications with constructed connection string'); + return new PostgresAdapter(constructedUrl); + } + + logger.info('Creating PostgreSQL adapter for classifications'); + return new PostgresAdapter(databaseUrl); + } + + // Default to SQLite + const defaultPath = join(__dirname, '../../data/classifications.db'); + logger.info(`Creating SQLite adapter for classifications with path: ${defaultPath}`); + return new SqliteAdapter(defaultPath); +} diff --git a/backend/src/services/database/interface.ts b/backend/src/services/database/interface.ts new file mode 100644 index 0000000..41022c6 --- /dev/null +++ b/backend/src/services/database/interface.ts @@ -0,0 +1,43 @@ +/** + * Database Adapter Interface + * + * Provides a unified interface for database operations across different database engines. + * This allows switching between SQLite (development) and PostgreSQL (production) seamlessly. + */ + +export interface DatabaseAdapter { + /** + * Execute a query and return results + */ + query(sql: string, params?: any[]): Promise; + + /** + * Execute a query and return a single row + */ + queryOne(sql: string, params?: any[]): Promise; + + /** + * Execute a statement (INSERT, UPDATE, DELETE) and return affected rows + */ + execute(sql: string, params?: any[]): Promise; + + /** + * Execute multiple statements in a transaction + */ + transaction(callback: (db: DatabaseAdapter) => Promise): Promise; + + /** + * Execute raw SQL (for schema initialization, etc.) + */ + exec(sql: string): Promise; + + /** + * Close the database connection + */ + close(): Promise; + + /** + * Get database size in bytes (if applicable) + */ + getSizeBytes?(): Promise; +} diff --git a/backend/src/services/database/postgresAdapter.ts b/backend/src/services/database/postgresAdapter.ts new file mode 100644 index 0000000..688d56d --- /dev/null +++ b/backend/src/services/database/postgresAdapter.ts @@ -0,0 +1,149 @@ +/** + * PostgreSQL Database Adapter + * + * Implements DatabaseAdapter for PostgreSQL using the 'pg' library. + */ + +import { Pool, PoolClient } from 'pg'; +import { logger } from '../logger.js'; +import type { DatabaseAdapter } from './interface.js'; + +export class PostgresAdapter implements DatabaseAdapter { + private pool: Pool; + private connectionString: string; + + constructor(connectionString: string) { + this.connectionString = connectionString; + this.pool = new Pool({ + connectionString, + max: 20, // Maximum number of clients in the pool + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 10000, // Increased timeout for initial connection + }); + + // Handle pool errors + this.pool.on('error', (err) => { + logger.error('PostgreSQL pool error:', err); + }); + + // Test connection on creation + this.pool.query('SELECT 1').catch((err) => { + logger.error('PostgreSQL: Failed to connect to database', err); + logger.error('Connection string:', connectionString.replace(/:[^:@]+@/, ':****@')); + }); + } + + async query(sql: string, params?: any[]): Promise { + try { + // Convert SQLite-style ? placeholders to PostgreSQL $1, $2, etc. + const convertedSql = this.convertPlaceholders(sql); + const result = await this.pool.query(convertedSql, params); + return result.rows as T[]; + } catch (error) { + logger.error('PostgreSQL query error:', { sql, params, error }); + throw error; + } + } + + async queryOne(sql: string, params?: any[]): Promise { + const rows = await this.query(sql, params); + return rows.length > 0 ? rows[0] : null; + } + + async execute(sql: string, params?: any[]): Promise { + try { + const convertedSql = this.convertPlaceholders(sql); + const result = await this.pool.query(convertedSql, params); + return result.rowCount || 0; + } catch (error) { + logger.error('PostgreSQL execute error:', { sql, params, error }); + throw error; + } + } + + async transaction(callback: (db: DatabaseAdapter) => Promise): Promise { + const client: PoolClient = await this.pool.connect(); + + try { + await client.query('BEGIN'); + + // Create a transaction-scoped adapter + const transactionAdapter: DatabaseAdapter = { + query: async (sql: string, params?: any[]) => { + const convertedSql = this.convertPlaceholders(sql); + const result = await client.query(convertedSql, params); + return result.rows; + }, + queryOne: async (sql: string, params?: any[]) => { + const convertedSql = this.convertPlaceholders(sql); + const result = await client.query(convertedSql, params); + return result.rows.length > 0 ? result.rows[0] : null; + }, + execute: async (sql: string, params?: any[]) => { + const convertedSql = this.convertPlaceholders(sql); + const result = await client.query(convertedSql, params); + return result.rowCount || 0; + }, + transaction: async (cb) => { + // Nested transactions not supported - just execute in same transaction + return cb(transactionAdapter); + }, + exec: async (sql: string) => { + await client.query(sql); + }, + close: async () => { + // Don't close client in nested transaction + }, + }; + + const result = await callback(transactionAdapter); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + logger.error('PostgreSQL transaction error:', error); + throw error; + } finally { + client.release(); + } + } + + async exec(sql: string): Promise { + try { + // Split multiple statements and execute them + const statements = sql.split(';').filter(s => s.trim().length > 0); + for (const statement of statements) { + if (statement.trim()) { + await this.pool.query(statement.trim()); + } + } + } catch (error) { + logger.error('PostgreSQL exec error:', { sql, error }); + throw error; + } + } + + async close(): Promise { + await this.pool.end(); + } + + async getSizeBytes(): Promise { + try { + const result = await this.query<{ size: number }>(` + SELECT pg_database_size(current_database()) as size + `); + return result[0]?.size || 0; + } catch (error) { + logger.error('PostgreSQL getSizeBytes error:', error); + return 0; + } + } + + /** + * Convert SQLite-style ? placeholders to PostgreSQL $1, $2, etc. + */ + private convertPlaceholders(sql: string): string { + let paramIndex = 1; + return sql.replace(/\?/g, () => `$${paramIndex++}`); + } +} diff --git a/backend/src/services/database/sqliteAdapter.ts b/backend/src/services/database/sqliteAdapter.ts new file mode 100644 index 0000000..d80d44b --- /dev/null +++ b/backend/src/services/database/sqliteAdapter.ts @@ -0,0 +1,132 @@ +/** + * SQLite Database Adapter + * + * Implements DatabaseAdapter for SQLite using 'better-sqlite3'. + * Maintains backward compatibility with existing SQLite code. + */ + +import Database from 'better-sqlite3'; +import { logger } from '../logger.js'; +import type { DatabaseAdapter } from './interface.js'; +import * as fs from 'fs'; + +export class SqliteAdapter implements DatabaseAdapter { + private db: Database.Database; + private dbPath: string; + + constructor(dbPath: string) { + this.dbPath = dbPath; + + // Ensure directory exists + const dir = require('path').dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.db = new Database(dbPath); + + // Enable foreign keys and WAL mode for better concurrency + this.db.pragma('foreign_keys = ON'); + this.db.pragma('journal_mode = WAL'); + } + + async query(sql: string, params?: any[]): Promise { + try { + const stmt = this.db.prepare(sql); + const rows = stmt.all(...(params || [])) as T[]; + return rows; + } catch (error) { + logger.error('SQLite query error:', { sql, params, error }); + throw error; + } + } + + async queryOne(sql: string, params?: any[]): Promise { + try { + const stmt = this.db.prepare(sql); + const row = stmt.get(...(params || [])) as T | undefined; + return row || null; + } catch (error) { + logger.error('SQLite queryOne error:', { sql, params, error }); + throw error; + } + } + + async execute(sql: string, params?: any[]): Promise { + try { + const stmt = this.db.prepare(sql); + const result = stmt.run(...(params || [])); + return result.changes || 0; + } catch (error) { + logger.error('SQLite execute error:', { sql, params, error }); + throw error; + } + } + + async transaction(callback: (db: DatabaseAdapter) => Promise): Promise { + // SQLite transactions using better-sqlite3 + const transactionFn = this.db.transaction((cb: (db: DatabaseAdapter) => Promise) => { + // Create a transaction-scoped adapter + const transactionAdapter: DatabaseAdapter = { + query: async (sql: string, params?: any[]) => { + const stmt = this.db.prepare(sql); + return stmt.all(...(params || [])) as any[]; + }, + queryOne: async (sql: string, params?: any[]): Promise => { + const stmt = this.db.prepare(sql); + const row = stmt.get(...(params || [])) as T | undefined; + return row || null; + }, + execute: async (sql: string, params?: any[]) => { + const stmt = this.db.prepare(sql); + const result = stmt.run(...(params || [])); + return result.changes || 0; + }, + transaction: async (cb) => { + // Nested transactions - just execute in same transaction + return cb(transactionAdapter); + }, + exec: async (sql: string) => { + this.db.exec(sql); + }, + close: async () => { + // Don't close in nested transaction + }, + }; + return cb(transactionAdapter); + }); + + try { + return await Promise.resolve(transactionFn(callback)); + } catch (error) { + logger.error('SQLite transaction error:', error); + throw error; + } + } + + async exec(sql: string): Promise { + try { + this.db.exec(sql); + } catch (error) { + logger.error('SQLite exec error:', { sql, error }); + throw error; + } + } + + async close(): Promise { + this.db.close(); + } + + async getSizeBytes(): Promise { + try { + if (fs.existsSync(this.dbPath)) { + const stats = fs.statSync(this.dbPath); + return stats.size; + } + return 0; + } catch (error) { + logger.error('SQLite getSizeBytes error:', error); + return 0; + } + } +} diff --git a/backend/src/services/jiraAssets.ts b/backend/src/services/jiraAssets.ts index 627200b..848e775 100644 --- a/backend/src/services/jiraAssets.ts +++ b/backend/src/services/jiraAssets.ts @@ -97,6 +97,8 @@ class JiraAssetsService { private complexityFactorsCache: Map | null = null; // Cache: Number of Users with factors private numberOfUsersCache: Map | null = null; + // Cache: Reference objects fetched via fallback (key: objectKey -> ReferenceValue) + private referenceObjectCache: Map = new Map(); // Cache: Team dashboard data private teamDashboardCache: { data: TeamDashboardData; timestamp: number } | null = null; private readonly TEAM_DASHBOARD_CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -414,11 +416,11 @@ class JiraAssetsService { } // Get reference value with schema fallback for attribute lookup - private getReferenceValueWithSchema( + private async getReferenceValueWithSchema( obj: JiraAssetsObject, attributeName: string, attrSchema?: Map - ): ReferenceValue | null { + ): Promise { const attr = this.getAttributeByName(obj, attributeName, attrSchema); if (!attr || attr.objectAttributeValues.length === 0) { return null; @@ -432,6 +434,50 @@ class JiraAssetsService { name: value.referencedObject.label, }; } + + // Fallback: if referencedObject is missing but we have a value, try to fetch it separately + // Note: value.value might be an object key (e.g., "GOV-A") or an object ID + if (value.value && !value.referencedObject) { + // Check cache first + const cached = this.referenceObjectCache.get(value.value); + if (cached) { + return cached; + } + + try { + // Try to fetch the referenced object by its key or ID + // First try as object key (most common) + let refObj: JiraAssetsObject | null = null; + try { + refObj = await this.request(`/object/${value.value}`); + } catch (keyError) { + // If that fails, try as object ID + try { + refObj = await this.request(`/object/${parseInt(value.value, 10)}`); + } catch (idError) { + // Both failed, log and continue + logger.debug(`getReferenceValueWithSchema: Could not fetch referenced object for value "${value.value}" (tried as key and ID) for attribute "${attributeName}" on object ${obj.objectKey}`); + } + } + + if (refObj) { + const refValue: ReferenceValue = { + objectId: refObj.id.toString(), + key: refObj.objectKey, + name: refObj.label, + }; + // Cache it for future use + this.referenceObjectCache.set(value.value, refValue); + this.referenceObjectCache.set(refObj.objectKey, refValue); + this.referenceObjectCache.set(refObj.id.toString(), refValue); + return refValue; + } + } catch (error) { + // If fetching fails, log but don't throw - just return null + logger.debug(`getReferenceValueWithSchema: Failed to fetch referenced object ${value.value} for attribute "${attributeName}" on object ${obj.objectKey}`, error); + } + } + return null; } @@ -511,26 +557,27 @@ class JiraAssetsService { // Ensure factor caches are populated (should be instant after initial population) await this.ensureFactorCaches(); - // Get reference values and enrich with factors - const dynamicsFactor = this.enrichWithFactor( + // Get reference values and enrich with factors (now async) + const [dynamicsFactorRaw, complexityFactorRaw, numberOfUsersRaw, governanceModel, applicationType, businessImpactAnalyse, applicationManagementHosting, applicationManagementTAM, applicationSubteam, platform] = await Promise.all([ this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), - this.dynamicsFactorsCache - ); - const complexityFactor = this.enrichWithFactor( this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema), - this.complexityFactorsCache - ); - const numberOfUsers = this.enrichWithFactor( this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.NUMBER_OF_USERS, attrSchema), - this.numberOfUsersCache - ); - - // Get other reference values needed for list view and effort calculation - const governanceModel = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema); - const applicationType = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema); - const businessImpactAnalyse = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema); - const applicationManagementHosting = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema); - const applicationManagementTAM = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema); + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + ]); + + if (!governanceModel && obj.objectKey) { + logger.debug(`parseJiraObject: No governanceModel found for ${obj.objectKey}. Attribute name: ${ATTRIBUTE_NAMES.GOVERNANCE_MODEL}`); + } + + const dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache); + const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); + const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); // Get override FTE const overrideFTE = (() => { @@ -589,10 +636,10 @@ class JiraAssetsService { dynamicsFactor, complexityFactor, // "Application Management - Subteam" on ApplicationComponent references Subteam objects - applicationSubteam: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + applicationSubteam, applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent applicationType, - platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + platform, requiredEffortApplicationManagement: effortResult.finalEffort, minFTE: effortResult.minFTE, maxFTE: effortResult.maxFTE, @@ -610,19 +657,37 @@ class JiraAssetsService { // Ensure factor caches are populated await this.ensureFactorCaches(); - // Get reference values and enrich with factors - const dynamicsFactor = this.enrichWithFactor( + // Get all reference values in parallel (now async) + const [ + dynamicsFactorRaw, + complexityFactorRaw, + numberOfUsersRaw, + hostingType, + businessImpactAnalyse, + governanceModel, + applicationSubteam, + applicationType, + platform, + applicationManagementHosting, + applicationManagementTAM, + ] = await Promise.all([ this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.DYNAMICS_FACTOR, attrSchema), - this.dynamicsFactorsCache - ); - const complexityFactor = this.enrichWithFactor( this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.COMPLEXITY_FACTOR, attrSchema), - this.complexityFactorsCache - ); - const numberOfUsers = this.enrichWithFactor( this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.NUMBER_OF_USERS, attrSchema), - this.numberOfUsersCache - ); + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.HOSTING_TYPE, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), + this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema), + ]); + + // Enrich with factors + const dynamicsFactor = this.enrichWithFactor(dynamicsFactorRaw, this.dynamicsFactorsCache); + const complexityFactor = this.enrichWithFactor(complexityFactorRaw, this.complexityFactorsCache); + const numberOfUsers = this.enrichWithFactor(numberOfUsersRaw, this.numberOfUsersCache); const applicationDetails: ApplicationDetails = { id: obj.id.toString(), @@ -632,10 +697,10 @@ class JiraAssetsService { description: rawDescription ? stripHtmlTags(rawDescription) : null, supplierProduct: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.SUPPLIER_PRODUCT, attrSchema), organisation: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.ORGANISATION, attrSchema), - hostingType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.HOSTING_TYPE, attrSchema), + hostingType, status: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.STATUS, attrSchema) as ApplicationStatus | null, businessImportance: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPORTANCE, attrSchema), - businessImpactAnalyse: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_IMPACT_ANALYSE, attrSchema), + businessImpactAnalyse, systemOwner: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.SYSTEM_OWNER, attrSchema), businessOwner: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.BUSINESS_OWNER, attrSchema), functionalApplicationManagement: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.FAM, attrSchema), @@ -648,12 +713,12 @@ class JiraAssetsService { dynamicsFactor, complexityFactor, numberOfUsers, - governanceModel: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema), + governanceModel, // "Application Management - Subteam" on ApplicationComponent references Subteam objects - applicationSubteam: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_SUBTEAM, attrSchema), + applicationSubteam, applicationTeam: null, // Team is looked up via Subteam, not directly on ApplicationComponent - applicationType: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_TYPE, attrSchema), - platform: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.PLATFORM, attrSchema), + applicationType, + platform, requiredEffortApplicationManagement: null, technischeArchitectuur: this.getAttributeValueWithSchema(obj, ATTRIBUTE_NAMES.TECHNISCHE_ARCHITECTUUR, attrSchema), overrideFTE: (() => { @@ -662,8 +727,8 @@ class JiraAssetsService { const parsed = parseFloat(value); return isNaN(parsed) ? null : parsed; })(), - applicationManagementHosting: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_HOSTING, attrSchema), - applicationManagementTAM: this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.APPLICATION_MANAGEMENT_TAM, attrSchema), + applicationManagementHosting, + applicationManagementTAM, }; // Calculate required effort application management @@ -1417,8 +1482,8 @@ class JiraAssetsService { for (const obj of response.objectEntries) { const subteamId = obj.id.toString(); - // Get the Team reference from the Subteam - const teamRef = this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUBTEAM_TEAM, attrSchema); + // Get the Team reference from the Subteam (now async) + const teamRef = await this.getReferenceValueWithSchema(obj, ATTRIBUTE_NAMES.SUBTEAM_TEAM, attrSchema); // Enrich the team reference with Type attribute if available if (teamRef) { @@ -1649,8 +1714,8 @@ class JiraAssetsService { const entries = batchResponse.objectEntries || []; totalFetched += entries.length; - // Process each application in the batch - for (const obj of entries) { + // Process each application in the batch (now async for governance model lookup) + await Promise.all(entries.map(async (obj) => { // Count by status (STATUS is a string/select value, not a reference) const status = this.getAttributeValueWithSchema( obj, @@ -1673,8 +1738,8 @@ class JiraAssetsService { classifiedCount++; } - // Count by governance model - const governanceModel = this.getReferenceValueWithSchema( + // Count by governance model (now async) + const governanceModel = await this.getReferenceValueWithSchema( obj, ATTRIBUTE_NAMES.GOVERNANCE_MODEL, attrSchema @@ -1686,7 +1751,7 @@ class JiraAssetsService { byGovernanceModel['Geen regiemodel'] = (byGovernanceModel['Geen regiemodel'] || 0) + 1; } - } + })); // Check if there are more pages hasMore = diff --git a/backend/src/services/jiraAssetsClient.ts b/backend/src/services/jiraAssetsClient.ts index 96d6e4f..8df7364 100644 --- a/backend/src/services/jiraAssetsClient.ts +++ b/backend/src/services/jiraAssetsClient.ts @@ -219,6 +219,29 @@ class JiraAssetsClient { }; } + /** + * Get the total count of objects for a specific type from Jira Assets + * This is more efficient than fetching all objects when you only need the count + */ + async getObjectCount(typeName: CMDBObjectTypeName): Promise { + const typeDef = OBJECT_TYPES[typeName]; + if (!typeDef) { + logger.warn(`JiraAssetsClient: Unknown type ${typeName}`); + return 0; + } + + try { + const iql = `objectType = "${typeDef.name}"`; + // Use pageSize=1 to minimize data transfer, we only need the totalCount + const result = await this.searchObjects(iql, 1, 1); + logger.debug(`JiraAssetsClient: ${typeName} has ${result.totalCount} objects in Jira Assets`); + return result.totalCount; + } catch (error) { + logger.error(`JiraAssetsClient: Failed to get count for ${typeName}`, error); + return 0; + } + } + async getAllObjectsOfType( typeName: CMDBObjectTypeName, batchSize: number = 40 @@ -292,12 +315,13 @@ class JiraAssetsClient { const typeName = TYPE_ID_TO_NAME[typeId] || JIRA_NAME_TO_TYPE[jiraObj.objectType?.name]; if (!typeName) { - logger.warn(`JiraAssetsClient: Unknown object type: ${jiraObj.objectType?.name} (ID: ${typeId})`); + logger.warn(`JiraAssetsClient: Unknown object type for object ${jiraObj.objectKey || jiraObj.id}: ${jiraObj.objectType?.name} (ID: ${typeId})`); return null; } const typeDef = OBJECT_TYPES[typeName]; if (!typeDef) { + logger.warn(`JiraAssetsClient: Type definition not found for type: ${typeName} (object: ${jiraObj.objectKey || jiraObj.id})`); return null; } diff --git a/backend/src/services/mockData.ts b/backend/src/services/mockData.ts index 1b2d273..f8f44b5 100644 --- a/backend/src/services/mockData.ts +++ b/backend/src/services/mockData.ts @@ -397,9 +397,11 @@ export class MockDataService { // Apply status filter if (filters.statuses && filters.statuses.length > 0) { - filtered = filtered.filter((app) => - app.status ? filters.statuses!.includes(app.status) : false - ); + filtered = filtered.filter((app) => { + // Handle empty/null status - treat as 'Undefined' for filtering + const status = app.status || 'Undefined'; + return filters.statuses!.includes(status as ApplicationStatus); + }); } // Apply applicationFunction filter diff --git a/backend/src/services/syncEngine.ts b/backend/src/services/syncEngine.ts index c5f6269..b4138ad 100644 --- a/backend/src/services/syncEngine.ts +++ b/backend/src/services/syncEngine.ts @@ -91,7 +91,7 @@ class SyncEngine { this.isRunning = true; // Check if we need a full sync - const stats = cacheStore.getStats(); + const stats = await cacheStore.getStats(); const lastFullSync = stats.lastFullSync; const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000); @@ -175,8 +175,8 @@ class SyncEngine { // Update sync metadata const now = new Date().toISOString(); - cacheStore.setSyncMetadata('lastFullSync', now); - cacheStore.setSyncMetadata('lastIncrementalSync', now); + await cacheStore.setSyncMetadata('lastFullSync', now); + await cacheStore.setSyncMetadata('lastIncrementalSync', now); this.lastIncrementalSync = new Date(); const duration = Date.now() - startTime; @@ -223,31 +223,52 @@ class SyncEngine { // Fetch all objects from Jira const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize); + logger.info(`SyncEngine: Fetched ${jiraObjects.length} ${typeName} objects from Jira`); // Parse and cache objects const parsedObjects: CMDBObject[] = []; + 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'})`); } } + // 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) { - cacheStore.batchUpsertObjects(typeName, parsedObjects); + await cacheStore.batchUpsertObjects(typeName, parsedObjects); objectsProcessed = parsedObjects.length; // Extract relations for (const obj of parsedObjects) { - cacheStore.extractAndStoreRelations(typeName, obj); + await cacheStore.extractAndStoreRelations(typeName, obj); relationsExtracted++; } } const duration = Date.now() - startTime; - logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`); + const skippedCount = jiraObjects.length - objectsProcessed; + if (skippedCount > 0) { + logger.warn(`SyncEngine: Synced ${objectsProcessed}/${jiraObjects.length} ${typeName} objects in ${duration}ms (${skippedCount} skipped)`); + } else { + logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`); + } return { objectType: typeName, @@ -304,7 +325,7 @@ class SyncEngine { try { // Get the last sync time - const lastSyncStr = cacheStore.getSyncMetadata('lastIncrementalSync'); + const lastSyncStr = await cacheStore.getSyncMetadata('lastIncrementalSync'); const since = lastSyncStr ? new Date(lastSyncStr) : new Date(Date.now() - 60000); // Default: last minute @@ -317,7 +338,7 @@ class SyncEngine { // If no objects returned (e.g., Data Center doesn't support IQL incremental sync), // check if we should trigger a full sync instead if (updatedObjects.length === 0) { - const lastFullSyncStr = cacheStore.getSyncMetadata('lastFullSync'); + const lastFullSyncStr = await cacheStore.getSyncMetadata('lastFullSync'); if (lastFullSyncStr) { const lastFullSync = new Date(lastFullSyncStr); const fullSyncAge = Date.now() - lastFullSync.getTime(); @@ -334,7 +355,7 @@ class SyncEngine { // Update timestamp even if no objects were synced const now = new Date(); - cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); + await cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); this.lastIncrementalSync = now; return { success: true, updatedCount: 0 }; @@ -346,15 +367,15 @@ class SyncEngine { const parsed = jiraAssetsClient.parseObject(jiraObj); if (parsed) { const typeName = parsed._objectType as CMDBObjectTypeName; - cacheStore.upsertObject(typeName, parsed); - cacheStore.extractAndStoreRelations(typeName, parsed); + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); updatedCount++; } } // Update sync metadata const now = new Date(); - cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); + await cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString()); this.lastIncrementalSync = now; if (updatedCount > 0) { @@ -413,14 +434,14 @@ class SyncEngine { const parsed = jiraAssetsClient.parseObject(jiraObj); if (!parsed) return false; - cacheStore.upsertObject(typeName, parsed); - cacheStore.extractAndStoreRelations(typeName, parsed); + await cacheStore.upsertObject(typeName, parsed); + await cacheStore.extractAndStoreRelations(typeName, parsed); return true; } catch (error) { // If object was deleted from Jira, remove it from our cache if (error instanceof JiraObjectNotFoundError) { - const deleted = cacheStore.deleteObject(typeName, objectId); + const deleted = await cacheStore.deleteObject(typeName, objectId); if (deleted) { logger.info(`SyncEngine: Removed deleted object ${typeName}/${objectId} from cache`); } @@ -438,8 +459,8 @@ class SyncEngine { /** * Get current sync engine status */ - getStatus(): SyncEngineStatus { - const stats = cacheStore.getStats(); + async getStatus(): Promise { + const stats = await cacheStore.getStats(); let nextIncrementalSync: string | null = null; if (this.isRunning && this.lastIncrementalSync) { diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 9427bd4..efa6ba7 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -47,8 +47,10 @@ export interface ApplicationListItem { minFTE?: number | null; // Minimum FTE from configuration range maxFTE?: number | null; // Maximum FTE from configuration range overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value) + businessImpactAnalyse?: ReferenceValue | null; // Business Impact Analyse applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM + dataCompletenessPercentage?: number; // Data completeness percentage (0-100) } // Full application details @@ -85,6 +87,7 @@ export interface ApplicationDetails { applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572) + dataCompletenessPercentage?: number; // Data completeness percentage (0-100) } // Search filters @@ -227,9 +230,12 @@ export interface ZiraTaxonomy { // Dashboard statistics export interface DashboardStats { - totalApplications: number; + totalApplications: number; // Excluding Closed/Deprecated + totalAllApplications: number; // Including all statuses classifiedCount: number; unclassifiedCount: number; + withApplicationFunction?: number; + applicationFunctionPercentage?: number; byStatus: Record; byDomain: Record; byGovernanceModel: Record; @@ -353,6 +359,8 @@ export interface JiraAssetsObject { name: string; }; attributes: JiraAssetsAttribute[]; + updated?: string; + created?: string; } export interface JiraAssetsAttribute { @@ -433,3 +441,27 @@ export interface ChatResponse { message: ChatMessage; suggestion?: AISuggestion; // Updated suggestion if AI provided one } + +// Data Completeness Configuration +export interface CompletenessFieldConfig { + id: string; // Unique identifier for the field within the category + name: string; // Display name (e.g., "Organisation", "ApplicationFunction") + fieldPath: string; // Path in ApplicationDetails object (e.g., "organisation", "applicationFunctions") + enabled: boolean; // Whether this field is included in completeness check +} + +export interface CompletenessCategoryConfig { + id: string; // Unique identifier for the category + name: string; + description: string; + fields: CompletenessFieldConfig[]; +} + +export interface DataCompletenessConfig { + metadata: { + version: string; + description: string; + lastUpdated: string; + }; + categories: CompletenessCategoryConfig[]; // Array of categories (dynamic) +} diff --git a/docker-compose.prod.registry.yml b/docker-compose.prod.registry.yml new file mode 100644 index 0000000..932e0b0 --- /dev/null +++ b/docker-compose.prod.registry.yml @@ -0,0 +1,74 @@ +version: '3.8' + +# Production Docker Compose using Gitea Container Registry +# +# Usage: +# 1. Build and push images: ./scripts/build-and-push.sh [version] +# 2. Deploy: ./scripts/deploy.sh [version] +# +# Or manually: +# docker login +# docker-compose -f docker-compose.prod.registry.yml pull +# docker-compose -f docker-compose.prod.registry.yml up -d +# +# Configuration: +# - Set GITEA_HOST environment variable (default: git.zuyderland.nl) +# - Set REPO_PATH environment variable (default: icmt/cmdb-gui) +# - Update image tags below to match your Gitea registry path + +services: + backend: + image: ${GITEA_HOST:-git.zuyderland.nl}/${REPO_PATH:-icmt/cmdb-gui}/backend:latest + environment: + - NODE_ENV=production + - PORT=3001 + env_file: + - .env.production + volumes: + - backend_data:/app/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + image: ${GITEA_HOST:-git.zuyderland.nl}/${REPO_PATH:-icmt/cmdb-gui}/frontend:latest + depends_on: + - backend + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_cache:/var/cache/nginx + depends_on: + - frontend + - backend + restart: unless-stopped + networks: + - internal + +volumes: + backend_data: + nginx_cache: + +networks: + internal: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..957a784 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,62 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + environment: + - NODE_ENV=production + - PORT=3001 + env_file: + - .env.production + volumes: + - backend_data:/app/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + depends_on: + - backend + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_cache:/var/cache/nginx + depends_on: + - frontend + - backend + restart: unless-stopped + networks: + - internal + +volumes: + backend_data: + nginx_cache: + +networks: + internal: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index ee692a6..d5415ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,23 @@ version: '3.8' services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: cmdb + POSTGRES_USER: cmdb + POSTGRES_PASSWORD: cmdb-dev + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cmdb"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + backend: build: context: ./backend @@ -10,6 +27,12 @@ services: environment: - NODE_ENV=development - PORT=3001 + - DATABASE_TYPE=postgres + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_NAME=cmdb + - DATABASE_USER=cmdb + - DATABASE_PASSWORD=cmdb-dev - JIRA_HOST=${JIRA_HOST} - JIRA_PAT=${JIRA_PAT} - JIRA_SCHEMA_ID=${JIRA_SCHEMA_ID} @@ -17,6 +40,9 @@ services: volumes: - ./backend/src:/app/src - backend_data:/app/data + depends_on: + postgres: + condition: service_healthy restart: unless-stopped frontend: @@ -33,3 +59,4 @@ services: volumes: backend_data: + postgres_data: \ No newline at end of file diff --git a/docs/AZURE-DEPLOYMENT-SUMMARY.md b/docs/AZURE-DEPLOYMENT-SUMMARY.md new file mode 100644 index 0000000..96eef11 --- /dev/null +++ b/docs/AZURE-DEPLOYMENT-SUMMARY.md @@ -0,0 +1,272 @@ +# Azure Deployment - Infrastructure Samenvatting + +## Applicatie Overzicht + +**Zuyderland CMDB GUI** - Web applicatie voor classificatie en beheer van applicatiecomponenten in Jira Assets. + +### Technologie Stack +- **Backend**: Node.js 20 (Express, TypeScript) +- **Frontend**: React 18 (Vite, TypeScript) +- **Database**: SQLite (cache layer, ~20MB, geen backup nodig - sync vanuit Jira) +- **Containerization**: Docker +- **Authentication**: Jira OAuth 2.0 of Personal Access Token +- **Gebruikers**: Max. 20 collega's + +--- + +## Infrastructure Vereisten + +### 1. Compute Resources + +**Aanbevolen: Azure App Service (Basic Tier)** +- **App Service Plan**: B1 (1 vCPU, 1.75GB RAM) - **voldoende voor 20 gebruikers** +- 2 Web Apps: Backend + Frontend (deel dezelfde App Service Plan) +- **Kosten**: ~€15-25/maand +- **Voordelen**: Eenvoudig, managed service, voldoende voor kleine teams + +**Alternatief: Azure Container Instances (ACI) - Als je containers prefereert** +- 2 containers: Backend + Frontend +- Backend: 1 vCPU, 2GB RAM +- Frontend: 0.5 vCPU, 1GB RAM +- **Kosten**: ~€30-50/maand +- **Nadeel**: Minder managed features dan App Service + +### 2. Database & Storage + +**Optie A: PostgreSQL (Aanbevolen) ⭐** +- **Azure Database for PostgreSQL**: Flexible Server Basic tier (B1ms) +- **Database**: ~20MB (huidige grootte, ruimte voor groei) +- **Kosten**: ~€20-30/maand +- **Voordelen**: Identieke dev/prod stack, betere concurrency, connection pooling + +**Optie B: SQLite (Huidige situatie)** +- **SQLite Database**: ~20MB (in Azure Storage) +- **Azure Storage Account**: Standard LRS (Hot tier) +- **Kosten**: ~€1-3/maand +- **Nadelen**: Beperkte concurrency, geen connection pooling + +**Logs**: ~500MB-1GB/maand (Application Insights) + +### 3. Networking + +**Vereisten:** +- **HTTPS**: SSL/TLS certificaat (Let's Encrypt of Azure App Service Certificate) +- **DNS**: Subdomain (bijv. `cmdb.zuyderland.nl`) +- **Firewall**: Inbound poorten 80/443, outbound naar Jira API +- **Load Balancer**: Azure Application Gateway (optioneel, voor HA) + +**Network Security:** +- Private endpoints (optioneel, voor extra security) +- Network Security Groups (NSG) +- Azure Firewall (optioneel) + +### 4. Secrets Management + +**Azure Key Vault** voor: +- `JIRA_OAUTH_CLIENT_SECRET` +- `SESSION_SECRET` +- `ANTHROPIC_API_KEY` +- `JIRA_PAT` (indien gebruikt) + +**Kosten**: ~€1-5/maand + +### 5. Monitoring & Logging + +**Azure Monitor:** +- Application Insights (Basic tier - gratis tot 5GB/maand) +- Log Analytics Workspace (Pay-as-you-go) +- Alerts voor health checks, errors + +**Kosten**: ~€0-20/maand (met Basic tier vaak gratis voor kleine apps) + +### 6. Backup & Disaster Recovery + +**Geen backup vereist** - Data wordt gesynchroniseerd vanuit Jira Assets, dus backup is niet nodig. +De SQLite database is een cache layer die opnieuw opgebouwd kan worden via sync. + +--- + +## Deployment Architectuur + +### Aanbevolen: Azure App Service (Basic Tier) + +**Eenvoudige setup voor kleine teams (20 gebruikers):** + +``` +┌─────────────────────────────────────┐ +│ Azure App Service (B1 Plan) │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Frontend │ │ Backend │ │ +│ │ Web App │ │ Web App │ │ +│ └──────────┘ └────┬─────┘ │ +└─────────────────────────┼──────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + ┌───────▼──────┐ ┌────────────▼────┐ + │ Azure Storage│ │ Azure Key Vault │ + │ (SQLite DB) │ │ (Secrets) │ + └──────────────┘ └─────────────────┘ + │ + ┌───────▼──────┐ + │ Application │ + │ Insights │ + │ (Basic/FREE) │ + └──────────────┘ +``` + +**Opmerking**: Application Gateway is niet nodig voor 20 gebruikers - App Service heeft ingebouwde SSL en load balancing. + +--- + +## Security Overwegingen + +### 1. Authentication +- **Jira OAuth 2.0**: Gebruikers authenticeren via Jira +- **Session Management**: Sessions in-memory (overweeg Azure Redis Cache voor productie) + +### 2. Network Security +- **HTTPS Only**: Alle verkeer via HTTPS +- **CORS**: Alleen toegestaan vanuit geconfigureerde frontend URL +- **Rate Limiting**: 100 requests/minuut per IP (configureerbaar) + +### 3. Data Security +- **Secrets**: Alle secrets in Azure Key Vault +- **Database**: SQLite database in Azure Storage (encrypted at rest) +- **In Transit**: TLS 1.2+ voor alle communicatie + +### 4. Compliance +- **Logging**: Alle API calls gelogd (geen PII) +- **Audit Trail**: Wijzigingen aan applicaties gelogd +- **Data Residency**: Data blijft in Azure West Europe (of gewenste regio) + +--- + +## Externe Dependencies + +### 1. Jira Assets API +- **Endpoint**: `https://jira.zuyderland.nl` +- **Authentication**: OAuth 2.0 of Personal Access Token +- **Rate Limits**: Respecteer Jira API rate limits +- **Network**: Outbound HTTPS naar Jira (poort 443) + +### 2. AI API (Optioneel) +- **Anthropic Claude API**: Voor AI classificatie features +- **Network**: Outbound HTTPS naar `api.anthropic.com` + +--- + +## Deployment Stappen + +### 1. Azure Resources Aanmaken +```bash +# Resource Group +az group create --name rg-cmdb-gui --location westeurope + +# App Service Plan (Basic B1 - voldoende voor 20 gebruikers) +az appservice plan create --name plan-cmdb-gui --resource-group rg-cmdb-gui --sku B1 + +# Web Apps (delen dezelfde plan - kostenbesparend) +az webapp create --name cmdb-backend --resource-group rg-cmdb-gui --plan plan-cmdb-gui +az webapp create --name cmdb-frontend --resource-group rg-cmdb-gui --plan plan-cmdb-gui + +# Key Vault +az keyvault create --name kv-cmdb-gui --resource-group rg-cmdb-gui --location westeurope + +# Storage Account (voor SQLite database - alleen bij SQLite optie) +az storage account create --name stcmdbgui --resource-group rg-cmdb-gui --location westeurope --sku Standard_LRS +``` + +**Met PostgreSQL (Aanbevolen):** +```bash +# PostgreSQL Database (Flexible Server) +az postgres flexible-server create \ + --resource-group rg-cmdb-gui \ + --name psql-cmdb-gui \ + --location westeurope \ + --admin-user cmdbadmin \ + --admin-password \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --storage-size 32 \ + --version 15 + +# Database aanmaken +az postgres flexible-server db create \ + --resource-group rg-cmdb-gui \ + --server-name psql-cmdb-gui \ + --database-name cmdb +``` + +### 2. Configuration +- Environment variabelen via App Service Configuration +- Secrets via Key Vault references +- SSL certificaat via App Service Certificate of Let's Encrypt + +### 3. CI/CD +- **Azure DevOps Pipelines** of **GitHub Actions** +- Automatische deployment bij push naar main branch +- Deployment slots voor zero-downtime updates + +--- + +## Kosten Schatting (Maandelijks) + +**Voor 20 gebruikers - Basic Setup:** + +**Met SQLite (huidige setup):** +| Component | Schatting | +|-----------|-----------| +| App Service Plan (B1) | €15-25 | +| Storage Account | €1-3 | +| Key Vault | €1-2 | +| Application Insights (Basic) | €0-5 | +| **Totaal** | **€17-35/maand** | + +**Met PostgreSQL (aanbevolen):** +| Component | Schatting | +|-----------|-----------| +| App Service Plan (B1) | €15-25 | +| PostgreSQL Database (B1ms) | €20-30 | +| Key Vault | €1-2 | +| Application Insights (Basic) | €0-5 | +| **Totaal** | **€36-62/maand** | + +*Inclusief: SSL certificaat (gratis via App Service), basis monitoring* + +**Opmerking**: Met Basic tier en gratis Application Insights kan dit zelfs onder €20/maand blijven. +**Backup**: Niet nodig - data wordt gesynchroniseerd vanuit Jira Assets. + +--- + +## Vragen voor Infrastructure Team + +1. **DNS & Domain**: Kunnen we een subdomain krijgen? (bijv. `cmdb.zuyderland.nl`) +2. **SSL Certificaat**: Azure App Service Certificate of Let's Encrypt via certbot? +3. **Network**: Moeten we via VPN/ExpressRoute of direct internet toegang? +4. **Firewall Rules**: Welke outbound toegang is nodig? (Jira API, Anthropic API) +5. **Monitoring**: Gebruiken we bestaande Azure Monitor setup of aparte workspace? +6. **Backup**: Niet nodig - SQLite database is cache layer, data wordt gesynchroniseerd vanuit Jira Assets +7. **Disaster Recovery**: Data kan opnieuw gesynchroniseerd worden vanuit Jira (geen backup vereist) +8. **Compliance**: Zijn er specifieke compliance requirements? (ISO 27001, NEN 7510) +9. **Scaling**: Niet nodig - max. 20 gebruikers, Basic tier is voldoende +10. **Maintenance Windows**: Wanneer kunnen we updates deployen? + +--- + +## Next Steps + +1. **Kick-off Meeting**: Bespreken architectuur en requirements +2. **Proof of Concept**: Deploy naar Azure App Service (test environment) +3. **Security Review**: Security team review van configuratie +4. **Load Testing**: Testen onder verwachte load +5. **Production Deployment**: Go-live met monitoring + +--- + +## Contact & Documentatie + +- **Application Code**: [Git Repository] +- **Deployment Guide**: `PRODUCTION-DEPLOYMENT.md` +- **API Documentation**: `/api/config` endpoint diff --git a/docs/AZURE-QUICK-REFERENCE.md b/docs/AZURE-QUICK-REFERENCE.md new file mode 100644 index 0000000..da61a98 --- /dev/null +++ b/docs/AZURE-QUICK-REFERENCE.md @@ -0,0 +1,142 @@ +# Azure Deployment - Quick Reference + +## 🎯 In één oogopslag + +**Applicatie**: Zuyderland CMDB GUI (Node.js + React web app) +**Doel**: Hosten in Azure App Service +**Gebruikers**: Max. 20 collega's +**Geschatte kosten**: €18-39/maand (Basic tier) +**Complexiteit**: Laag (eenvoudige web app deployment) + +--- + +## 📦 Wat hebben we nodig? + +### Core Services +- ✅ **Azure App Service Plan (B1)**: Gedeeld tussen Backend + Frontend +- ✅ **Azure Key Vault**: Voor secrets (OAuth, API keys) +- ✅ **Database**: PostgreSQL (aanbevolen) of SQLite (huidige) + - PostgreSQL: Azure Database for PostgreSQL (B1ms) - €20-30/maand + - SQLite: Azure Storage - €1-3/maand +- ✅ **Application Insights (Basic)**: Monitoring & logging (gratis tot 5GB/maand) + +### Networking +- ✅ **HTTPS**: SSL certificaat (App Service Certificate of Let's Encrypt) +- ✅ **DNS**: Subdomain (bijv. `cmdb.zuyderland.nl`) +- ✅ **Outbound**: Toegang naar `jira.zuyderland.nl` (HTTPS) + +### Security +- ✅ **OAuth 2.0**: Authenticatie via Jira +- ✅ **Secrets**: Alles in Key Vault +- ✅ **HTTPS Only**: Geen HTTP toegang + +--- + +## 💰 Kosten Breakdown + +**Optie 1: Met SQLite (huidige setup)** +| Item | Maandelijks | +|------|-------------| +| App Service Plan (B1) | €15-25 | +| Application Insights (Basic) | €0-5 | +| Storage + Key Vault | €2-5 | +| **Totaal** | **€17-35** | + +**Optie 2: Met PostgreSQL (aanbevolen voor identieke dev/prod)** +| Item | Maandelijks | +|------|-------------| +| App Service Plan (B1) | €15-25 | +| PostgreSQL Database (B1ms) | €20-30 | +| Application Insights (Basic) | €0-5 | +| Key Vault | €1-2 | +| **Totaal** | **€36-62** | + +*Zie `DATABASE-RECOMMENDATION.md` voor volledige vergelijking* +*Backup niet nodig - data sync vanuit Jira Assets* + +--- + +## ⚙️ Technische Details + +**Backend:** +- Node.js 20, Express API +- Poort: 3001 (intern) +- Health check: `/health` endpoint +- Database: SQLite (~20MB - huidige grootte) +- **Resources**: 1 vCPU, 1.75GB RAM (B1 tier - voldoende) +- **Backup**: Niet nodig - data sync vanuit Jira Assets + +**Frontend:** +- React SPA +- Static files via App Service +- API calls naar backend via `/api/*` + +**Dependencies:** +- Jira Assets API (outbound HTTPS) +- Anthropic API (optioneel, voor AI features) + +--- + +## 🚀 Deployment Opties + +### Optie 1: Azure App Service Basic (Aanbevolen) ⭐ +- **Pro**: Eenvoudig, managed service, goedkoop, voldoende voor 20 gebruikers +- **Con**: Geen auto-scaling (niet nodig), minder flexibel dan containers +- **Tijd**: 1 dag setup +- **Kosten**: €18-39/maand + +### Optie 2: Azure Container Instances (ACI) +- **Pro**: Snelle setup, container-based +- **Con**: Duurder dan App Service, minder managed features +- **Tijd**: 1 dag setup +- **Kosten**: €30-50/maand + +**Niet aanbevolen voor 20 gebruikers** - App Service is goedkoper en eenvoudiger. + +--- + +## ❓ Vragen voor Jullie + +1. **DNS**: Kunnen we `cmdb.zuyderland.nl` krijgen? +2. **SSL**: App Service Certificate of Let's Encrypt? +3. **Network**: Direct internet of via VPN/ExpressRoute? +4. **Monitoring**: Nieuwe workspace of bestaande? +5. **Backup**: Niet nodig - data wordt gesynchroniseerd vanuit Jira Assets +6. **Compliance**: Specifieke requirements? (NEN 7510, ISO 27001) + +--- + +## 📋 Checklist voor Go-Live + +- [ ] Resource Group aangemaakt +- [ ] App Service Plan geconfigureerd +- [ ] 2x Web Apps aangemaakt (backend + frontend) +- [ ] Key Vault aangemaakt met secrets +- [ ] Storage Account voor database +- [ ] SSL certificaat geconfigureerd +- [ ] DNS record aangemaakt +- [ ] Application Insights geconfigureerd +- [ ] Health checks getest +- [ ] Monitoring alerts ingesteld + +--- + +## 📝 Belangrijke Notities + +**Schaalbaarheid**: Deze setup is geoptimaliseerd voor **max. 20 gebruikers**. +- Basic B1 tier (1 vCPU, 1.75GB RAM) is ruim voldoende +- Geen auto-scaling nodig +- Geen load balancer nodig +- Eenvoudige, kosteneffectieve oplossing + +**Als het aantal gebruikers groeit** (>50 gebruikers), overweeg dan: +- Upgrade naar B2 tier (€50-75/maand) +- Of Standard S1 tier voor betere performance + +--- + +## 📞 Contact + +Voor vragen over de applicatie zelf, zie: +- `PRODUCTION-DEPLOYMENT.md` - Volledige deployment guide +- `AZURE-DEPLOYMENT-SUMMARY.md` - Uitgebreide Azure specifieke info diff --git a/docs/DATABASE-RECOMMENDATION.md b/docs/DATABASE-RECOMMENDATION.md new file mode 100644 index 0000000..8e5a90a --- /dev/null +++ b/docs/DATABASE-RECOMMENDATION.md @@ -0,0 +1,464 @@ +# Database Engine Aanbeveling - Azure Productie + +## Huidige Situatie + +De applicatie gebruikt momenteel **SQLite** via `better-sqlite3`: +- **cmdb-cache.db**: ~20MB - CMDB object cache +- **classifications.db**: Classification history + +## Aanbeveling: PostgreSQL + +> **Belangrijk**: Azure Database for MariaDB wordt afgeschaft (retirement september 2025). +> De keuze is daarom tussen **PostgreSQL** en **MySQL** (niet MariaDB). + +### PostgreSQL vs MySQL Vergelijking + +| Feature | PostgreSQL | MySQL (Azure) | +|---------|------------|---------------| +| **Azure Support** | ✅ Flexible Server | ✅ Flexible Server | +| **Kosten (B1ms)** | ~€20-30/maand | ~€20-30/maand | +| **JSON Support** | ✅ JSONB (superieur) | ✅ JSON (basis) | +| **Performance** | ✅ Uitstekend | ✅ Goed | +| **Concurrency** | ✅ MVCC (zeer goed) | ✅ Goed | +| **SQL Standards** | ✅ Zeer compliant | ⚠️ Eigen dialect | +| **Full-Text Search** | ✅ Ingebouwd | ⚠️ Basis | +| **Community** | ✅ Groot & actief | ✅ Groot & actief | +| **Development Tools** | ✅ Uitstekend | ✅ Goed | + +**Voor jouw use case (JSON data, 20 gebruikers):** + +✅ **PostgreSQL heeft voordeel:** +- **JSONB**: Betere JSON performance en querying (gebruikt in huidige schema) +- **MVCC**: Betere concurrency voor 20 gelijktijdige gebruikers +- **SQL Standards**: Makkelijker migreren van SQLite +- **Full-Text Search**: Ingebouwd (handig voor toekomstige zoekfuncties) + +✅ **MySQL is ook goed:** +- Vergelijkbare kosten +- Goede performance +- Veel gebruikt (bekende technologie) + +**Conclusie**: Beide zijn goede keuzes. PostgreSQL heeft lichte voordelen voor JSON-heavy workloads en betere SQLite compatibiliteit. + +### Waarom PostgreSQL? + +✅ **Identieke Dev/Prod Stack** +- Lokaal: PostgreSQL via Docker (gratis) +- Azure: Azure Database for PostgreSQL Flexible Server +- Zelfde database engine, zelfde SQL syntax + +✅ **Azure Integratie** +- Native ondersteuning in Azure +- Managed service (geen server management) +- Betaalbaar: Basic tier ~€20-30/maand voor 20 gebruikers + +✅ **Performance** +- Betere concurrency dan SQLite (20 gebruikers) +- Connection pooling (nodig voor web apps) +- Betere query performance bij groei + +✅ **Features** +- JSON support (gebruikt in huidige schema) +- Transactions +- Foreign keys +- Full-text search (toekomstig) + +✅ **Development Experience** +- Docker setup identiek aan productie +- Migraties mogelijk (Prisma, Knex, etc.) +- Betere tooling + +### Alternatief: SQLite Blijven + +**Voordelen:** +- ✅ Geen migratie nodig +- ✅ Werkt in Azure App Service (file storage) +- ✅ Gratis +- ✅ Eenvoudig + +**Nadelen:** +- ❌ Beperkte concurrency (kan problemen geven met 20 gebruikers) +- ❌ Geen connection pooling +- ❌ File-based (moeilijker backup/restore) +- ❌ Beperkte query performance bij groei + +**Conclusie**: SQLite kan werken voor 20 gebruikers, maar PostgreSQL is beter voor productie. + +--- + +## Migratie naar PostgreSQL + +### Stappenplan + +#### 1. Database Abstraction Layer + +Maak een database abstraction layer zodat we kunnen wisselen tussen SQLite (dev) en PostgreSQL (prod): + +```typescript +// backend/src/services/database/interface.ts +export interface DatabaseAdapter { + query(sql: string, params?: any[]): Promise; + execute(sql: string, params?: any[]): Promise; + transaction(callback: (db: DatabaseAdapter) => Promise): Promise; + close(): Promise; +} +``` + +#### 2. PostgreSQL Adapter + +```typescript +// backend/src/services/database/postgresAdapter.ts +import { Pool } from 'pg'; + +export class PostgresAdapter implements DatabaseAdapter { + private pool: Pool; + + constructor(connectionString: string) { + this.pool = new Pool({ connectionString }); + } + + async query(sql: string, params?: any[]): Promise { + const result = await this.pool.query(sql, params); + return result.rows; + } + + // ... implement other methods +} +``` + +#### 3. SQLite Adapter (voor backward compatibility) + +```typescript +// backend/src/services/database/sqliteAdapter.ts +import Database from 'better-sqlite3'; + +export class SqliteAdapter implements DatabaseAdapter { + private db: Database.Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + } + + async query(sql: string, params?: any[]): Promise { + const stmt = this.db.prepare(sql); + return stmt.all(...(params || [])) as any[]; + } + + // ... implement other methods +} +``` + +#### 4. Schema Migratie + +**SQLite → PostgreSQL verschillen:** +- `INTEGER PRIMARY KEY AUTOINCREMENT` → `SERIAL PRIMARY KEY` +- `TEXT` → `TEXT` of `VARCHAR` +- `JSON` → `JSONB` (beter in PostgreSQL) +- `ON CONFLICT` → `ON CONFLICT` (werkt in beide) + +**SQLite → MySQL verschillen:** +- `INTEGER PRIMARY KEY AUTOINCREMENT` → `INT AUTO_INCREMENT PRIMARY KEY` +- `TEXT` → `TEXT` of `VARCHAR(255)` +- `JSON` → `JSON` (basis support) +- `ON CONFLICT` → `ON DUPLICATE KEY UPDATE` (andere syntax) + +--- + +## Azure Setup + +### Optie 1: Azure Database for PostgreSQL Flexible Server + +**Basic Tier (aanbevolen voor 20 gebruikers):** +- **Burstable B1ms**: 1 vCore, 2GB RAM +- **Storage**: 32GB (meer dan genoeg voor 20MB database) +- **Kosten**: ~€20-30/maand +- **Backup**: 7 dagen retention (gratis) + +**Configuratie:** +```bash +# Azure CLI +az postgres flexible-server create \ + --resource-group rg-cmdb-gui \ + --name psql-cmdb-gui \ + --location westeurope \ + --admin-user cmdbadmin \ + --admin-password \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --storage-size 32 \ + --version 15 +``` + +**Connection String:** +``` +postgresql://cmdbadmin:@psql-cmdb-gui.postgres.database.azure.com:5432/cmdb?sslmode=require +``` + +### Optie 2: Azure Database for MySQL Flexible Server + +**Basic Tier (aanbevolen voor 20 gebruikers):** +- **Burstable B1ms**: 1 vCore, 2GB RAM +- **Storage**: 32GB (meer dan genoeg voor 20MB database) +- **Kosten**: ~€20-30/maand +- **Backup**: 7 dagen retention (gratis) + +**Configuratie:** +```bash +# Azure CLI +az mysql flexible-server create \ + --resource-group rg-cmdb-gui \ + --name mysql-cmdb-gui \ + --location westeurope \ + --admin-user cmdbadmin \ + --admin-password \ + --sku-name Standard_B1ms \ + --tier Burstable \ + --storage-size 32 \ + --version 8.0.21 +``` + +**Connection String:** +``` +mysql://cmdbadmin:@mysql-cmdb-gui.mysql.database.azure.com:3306/cmdb?ssl-mode=REQUIRED +``` + +**MySQL vs PostgreSQL voor jouw schema:** +- **JSON**: MySQL heeft JSON type, maar PostgreSQL JSONB is sneller voor queries +- **AUTOINCREMENT**: MySQL gebruikt `AUTO_INCREMENT`, PostgreSQL gebruikt `SERIAL` (beide werken) +- **ON CONFLICT**: MySQL gebruikt `ON DUPLICATE KEY UPDATE`, PostgreSQL gebruikt `ON CONFLICT` (beide werken) + +--- + +## Local Development Setup + +### Optie 1: Docker Compose voor PostgreSQL + +Voeg toe aan `docker-compose.yml`: + +```yaml +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: cmdb + POSTGRES_USER: cmdb + POSTGRES_PASSWORD: cmdb-dev + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cmdb"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: +``` + +### Optie 2: Docker Compose voor MySQL + +Voeg toe aan `docker-compose.yml`: + +```yaml +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: cmdb + MYSQL_USER: cmdb + MYSQL_PASSWORD: cmdb-dev + MYSQL_ROOT_PASSWORD: root-dev + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: +``` + +**Environment variabelen (lokaal - PostgreSQL):** +```bash +# .env.local +DATABASE_URL=postgresql://cmdb:cmdb-dev@localhost:5432/cmdb +# of +DATABASE_TYPE=postgres +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=cmdb +DATABASE_USER=cmdb +DATABASE_PASSWORD=cmdb-dev +``` + +**Environment variabelen (Azure):** +```bash +# Azure App Service Configuration +DATABASE_TYPE=postgres +DATABASE_HOST=psql-cmdb-gui.postgres.database.azure.com +DATABASE_PORT=5432 +DATABASE_NAME=cmdb +DATABASE_USER=cmdbadmin +DATABASE_PASSWORD= +DATABASE_SSL=true +``` + +**Environment variabelen (lokaal - MySQL):** +```bash +# .env.local +DATABASE_URL=mysql://cmdb:cmdb-dev@localhost:3306/cmdb +# of +DATABASE_TYPE=mysql +DATABASE_HOST=localhost +DATABASE_PORT=3306 +DATABASE_NAME=cmdb +DATABASE_USER=cmdb +DATABASE_PASSWORD=cmdb-dev +``` + +**Environment variabelen (Azure - MySQL):** +```bash +# Azure App Service Configuration +DATABASE_TYPE=mysql +DATABASE_HOST=mysql-cmdb-gui.mysql.database.azure.com +DATABASE_PORT=3306 +DATABASE_NAME=cmdb +DATABASE_USER=cmdbadmin +DATABASE_PASSWORD= +DATABASE_SSL=true +``` + +--- + +## Kosten Vergelijking + +| Optie | Lokaal | Azure (maandelijks) | Totaal | +|-------|--------|---------------------|--------| +| **PostgreSQL** | Gratis (Docker) | €20-30 | €20-30 | +| **MySQL** | Gratis (Docker) | €20-30 | €20-30 | +| **SQLite** | Gratis | €0 (in App Service) | €0 | +| **Azure SQL** | SQL Server Express | €50-100 | €50-100 | + +**Aanbeveling**: PostgreSQL - beste balans tussen kosten, performance en identieke stack. +MySQL is ook een goede keuze met vergelijkbare kosten en performance. + +--- + +## Migratie Script + +### SQLite naar PostgreSQL Converter + +```typescript +// scripts/migrate-sqlite-to-postgres.ts +import Database from 'better-sqlite3'; +import { Pool } from 'pg'; + +async function migrate() { + // Connect to SQLite + const sqlite = new Database('./data/cmdb-cache.db'); + + // Connect to PostgreSQL + const pg = new Pool({ + connectionString: process.env.DATABASE_URL + }); + + // Migrate cached_objects + const objects = sqlite.prepare('SELECT * FROM cached_objects').all(); + for (const obj of objects) { + await pg.query( + `INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING`, + [obj.id, obj.object_key, obj.object_type, obj.label, obj.data, obj.jira_updated_at, obj.jira_created_at, obj.cached_at] + ); + } + + // Migrate relations, metadata, etc. + // ... + + sqlite.close(); + await pg.end(); +} +``` + +--- + +## Implementatie Plan + +### Fase 1: Database Abstraction (1-2 dagen) +1. Maak `DatabaseAdapter` interface +2. Implementeer `PostgresAdapter` en `SqliteAdapter` +3. Update `CacheStore` en `DatabaseService` om adapter te gebruiken + +### Fase 2: Local PostgreSQL Setup (0.5 dag) +1. Voeg PostgreSQL toe aan docker-compose.yml +2. Test lokaal met PostgreSQL +3. Update environment configuratie + +### Fase 3: Schema Migratie (1 dag) +1. Converteer SQLite schema naar PostgreSQL +2. Maak migratie script +3. Test migratie lokaal + +### Fase 4: Azure Setup (1 dag) +1. Maak Azure Database for PostgreSQL +2. Configureer connection string in Key Vault +3. Test connectiviteit + +### Fase 5: Productie Migratie (0.5 dag) +1. Migreer data van SQLite naar PostgreSQL +2. Update App Service configuratie +3. Test in productie + +**Totaal**: ~3-4 dagen werk + +--- + +## Aanbeveling + +### 🏆 PostgreSQL (Aanbevolen) + +**Gebruik PostgreSQL** voor: +- ✅ Identieke dev/prod stack +- ✅ Betere JSON performance (JSONB) +- ✅ Betere concurrency (MVCC) +- ✅ Azure native ondersteuning +- ✅ Toekomstbestendig +- ✅ Betaalbaar (~€20-30/maand) +- ✅ Makkelijker migratie van SQLite (SQL syntax) + +### ✅ MySQL (Ook goed) + +**MySQL is ook een goede keuze** als: +- Je team meer ervaring heeft met MySQL +- Je voorkeur geeft aan MySQL ecosystem +- Vergelijkbare kosten en performance acceptabel zijn +- Je JSON queries niet te complex zijn + +**Nadelen t.o.v. PostgreSQL:** +- ⚠️ Minder geavanceerde JSON support (geen JSONB) +- ⚠️ Minder SQL standards compliant +- ⚠️ Iets andere syntax voor conflicten + +### 💰 SQLite (Minimale kosten) + +**SQLite blijft optie** als: +- Je snel moet deployen zonder migratie +- Kosten kritiek zijn (€0 vs €20-30) +- Je accepteert beperkingen (concurrency, performance) + +**Conclusie**: Voor jouw use case (JSON data, 20 gebruikers) is **PostgreSQL de beste keuze**, maar MySQL is ook prima. Beide zijn veel beter dan SQLite voor productie. + +--- + +## Next Steps + +1. **Beslissing**: PostgreSQL of SQLite blijven? +2. **Als PostgreSQL**: Start met Fase 1 (Database Abstraction) +3. **Als SQLite**: Documenteer beperkingen en overweeg toekomstige migratie diff --git a/docs/GITEA-DOCKER-REGISTRY.md b/docs/GITEA-DOCKER-REGISTRY.md new file mode 100644 index 0000000..c04bbf0 --- /dev/null +++ b/docs/GITEA-DOCKER-REGISTRY.md @@ -0,0 +1,435 @@ +# Gitea Docker Container Registry - Deployment Guide + +Deze guide beschrijft hoe je Gitea gebruikt als Docker Container Registry voor het deployen van de Zuyderland CMDB GUI applicatie in productie. + +## 📋 Inhoudsopgave + +1. [Gitea Container Registry Setup](#gitea-container-registry-setup) +2. [Build & Push Images](#build--push-images) +3. [Docker Compose Configuration](#docker-compose-configuration) +4. [Deployment Workflow](#deployment-workflow) +5. [Automation Scripts](#automation-scripts) + +--- + +## 🔧 Gitea Container Registry Setup + +### 1. Enable Container Registry in Gitea + +In je Gitea configuratie (`app.ini`), zorg dat de Container Registry enabled is: + +```ini +[registry] +ENABLED = true +``` + +Of via de Gitea UI: **Settings** → **Application** → **Container Registry** → Enable + +### 2. Registry URL Format + +Gitea Container Registry gebruikt het volgende formaat: +``` +// +``` + +Bijvoorbeeld: +- Gitea URL: `https://git.zuyderland.nl` +- Repository: `icmt/cmdb-gui` +- Registry URL: `git.zuyderland.nl/icmt/cmdb-gui` + +--- + +## 🐳 Build & Push Images + +### 1. Login to Gitea Registry + +```bash +# Login met Gitea credentials +docker login git.zuyderland.nl +# Username: +# Password: (of Personal Access Token) +``` + +### 2. Build Images + +```bash +# Build backend image +docker build -t git.zuyderland.nl/icmt/cmdb-gui/backend:latest -f backend/Dockerfile.prod ./backend + +# Build frontend image +docker build -t git.zuyderland.nl/icmt/cmdb-gui/frontend:latest -f frontend/Dockerfile.prod ./frontend +``` + +### 3. Push Images + +```bash +# Push backend image +docker push git.zuyderland.nl/icmt/cmdb-gui/backend:latest + +# Push frontend image +docker push git.zuyderland.nl/icmt/cmdb-gui/frontend:latest +``` + +### 4. Tagging for Versions + +Voor versioned releases: + +```bash +VERSION="1.0.0" + +# Tag and push backend +docker tag git.zuyderland.nl/icmt/cmdb-gui/backend:latest \ + git.zuyderland.nl/icmt/cmdb-gui/backend:v${VERSION} +docker push git.zuyderland.nl/icmt/cmdb-gui/backend:v${VERSION} + +# Tag and push frontend +docker tag git.zuyderland.nl/icmt/cmdb-gui/frontend:latest \ + git.zuyderland.nl/icmt/cmdb-gui/frontend:v${VERSION} +docker push git.zuyderland.nl/icmt/cmdb-gui/frontend:v${VERSION} +``` + +--- + +## 🚀 Docker Compose Configuration + +### Production Docker Compose met Gitea Registry + +Maak `docker-compose.prod.registry.yml`: + +```yaml +version: '3.8' + +services: + backend: + image: git.zuyderland.nl/icmt/cmdb-gui/backend:latest + environment: + - NODE_ENV=production + - PORT=3001 + env_file: + - .env.production + volumes: + - backend_data:/app/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + image: git.zuyderland.nl/icmt/cmdb-gui/frontend:latest + depends_on: + - backend + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_cache:/var/cache/nginx + depends_on: + - frontend + - backend + restart: unless-stopped + networks: + - internal + +volumes: + backend_data: + nginx_cache: + +networks: + internal: + driver: bridge +``` + +### Using Specific Versions + +Voor productie deployments, gebruik specifieke versies in plaats van `latest`: + +```yaml +backend: + image: git.zuyderland.nl/icmt/cmdb-gui/backend:v1.0.0 + +frontend: + image: git.zuyderland.nl/icmt/cmdb-gui/frontend:v1.0.0 +``` + +--- + +## 📦 Deployment Workflow + +### 1. Build & Push Script + +Maak `scripts/build-and-push.sh`: + +```bash +#!/bin/bash +set -e + +# Configuration +GITEA_HOST="git.zuyderland.nl" +REPO_PATH="icmt/cmdb-gui" +VERSION="${1:-latest}" + +echo "🔨 Building Docker images..." +echo "Registry: ${GITEA_HOST}/${REPO_PATH}" +echo "Version: ${VERSION}" + +# Build backend +echo "📦 Building backend..." +docker build -t ${GITEA_HOST}/${REPO_PATH}/backend:${VERSION} \ + -f backend/Dockerfile.prod ./backend + +# Build frontend +echo "📦 Building frontend..." +docker build -t ${GITEA_HOST}/${REPO_PATH}/frontend:${VERSION} \ + -f frontend/Dockerfile.prod ./frontend + +# Push images +echo "📤 Pushing images to registry..." +docker push ${GITEA_HOST}/${REPO_PATH}/backend:${VERSION} +docker push ${GITEA_HOST}/${REPO_PATH}/frontend:${VERSION} + +echo "✅ Build and push complete!" +echo "" +echo "To deploy, run:" +echo " docker-compose -f docker-compose.prod.registry.yml pull" +echo " docker-compose -f docker-compose.prod.registry.yml up -d" +``` + +### 2. Deployment Script + +Maak `scripts/deploy.sh`: + +```bash +#!/bin/bash +set -e + +VERSION="${1:-latest}" +COMPOSE_FILE="docker-compose.prod.registry.yml" + +echo "🚀 Deploying version: ${VERSION}" + +# Update image tags in compose file (if using version tags) +if [ "$VERSION" != "latest" ]; then + sed -i.bak "s|:latest|:v${VERSION}|g" ${COMPOSE_FILE} +fi + +# Pull latest images +echo "📥 Pulling images..." +docker-compose -f ${COMPOSE_FILE} pull + +# Deploy +echo "🚀 Starting services..." +docker-compose -f ${COMPOSE_FILE} up -d + +# Cleanup old images (optional) +echo "🧹 Cleaning up..." +docker image prune -f + +echo "✅ Deployment complete!" +echo "" +echo "Check status:" +echo " docker-compose -f ${COMPOSE_FILE} ps" +echo "" +echo "View logs:" +echo " docker-compose -f ${COMPOSE_FILE} logs -f" +``` + +### 3. CI/CD Integration (Gitea Actions) + +Maak `.gitea/workflows/docker-build.yml`: + +```yaml +name: Build and Push Docker Images + +on: + push: + tags: + - 'v*' + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v2 + with: + registry: git.zuyderland.nl + username: ${{ secrets.GITEA_USERNAME }} + password: ${{ secrets.GITEA_PASSWORD }} + + - name: Determine version + id: version + run: | + if [[ ${{ github.ref }} == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION=latest + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Build and push backend + uses: docker/build-push-action@v4 + with: + context: ./backend + file: ./backend/Dockerfile.prod + push: true + tags: | + git.zuyderland.nl/icmt/cmdb-gui/backend:${{ steps.version.outputs.version }} + git.zuyderland.nl/icmt/cmdb-gui/backend:latest + + - name: Build and push frontend + uses: docker/build-push-action@v4 + with: + context: ./frontend + file: ./frontend/Dockerfile.prod + push: true + tags: | + git.zuyderland.nl/icmt/cmdb-gui/frontend:${{ steps.version.outputs.version }} + git.zuyderland.nl/icmt/cmdb-gui/frontend:latest +``` + +--- + +## 🔐 Authentication + +### Personal Access Token (Aanbevolen) + +Voor CI/CD en automatisering, gebruik een Personal Access Token: + +1. Gitea UI → **Settings** → **Applications** → **Generate New Token** +2. Scopes: `read:repository`, `write:repository` +3. Gebruik token als password bij `docker login`: + +```bash +echo $GITEA_TOKEN | docker login git.zuyderland.nl -u --password-stdin +``` + +### Environment Variables + +Voor scripts, gebruik environment variables: + +```bash +export GITEA_REGISTRY="git.zuyderland.nl" +export GITEA_USERNAME="your-username" +export GITEA_PASSWORD="your-token" +export REPO_PATH="icmt/cmdb-gui" +``` + +--- + +## 📝 Usage Examples + +### Build and Push + +```bash +# Build and push latest +./scripts/build-and-push.sh + +# Build and push specific version +./scripts/build-and-push.sh 1.0.0 +``` + +### Deploy + +```bash +# Deploy latest +./scripts/deploy.sh + +# Deploy specific version +./scripts/deploy.sh 1.0.0 +``` + +### Manual Deployment + +```bash +# Login +docker login git.zuyderland.nl + +# Pull images +docker-compose -f docker-compose.prod.registry.yml pull + +# Deploy +docker-compose -f docker-compose.prod.registry.yml up -d + +# Check status +docker-compose -f docker-compose.prod.registry.yml ps + +# View logs +docker-compose -f docker-compose.prod.registry.yml logs -f +``` + +--- + +## 🔍 Troubleshooting + +### Authentication Issues + +```bash +# Check login status +cat ~/.docker/config.json + +# Re-login +docker logout git.zuyderland.nl +docker login git.zuyderland.nl +``` + +### Registry Not Found + +- Controleer dat Container Registry enabled is in Gitea +- Verifieer de registry URL format: `//` +- Check Gitea logs voor errors + +### Image Pull Errors + +```bash +# Check if image exists in registry (via Gitea UI) +# Verify network connectivity +curl -I https://git.zuyderland.nl + +# Check Docker daemon logs +journalctl -u docker.service +``` + +--- + +## 🎯 Best Practices + +1. **Use Version Tags**: Gebruik specifieke versies (`v1.0.0`) voor productie, `latest` voor development +2. **Security**: Gebruik Personal Access Tokens in plaats van passwords +3. **CI/CD**: Automatiseer build/push via Gitea Actions +4. **Image Scanning**: Overweeg image vulnerability scanning (Trivy, Clair) +5. **Registry Cleanup**: Regelmatig oude images verwijderen om ruimte te besparen + +--- + +## 📚 Additional Resources + +- [Gitea Container Registry Documentation](https://docs.gitea.io/en-us/usage/packages/container/) +- [Docker Registry Authentication](https://docs.docker.com/engine/reference/commandline/login/) +- [Docker Compose Production Guide](./PRODUCTION-DEPLOYMENT.md) diff --git a/docs/PRODUCTION-DEPLOYMENT.md b/docs/PRODUCTION-DEPLOYMENT.md new file mode 100644 index 0000000..319e81a --- /dev/null +++ b/docs/PRODUCTION-DEPLOYMENT.md @@ -0,0 +1,759 @@ +# Productie Deployment Guide + +Deze guide beschrijft hoe je de Zuyderland CMDB GUI applicatie veilig en betrouwbaar in productie kunt draaien. + +## 📋 Inhoudsopgave + +1. [Security Best Practices](#security-best-practices) +2. [Production Docker Setup](#production-docker-setup) +3. [Environment Configuratie](#environment-configuratie) +4. [Reverse Proxy (Nginx)](#reverse-proxy-nginx) +5. [SSL/TLS Certificaten](#ssltls-certificaten) +6. [Database & Cache](#database--cache) +7. [Session Management](#session-management) +8. [Monitoring & Logging](#monitoring--logging) +9. [Backup Strategie](#backup-strategie) +10. [Deployment Checklist](#deployment-checklist) + +--- + +## 🔒 Security Best Practices + +### 1. Environment Variabelen + +**ALTIJD** gebruik een `.env` bestand dat NIET in git staat: + +```bash +# .env (niet committen!) +JIRA_HOST=https://jira.zuyderland.nl +JIRA_SCHEMA_ID=your-schema-id +JIRA_AUTH_METHOD=oauth # of 'pat' +JIRA_OAUTH_CLIENT_ID=your-client-id +JIRA_OAUTH_CLIENT_SECRET=your-client-secret +JIRA_OAUTH_CALLBACK_URL=https://cmdb.zuyderland.nl/api/auth/callback +SESSION_SECRET= +ANTHROPIC_API_KEY=your-key +NODE_ENV=production +PORT=3001 +FRONTEND_URL=https://cmdb.zuyderland.nl +JIRA_API_BATCH_SIZE=15 +``` + +**Genereer een veilige SESSION_SECRET:** +```bash +openssl rand -hex 32 +``` + +### 2. Secrets Management + +Gebruik een secrets management systeem: +- **Kubernetes**: Secrets +- **Docker Swarm**: Docker Secrets +- **Cloud**: AWS Secrets Manager, Azure Key Vault, Google Secret Manager +- **On-premise**: HashiCorp Vault + +### 3. Network Security + +- **Firewall**: Alleen poorten 80/443 open voor reverse proxy +- **Internal Network**: Backend alleen bereikbaar via reverse proxy +- **VPN**: Overweeg VPN voor interne toegang + +--- + +## 🐳 Production Docker Setup + +### Backend Production Dockerfile + +Maak `backend/Dockerfile.prod`: + +```dockerfile +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy source +COPY . . + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install only production dependencies +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Copy built files +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/src/generated ./src/generated + +# Create data directory with proper permissions +RUN mkdir -p /app/data && chown -R node:node /app/data + +# Switch to non-root user +USER node + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start production server +CMD ["node", "dist/index.js"] +``` + +### Frontend Production Dockerfile + +Maak `frontend/Dockerfile.prod`: + +```dockerfile +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npm run build + +# Production stage with nginx +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] +``` + +### Frontend Nginx Config + +Maak `frontend/nginx.conf`: + +```nginx +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API proxy + location /api { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +### Production Docker Compose + +Maak `docker-compose.prod.yml`: + +```yaml +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + environment: + - NODE_ENV=production + - PORT=3001 + env_file: + - .env.production + volumes: + - backend_data:/app/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + depends_on: + - backend + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_cache:/var/cache/nginx + depends_on: + - frontend + - backend + restart: unless-stopped + networks: + - internal + +volumes: + backend_data: + nginx_cache: + +networks: + internal: + driver: bridge +``` + +--- + +## 🌐 Reverse Proxy (Nginx) + +### Main Nginx Config + +Maak `nginx/nginx.conf`: + +```nginx +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 10M; + + # Gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m; + limit_req_zone $binary_remote_addr zone=general_limit:10m rate=200r/m; + + # SSL Configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Upstream backend + upstream backend { + server backend:3001; + keepalive 32; + } + + # Upstream frontend + upstream frontend { + server frontend:80; + } + + # HTTP to HTTPS redirect + server { + listen 80; + server_name cmdb.zuyderland.nl; + return 301 https://$server_name$request_uri; + } + + # HTTPS server + server { + listen 443 ssl http2; + server_name cmdb.zuyderland.nl; + + # SSL certificates + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://jira.zuyderland.nl;" always; + + # API routes with rate limiting + location /api { + limit_req zone=api_limit burst=20 nodelay; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_cache_bypass $http_upgrade; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 300s; + } + + # Frontend + location / { + limit_req zone=general_limit burst=50 nodelay; + + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Health check (no rate limit) + location /health { + access_log off; + proxy_pass http://backend/health; + } + } +} +``` + +--- + +## 🔐 SSL/TLS Certificaten + +### Let's Encrypt (Gratis) + +```bash +# Install certbot +sudo apt-get update +sudo apt-get install certbot + +# Generate certificate +sudo certbot certonly --standalone -d cmdb.zuyderland.nl + +# Certificates worden opgeslagen in: +# /etc/letsencrypt/live/cmdb.zuyderland.nl/fullchain.pem +# /etc/letsencrypt/live/cmdb.zuyderland.nl/privkey.pem + +# Auto-renewal +sudo certbot renew --dry-run +``` + +### Certificaten kopiëren naar Docker volume: + +```bash +# Maak directory +mkdir -p nginx/ssl + +# Kopieer certificaten (of gebruik bind mount) +cp /etc/letsencrypt/live/cmdb.zuyderland.nl/fullchain.pem nginx/ssl/ +cp /etc/letsencrypt/live/cmdb.zuyderland.nl/privkey.pem nginx/ssl/ +``` + +--- + +## 💾 Database & Cache + +### SQLite Cache Database + +De cache database wordt opgeslagen in `/app/data/cmdb-cache.db`. + +**Backup strategie:** +```bash +# Dagelijkse backup script +#!/bin/bash +BACKUP_DIR="/backups/cmdb" +DATE=$(date +%Y%m%d_%H%M%S) +docker exec cmdb-gui_backend_1 sqlite3 /app/data/cmdb-cache.db ".backup '$BACKUP_DIR/cmdb-cache-$DATE.db'" +# Bewaar laatste 30 dagen +find $BACKUP_DIR -name "cmdb-cache-*.db" -mtime +30 -delete +``` + +**Volume backup:** +```bash +# Backup hele volume +docker run --rm -v cmdb-gui_backend_data:/data -v $(pwd)/backups:/backup \ + alpine tar czf /backup/backend-data-$(date +%Y%m%d).tar.gz /data +``` + +--- + +## 🔑 Session Management + +### Huidige Implementatie + +De applicatie gebruikt momenteel in-memory session storage. Voor productie is dit **NIET geschikt**. + +### Aanbeveling: Redis voor Sessions + +1. **Voeg Redis toe aan docker-compose.prod.yml:** + +```yaml + redis: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + restart: unless-stopped + networks: + - internal + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval: 30s + timeout: 3s + retries: 3 + +volumes: + redis_data: +``` + +2. **Update authService.ts om Redis te gebruiken:** + +```typescript +import Redis from 'ioredis'; + +const redis = new Redis({ + host: process.env.REDIS_HOST || 'redis', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, +}); + +// Vervang sessionStore met Redis calls +async function setSession(sessionId: string, session: UserSession): Promise { + const ttl = Math.floor((session.expiresAt - Date.now()) / 1000); + await redis.setex(`session:${sessionId}`, ttl, JSON.stringify(session)); +} + +async function getSession(sessionId: string): Promise { + const data = await redis.get(`session:${sessionId}`); + return data ? JSON.parse(data) : null; +} +``` + +--- + +## 📊 Monitoring & Logging + +### 1. Logging + +De applicatie gebruikt Winston. Configureer log rotation: + +```typescript +// backend/src/services/logger.ts +import winston from 'winston'; +import DailyRotateFile from 'winston-daily-rotate-file'; + +export const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + transports: [ + new DailyRotateFile({ + filename: 'logs/application-%DATE%.log', + datePattern: 'YYYY-MM-DD', + maxSize: '20m', + maxFiles: '14d', + }), + new DailyRotateFile({ + filename: 'logs/error-%DATE%.log', + datePattern: 'YYYY-MM-DD', + level: 'error', + maxSize: '20m', + maxFiles: '30d', + }), + ], +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.simple() + })); +} +``` + +### 2. Health Checks + +De applicatie heeft al een `/health` endpoint. Monitor dit: + +```bash +# Monitoring script +#!/bin/bash +HEALTH_URL="https://cmdb.zuyderland.nl/health" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL) + +if [ $STATUS -ne 200 ]; then + # Alert via email/Slack/etc + echo "Health check failed: $STATUS" + exit 1 +fi +``` + +### 3. Application Monitoring + +Overweeg: +- **Prometheus + Grafana**: Metrics en dashboards +- **Sentry**: Error tracking +- **New Relic / Datadog**: APM (Application Performance Monitoring) + +--- + +## 💾 Backup Strategie + +### 1. Database Backups + +```bash +#!/bin/bash +# backup.sh - Run dagelijks via cron + +BACKUP_DIR="/backups/cmdb" +DATE=$(date +%Y%m%d_%H%M%S) + +# Backup SQLite database +docker exec cmdb-gui_backend_1 sqlite3 /app/data/cmdb-cache.db \ + ".backup '$BACKUP_DIR/cmdb-cache-$DATE.db'" + +# Compress +gzip "$BACKUP_DIR/cmdb-cache-$DATE.db" + +# Upload naar remote storage (optioneel) +# aws s3 cp "$BACKUP_DIR/cmdb-cache-$DATE.db.gz" s3://your-backup-bucket/ + +# Cleanup oude backups (bewaar 30 dagen) +find $BACKUP_DIR -name "*.db.gz" -mtime +30 -delete +``` + +### 2. Configuration Backups + +Backup `.env.production` en andere configuratie bestanden naar een veilige locatie. + +--- + +## ✅ Deployment Checklist + +### Pre-Deployment + +- [ ] Alle environment variabelen geconfigureerd +- [ ] SESSION_SECRET gegenereerd (64+ karakters) +- [ ] SSL certificaten geconfigureerd +- [ ] Firewall regels ingesteld +- [ ] Reverse proxy geconfigureerd +- [ ] Health checks getest +- [ ] Backup strategie geïmplementeerd + +### Security + +- [ ] Alle secrets in environment variabelen (niet in code) +- [ ] HTTPS geforceerd (HTTP → HTTPS redirect) +- [ ] Security headers geconfigureerd (HSTS, CSP, etc.) +- [ ] Rate limiting geactiveerd +- [ ] CORS correct geconfigureerd +- [ ] Session cookies secure en httpOnly +- [ ] OAuth callback URL correct geconfigureerd + +### Performance + +- [ ] Production builds getest +- [ ] Docker images geoptimaliseerd (multi-stage builds) +- [ ] Caching geconfigureerd (nginx, browser) +- [ ] Gzip compression geactiveerd +- [ ] Database indexes gecontroleerd + +### Monitoring + +- [ ] Logging geconfigureerd met rotation +- [ ] Health checks geïmplementeerd +- [ ] Monitoring alerts ingesteld +- [ ] Error tracking geconfigureerd (optioneel) + +### Operations + +- [ ] Backup scripts getest +- [ ] Restore procedure gedocumenteerd +- [ ] Rollback procedure gedocumenteerd +- [ ] Deployment procedure gedocumenteerd +- [ ] Incident response plan + +--- + +## 🚀 Deployment Commands + +### Build en Start + +```bash +# Build production images +docker-compose -f docker-compose.prod.yml build + +# Start services +docker-compose -f docker-compose.prod.yml up -d + +# Check logs +docker-compose -f docker-compose.prod.yml logs -f + +# Check health +curl https://cmdb.zuyderland.nl/health +``` + +### Updates + +```bash +# Pull nieuwe code +git pull + +# Rebuild en restart +docker-compose -f docker-compose.prod.yml up -d --build + +# Zero-downtime deployment (met meerdere instances) +# Gebruik rolling updates of blue-green deployment +``` + +--- + +## 🔧 Aanvullende Aanbevelingen + +### 1. Container Orchestration + +Voor grotere deployments, overweeg: +- **Kubernetes**: Voor schaalbaarheid en high availability +- **Docker Swarm**: Simpelere alternatief voor Kubernetes + +### 2. Load Balancing + +Voor high availability, gebruik meerdere backend instances met load balancer. + +### 3. CDN + +Overweeg een CDN (CloudFlare, AWS CloudFront) voor statische assets. + +### 4. Database Scaling + +Voor grote datasets, overweeg: +- PostgreSQL in plaats van SQLite +- Database replicatie voor read scaling +- Connection pooling + +### 5. Caching Layer + +Overweeg Redis voor: +- Session storage +- API response caching +- Query result caching + +--- + +## 📞 Support & Troubleshooting + +### Veelvoorkomende Issues + +1. **Sessions verlopen te snel**: Check SESSION_SECRET en Redis configuratie +2. **CORS errors**: Check FRONTEND_URL en CORS configuratie +3. **Database locks**: SQLite is niet geschikt voor hoge concurrency +4. **Memory issues**: Monitor container memory usage + +### Logs Bekijken + +```bash +# Backend logs +docker-compose -f docker-compose.prod.yml logs backend + +# Frontend logs +docker-compose -f docker-compose.prod.yml logs frontend + +# Nginx logs +docker-compose -f docker-compose.prod.yml logs nginx +``` + +--- + +## 📚 Referenties + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/) +- [Docker Security Best Practices](https://docs.docker.com/engine/security/) +- [Nginx Security Guide](https://nginx.org/en/docs/http/configuring_https_servers.html) diff --git a/zira-classificatie-tool-specificatie.md b/docs/zira-classificatie-tool-specificatie.md similarity index 100% rename from zira-classificatie-tool-specificatie.md rename to docs/zira-classificatie-tool-specificatie.md diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..6424b79 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,29 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci + +# Copy source and build +COPY . . +RUN npm run build + +# Production stage with nginx +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c699b55 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API proxy + location /api { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json index 885c43f..9fe1226 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", + "recharts": "^3.6.0", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec65730..55339c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,16 @@ import ConfigurationV25 from './components/ConfigurationV25'; import ReportsDashboard from './components/ReportsDashboard'; import GovernanceAnalysis from './components/GovernanceAnalysis'; import DataModelDashboard from './components/DataModelDashboard'; +import TechnicalDebtHeatmap from './components/TechnicalDebtHeatmap'; +import LifecyclePipeline from './components/LifecyclePipeline'; +import DataCompletenessScore from './components/DataCompletenessScore'; +import ZiRADomainCoverage from './components/ZiRADomainCoverage'; +import FTEPerZiRADomain from './components/FTEPerZiRADomain'; +import ComplexityDynamicsBubbleChart from './components/ComplexityDynamicsBubbleChart'; import FTECalculator from './components/FTECalculator'; +import DataCompletenessConfig from './components/DataCompletenessConfig'; +import BIASyncDashboard from './components/BIASyncDashboard'; +import BusinessImportanceComparison from './components/BusinessImportanceComparison'; import Login from './components/Login'; import { useAuthStore } from './stores/authStore'; @@ -190,7 +199,6 @@ function AppContent() { { path: '/app-components', label: 'Dashboard', exact: true }, { path: '/application/overview', label: 'Overzicht', exact: false }, { path: '/application/fte-calculator', label: 'FTE Calculator', exact: true }, - { path: '/app-components/fte-config', label: 'FTE Config', exact: true }, ], }; @@ -201,12 +209,38 @@ function AppContent() { { path: '/reports', label: 'Overzicht', exact: true }, { path: '/reports/team-dashboard', label: 'Team-indeling', exact: true }, { path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true }, - { path: '/reports/data-model', label: 'Datamodel', exact: true }, + { path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true }, + { path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true }, + { path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true }, + { path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true }, + { path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true }, + { path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true }, + { path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true }, + ], + }; + + const appsDropdown: NavDropdown = { + label: 'Apps', + basePath: '/apps', + items: [ + { path: '/apps/bia-sync', label: 'BIA Sync', exact: true }, + ], + }; + + const settingsDropdown: NavDropdown = { + label: 'Instellingen', + basePath: '/settings', + items: [ + { path: '/settings/fte-config', label: 'FTE Config', exact: true }, + { path: '/settings/data-model', label: 'Datamodel', exact: true }, + { path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true }, ], }; const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application'); const isReportsActive = location.pathname.startsWith('/reports'); + const isSettingsActive = location.pathname.startsWith('/settings'); + const isAppsActive = location.pathname.startsWith('/apps'); const isDashboardActive = location.pathname === '/'; return ( @@ -243,8 +277,14 @@ function AppContent() { {/* Application Component Dropdown */} + {/* Apps Dropdown */} + + {/* Reports Dropdown */} + + {/* Settings Dropdown */} + @@ -267,21 +307,37 @@ function AppContent() { {/* Application Component routes */} } /> - } /> {/* Reports routes */} } /> } /> } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Apps routes */} + } /> + + {/* Settings routes */} + } /> + } /> + } /> {/* Legacy redirects for bookmarks - redirect old paths to new ones */} } /> } /> + } /> } /> } /> + } /> + } /> } /> - } /> + } /> diff --git a/frontend/src/components/ApplicationInfo.tsx b/frontend/src/components/ApplicationInfo.tsx index 9fe4da3..9737121 100644 --- a/frontend/src/components/ApplicationInfo.tsx +++ b/frontend/src/components/ApplicationInfo.tsx @@ -140,7 +140,7 @@ export default function ApplicationInfo() { // Related objects state const [relatedObjects, setRelatedObjects] = useState>(new Map()); - const [expandedSections, setExpandedSections] = useState>(new Set(['Server', 'Certificate'])); // Default expanded + const [expandedSections, setExpandedSections] = useState>(new Set()); // Default collapsed useEffect(() => { async function fetchData() { @@ -365,6 +365,30 @@ export default function ApplicationInfo() { + {application.dataCompletenessPercentage !== undefined && ( +
+ +
+
+
+
= 80 + ? 'bg-green-500' + : application.dataCompletenessPercentage >= 60 + ? 'bg-yellow-500' + : 'bg-red-500' + }`} + style={{ width: `${application.dataCompletenessPercentage}%` }} + /> +
+
+ + {application.dataCompletenessPercentage.toFixed(1)}% + +
+
+ )}
@@ -463,7 +487,6 @@ export default function ApplicationInfo() { : 'bg-gray-100 text-gray-700' )} > - {func.key} {func.name} ))} diff --git a/frontend/src/components/ApplicationList.tsx b/frontend/src/components/ApplicationList.tsx index 4ab0ba9..3df6eb1 100644 --- a/frontend/src/components/ApplicationList.tsx +++ b/frontend/src/components/ApplicationList.tsx @@ -394,6 +394,9 @@ export default function ApplicationList() { Benodigde inspanning + + Data Completeness + @@ -489,6 +492,37 @@ export default function ApplicationList() { )} + + handleRowClick(app, index, e)} + className="block px-4 py-3" + > + {app.dataCompletenessPercentage !== undefined ? ( +
+
+
+
= 80 + ? 'bg-green-500' + : app.dataCompletenessPercentage >= 60 + ? 'bg-yellow-500' + : 'bg-red-500' + }`} + style={{ width: `${app.dataCompletenessPercentage}%` }} + /> +
+
+ + {app.dataCompletenessPercentage.toFixed(0)}% + +
+ ) : ( + - + )} + + ))} diff --git a/frontend/src/components/BIASyncDashboard.tsx b/frontend/src/components/BIASyncDashboard.tsx new file mode 100644 index 0000000..593a073 --- /dev/null +++ b/frontend/src/components/BIASyncDashboard.tsx @@ -0,0 +1,615 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { Link } from 'react-router-dom'; +import { clsx } from 'clsx'; +import { getBIAComparison, updateApplication, getBusinessImpactAnalyses } from '../services/api'; +import type { BIAComparisonItem, BIAComparisonResponse, ReferenceValue } from '../types'; + +type MatchStatusFilter = 'all' | 'match' | 'mismatch' | 'not_found' | 'no_excel_bia'; +type MatchTypeFilter = 'all' | 'exact' | 'search_reference' | 'fuzzy' | 'none'; + +export default function BIASyncDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchText, setSearchText] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [matchTypeFilter, setMatchTypeFilter] = useState('all'); + const [savingIds, setSavingIds] = useState>(new Set()); + const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState([]); + const [expandedMatches, setExpandedMatches] = useState>(new Set()); + const [isHeaderFixed, setIsHeaderFixed] = useState(false); + const [headerHeight, setHeaderHeight] = useState(0); + const tableRef = useRef(null); + const theadRef = useRef(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [comparisonData, biaData] = await Promise.all([ + getBIAComparison(), + getBusinessImpactAnalyses(), + ]); + setData(comparisonData); + setBusinessImpactAnalyses(biaData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load BIA comparison'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Make header sticky on scroll + useEffect(() => { + if (!theadRef.current || !tableRef.current || !data) return; + + const tableContainer = tableRef.current.closest('.overflow-x-auto'); + if (!tableContainer) return; + + const handleScroll = () => { + const tableRect = tableRef.current!.getBoundingClientRect(); + const containerRect = (tableContainer as HTMLElement).getBoundingClientRect(); + + // When table header scrolls out of view, make it fixed at the very top + if (tableRect.top < 0 && tableRect.bottom > 50) { + // Only apply fixed positioning if not already fixed (to avoid recalculating widths) + if (!isHeaderFixed) { + // Get all header cells and body cells to match widths + const headerCells = theadRef.current!.querySelectorAll('th'); + const firstBodyRow = tableRef.current!.querySelector('tbody tr'); + const bodyCells = firstBodyRow?.querySelectorAll('td') || []; + + if (headerCells.length > 0) { + // Store header height if not already stored + if (headerHeight === 0) { + const currentHeight = theadRef.current!.getBoundingClientRect().height; + setHeaderHeight(currentHeight); + } + + // Measure column widths BEFORE making header fixed + const columnWidths: number[] = []; + headerCells.forEach((headerCell, index) => { + let width: number; + if (bodyCells[index]) { + width = bodyCells[index].getBoundingClientRect().width; + } else { + width = headerCell.getBoundingClientRect().width; + } + columnWidths.push(width); + }); + + // Use table-layout: fixed to lock column widths + tableRef.current!.style.tableLayout = 'fixed'; + + // Apply measured widths to header cells + headerCells.forEach((headerCell, index) => { + const width = columnWidths[index]; + (headerCell as HTMLElement).style.width = `${width}px`; + (headerCell as HTMLElement).style.minWidth = `${width}px`; + (headerCell as HTMLElement).style.maxWidth = `${width}px`; + }); + } + } + + // Update position and dimensions + theadRef.current!.style.position = 'fixed'; + theadRef.current!.style.top = '0px'; + theadRef.current!.style.left = `${containerRect.left}px`; + theadRef.current!.style.width = `${containerRect.width}px`; + theadRef.current!.style.zIndex = '50'; + theadRef.current!.style.backgroundColor = '#f9fafb'; + theadRef.current!.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1)'; + setIsHeaderFixed(true); + } else { + // Reset table layout and column widths when not fixed + if (isHeaderFixed) { + tableRef.current!.style.tableLayout = ''; + + const headerCells = theadRef.current!.querySelectorAll('th'); + headerCells.forEach((cell) => { + (cell as HTMLElement).style.width = ''; + (cell as HTMLElement).style.minWidth = ''; + (cell as HTMLElement).style.maxWidth = ''; + }); + } + + theadRef.current!.style.position = ''; + theadRef.current!.style.top = ''; + theadRef.current!.style.left = ''; + theadRef.current!.style.width = ''; + theadRef.current!.style.zIndex = ''; + theadRef.current!.style.backgroundColor = ''; + theadRef.current!.style.boxShadow = ''; + setIsHeaderFixed(false); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleScroll, { passive: true }); + if (tableContainer) { + tableContainer.addEventListener('scroll', handleScroll, { passive: true }); + } + handleScroll(); // Check initial position + + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleScroll); + if (tableContainer) { + tableContainer.removeEventListener('scroll', handleScroll); + } + }; + }, [data]); + + + const handleSave = async (item: BIAComparisonItem) => { + if (!item.excelBIA) { + return; + } + + // Find the ReferenceValue for the Excel BIA letter + // Try to match by first character or by name pattern like "A - ..." + const biaValue = businessImpactAnalyses.find(bia => { + // Try matching first character + const firstChar = bia.name.charAt(0).toUpperCase(); + if (firstChar === item.excelBIA && /^[A-F]$/.test(firstChar)) { + return true; + } + // Try matching pattern like "A - ..." + const match = bia.name.match(/^([A-F])/); + if (match && match[1].toUpperCase() === item.excelBIA) { + return true; + } + return false; + }); + + if (!biaValue) { + alert(`Kon geen Business Impact Analyse vinden voor waarde "${item.excelBIA}"`); + return; + } + + if (!confirm(`Weet je zeker dat je de BIA waarde voor "${item.name}" wilt bijwerken naar "${biaValue.name}"?`)) { + return; + } + + setSavingIds(prev => new Set(prev).add(item.id)); + + try { + await updateApplication(item.id, { + businessImpactAnalyse: biaValue, + source: 'MANUAL', + }); + + // Update only the specific item in the state instead of reloading everything + if (data) { + setData(prevData => { + if (!prevData) return prevData; + + const updatedApplications = prevData.applications.map(app => { + if (app.id === item.id) { + // Update the current BIA and match status + const newMatchStatus: BIAComparisonItem['matchStatus'] = + app.excelBIA === item.excelBIA ? 'match' : 'mismatch'; + + return { + ...app, + currentBIA: biaValue, + matchStatus: newMatchStatus, + }; + } + return app; + }); + + // Recalculate summary + const summary = { + total: updatedApplications.length, + matched: updatedApplications.filter(a => a.matchStatus === 'match').length, + mismatched: updatedApplications.filter(a => a.matchStatus === 'mismatch').length, + notFound: updatedApplications.filter(a => a.matchStatus === 'not_found').length, + noExcelBIA: updatedApplications.filter(a => a.matchStatus === 'no_excel_bia').length, + }; + + return { + applications: updatedApplications, + summary, + }; + }); + } + } catch (err) { + alert(`Fout bij bijwerken: ${err instanceof Error ? err.message : 'Onbekende fout'}`); + } finally { + setSavingIds(prev => { + const next = new Set(prev); + next.delete(item.id); + return next; + }); + } + }; + + // Filter applications + const filteredApplications = data?.applications.filter(app => { + // Search filter + if (searchText) { + const searchLower = searchText.toLowerCase(); + const matchesSearch = + app.name.toLowerCase().includes(searchLower) || + app.key.toLowerCase().includes(searchLower) || + (app.searchReference && app.searchReference.toLowerCase().includes(searchLower)); + if (!matchesSearch) return false; + } + + // Status filter + if (statusFilter !== 'all' && app.matchStatus !== statusFilter) { + return false; + } + + // Match type filter + if (matchTypeFilter !== 'all') { + if (matchTypeFilter === 'none' && app.matchType !== null) { + return false; + } + if (matchTypeFilter !== 'none' && app.matchType !== matchTypeFilter) { + return false; + } + } + + return true; + }) || []; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return null; + } + + const getStatusBadgeClass = (status: BIAComparisonItem['matchStatus']) => { + switch (status) { + case 'match': + return 'bg-green-100 text-green-800'; + case 'mismatch': + return 'bg-red-100 text-red-800'; + case 'not_found': + return 'bg-yellow-100 text-yellow-800'; + case 'no_excel_bia': + return 'bg-gray-100 text-gray-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getStatusLabel = (status: BIAComparisonItem['matchStatus']) => { + switch (status) { + case 'match': + return 'Match'; + case 'mismatch': + return 'Verschil'; + case 'not_found': + return 'Niet in CMDB'; + case 'no_excel_bia': + return 'Niet in Excel'; + default: + return status; + } + }; + + const getMatchTypeLabel = (matchType: BIAComparisonItem['matchType']) => { + switch (matchType) { + case 'exact': + return 'Exact'; + case 'search_reference': + return 'Zoekreferentie'; + case 'fuzzy': + return 'Fuzzy'; + default: + return '-'; + } + }; + + return ( +
+ {/* Page header */} +
+

BIA Sync Dashboard

+

+ Vergelijk Business Impact Analyse waarden uit Excel met de CMDB +

+
+ + {/* Summary cards */} +
+
+
Totaal
+
{data.summary.total}
+
+
+
Match
+
{data.summary.matched}
+
+
+
Verschil
+
{data.summary.mismatched}
+
+
+
Niet in CMDB
+
{data.summary.notFound}
+
+
+
Niet in Excel
+
{data.summary.noExcelBIA}
+
+
+ + {/* Filters */} +
+
+
+ setSearchText(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+
+ +
+ +
+
+ + {/* Table */} +
+
+ {/* Placeholder spacer when header is fixed */} + {isHeaderFixed && headerHeight > 0 && ( +
+ )} + + + + + + + + + + + + + {filteredApplications.length === 0 ? ( + + + + ) : ( + filteredApplications.map((item) => ( + + + + + + + + + )) + )} + +
+ Applicatie + + BIA (CMDB) + + BIA (Excel) + + Status + + Match Type + + Actie +
+ Geen applicaties gevonden +
+
+
+ e.stopPropagation()} + > + {item.name} + +
+
+ {item.key} + {item.searchReference && ` • ${item.searchReference}`} +
+ {item.excelApplicationName && item.excelApplicationName !== item.name && ( +
+ Excel: {item.excelApplicationName} +
+ )} + {item.allMatches && item.allMatches.length > 1 && ( +
+ + {expandedMatches.has(item.id) && ( +
+
Alle gevonden matches:
+
    + {item.allMatches.map((match, idx) => ( +
  • + {idx + 1}. "{match.excelApplicationName}" → BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%) + {match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'} +
  • + ))} +
+
+ )} +
+ )} +
+
+
+ {item.currentBIA ? ( + {item.currentBIA.name} + ) : ( + - + )} +
+
+
+ {item.excelBIA ? ( + {item.excelBIA} + ) : ( + - + )} +
+
+ + {getStatusLabel(item.matchStatus)} + + +
+ {getMatchTypeLabel(item.matchType)} + {item.matchConfidence !== undefined && ( + + ({(item.matchConfidence * 100).toFixed(0)}%) + + )} +
+
+ {item.excelBIA && item.matchStatus !== 'match' && ( + + )} +
+
+
+ + {/* Results count */} +
+ {filteredApplications.length} van {data.applications.length} applicaties getoond +
+
+ ); +} diff --git a/frontend/src/components/BusinessImportanceComparison.tsx b/frontend/src/components/BusinessImportanceComparison.tsx new file mode 100644 index 0000000..551285f --- /dev/null +++ b/frontend/src/components/BusinessImportanceComparison.tsx @@ -0,0 +1,347 @@ +import { useEffect, useState, useMemo } from 'react'; +import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell, ReferenceLine } from 'recharts'; +import { getBusinessImportanceComparison } from '../services/api'; +import type { BusinessImportanceComparisonItem } from '../types'; +import { Link } from 'react-router-dom'; + +interface ScatterDataPoint { + x: number; // Business Importance (0-6) + y: number; // BIA Class (1-6) + name: string; + id: string; + key: string; + searchReference: string | null; + businessImportance: string | null; + biaClass: string | null; + discrepancyCategory: string; + discrepancyScore: number; +} + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload as ScatterDataPoint; + return ( +
+

{data.name}

+

{data.key}

+
+

Business Importance: {data.businessImportance || 'Niet ingevuld'}

+

BIA Class: {data.biaClass || 'Niet ingevuld'}

+

Discrepancy Score: {data.discrepancyScore}

+

Category: { + data.discrepancyCategory === 'high_bi_low_bia' ? 'High BI + Low BIA' : + data.discrepancyCategory === 'low_bi_high_bia' ? 'Low BI + High BIA' : + data.discrepancyCategory === 'aligned' ? 'Aligned' : + 'Missing Data' + }

+
+
+ ); + } + return null; +}; + +function getCategoryColor(category: string): string { + switch (category) { + case 'high_bi_low_bia': + return '#DC2626'; // red-600 - IT thinks critical, business doesn't + case 'low_bi_high_bia': + return '#F59E0B'; // amber-500 - Business thinks critical, IT doesn't + case 'aligned': + return '#10B981'; // emerald-500 - Aligned + case 'missing_data': + return '#94A3B8'; // slate-400 - Missing data + default: + return '#6B7280'; // gray-500 + } +} + +export default function BusinessImportanceComparison() { + const [data, setData] = useState([]); + const [summary, setSummary] = useState({ + total: 0, + withBothFields: 0, + highBiLowBia: 0, + lowBiHighBia: 0, + aligned: 0, + missingData: 0, + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedCategory, setSelectedCategory] = useState('all'); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + try { + const response = await getBusinessImportanceComparison(); + setData(response.applications); + setSummary(response.summary); + } catch (err) { + console.error('Error fetching comparison data:', err); + setError(err instanceof Error ? err.message : 'Failed to load comparison data'); + } finally { + setLoading(false); + } + } + fetchData(); + }, []); + + // Filter data based on selected category + const filteredData = useMemo(() => { + if (selectedCategory === 'all') return data; + return data.filter(item => item.discrepancyCategory === selectedCategory); + }, [data, selectedCategory]); + + // Transform data for scatter plot (only include items with both fields) + const scatterData = useMemo(() => { + return filteredData + .filter(item => item.businessImportanceNormalized !== null && item.biaClassNormalized !== null) + .map(item => ({ + x: item.businessImportanceNormalized!, + y: item.biaClassNormalized!, + name: item.name, + id: item.id, + key: item.key, + searchReference: item.searchReference, + businessImportance: item.businessImportance, + biaClass: item.biaClass, + discrepancyCategory: item.discrepancyCategory, + discrepancyScore: item.discrepancyScore, + })); + }, [filteredData]); + + // Get discrepancy items for table + const discrepancyItems = useMemo(() => { + return filteredData + .filter(item => item.discrepancyCategory === 'high_bi_low_bia' || item.discrepancyCategory === 'low_bi_high_bia') + .sort((a, b) => b.discrepancyScore - a.discrepancyScore); + }, [filteredData]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + return ( +
+ {/* Header */} +
+

Business Importance vs Business Impact Analysis

+

+ Vergelijking tussen IT-infrastructuur prioritering (Business Importance) en business owner beoordeling (Business Impact Analysis) +

+
+ + {/* Summary Statistics Cards */} +
+
+
Totaal
+
{summary.total}
+
+
+
Beide velden ingevuld
+
{summary.withBothFields}
+
+
+
High BI + Low BIA
+
{summary.highBiLowBia}
+
+
+
Low BI + High BIA
+
{summary.lowBiHighBia}
+
+
+
Aligned
+
{summary.aligned}
+
+
+
Missing Data
+
{summary.missingData}
+
+
+ + {/* Scatter Plot */} +
+

Scatter Plot

+
+ + +
+ {scatterData.length > 0 ? ( + + + + + + } /> + + {/* Reference lines for alignment zones */} + + + + + + {scatterData.map((entry, index) => ( + + ))} + + + + ) : ( +
+ Geen data beschikbaar voor de scatter plot. +
+ )} +
+
+
+ High BI + Low BIA (IT critical, business low) +
+
+
+ Low BI + High BIA (Business critical, IT low) +
+
+
+ Aligned +
+
+
+ Missing Data +
+
+
+ + {/* Discrepancy Table */} +
+
+

Discrepancies

+

+ Applicaties met grote verschillen tussen Business Importance en Business Impact Analysis +

+
+
+ + + + + + + + + + + + {discrepancyItems.length === 0 ? ( + + + + ) : ( + discrepancyItems.map((item) => ( + + + + + + + + )) + )} + +
+ Applicatie + + Business Importance + + BIA Class + + Discrepancy Score + + Categorie +
+ Geen discrepancies gevonden +
+ + {item.name} + + {item.searchReference && ( +
{item.searchReference}
+ )} +
+ {item.businessImportance || '-'} + {item.businessImportanceNormalized !== null && ( + ({item.businessImportanceNormalized}) + )} + + {item.biaClass || '-'} + {item.biaClassNormalized !== null && ( + ({item.biaClassNormalized}) + )} + + {item.discrepancyScore} + + + {item.discrepancyCategory === 'high_bi_low_bia' + ? 'High BI + Low BIA' + : item.discrepancyCategory === 'low_bi_high_bia' + ? 'Low BI + High BIA' + : item.discrepancyCategory} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ComplexityDynamicsBubbleChart.tsx b/frontend/src/components/ComplexityDynamicsBubbleChart.tsx new file mode 100644 index 0000000..78b4679 --- /dev/null +++ b/frontend/src/components/ComplexityDynamicsBubbleChart.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState } from 'react'; +import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'; +import { searchApplications } from '../services/api'; +import type { ApplicationListItem, ReferenceValue, ApplicationStatus } from '../types'; +import { Link } from 'react-router-dom'; + +const ALL_STATUSES: ApplicationStatus[] = [ + 'In Production', + 'Implementation', + 'Proof of Concept', + 'End of support', + 'End of life', + 'Deprecated', + 'Shadow IT', + 'Closed', + 'Undefined', +]; + +interface BubbleDataPoint { + x: number; // Complexity + y: number; // Dynamics + z: number; // FTE (size) + bia: string; // BIA name for color + biaId: string; // BIA ID for color mapping + name: string; // Application name + id: string; // Application ID + key: string; // Application key +} + +// Extract numeric value from Complexity/Dynamics ReferenceValue +// Try to extract from name (e.g., "1 - Stabiel" -> 1), or use order/factor +function getNumericValue(ref: ReferenceValue | null): number { + if (!ref) return 0; + + // First try order property + if (ref.order !== undefined) return ref.order; + + // Then try factor property + if (ref.factor !== undefined) return ref.factor; + + // Try to extract number from name (e.g., "1 - Stabiel" or "DYN-2" -> 2) + const nameMatch = ref.name.match(/(\d+)/); + if (nameMatch) return parseInt(nameMatch[1], 10); + + // Try to extract from key (e.g., "DYN-2" -> 2) + const keyMatch = ref.key.match(/-(\d+)$/); + if (keyMatch) return parseInt(keyMatch[1], 10); + + return 0; +} + +// Color mapping for BIA values +const BIA_COLORS: Record = { + 'Critical': '#DC2626', // red-600 + 'High': '#F59E0B', // amber-500 + 'Medium': '#10B981', // emerald-500 + 'Low': '#3B82F6', // blue-500 + 'Kritiek': '#DC2626', + 'Hoog': '#F59E0B', + 'Gemiddeld': '#10B981', + 'Laag': '#3B82F6', +}; + +function getBIAColor(biaName: string | null): string { + if (!biaName) return '#94A3B8'; // slate-400 for no BIA + return BIA_COLORS[biaName] || '#6B7280'; // gray-500 default +} + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload as BubbleDataPoint; + return ( +
+

{data.name}

+

{data.key}

+
+

Complexity: {data.x}

+

Dynamics: {data.y}

+

FTE: {data.z.toFixed(2)}

+

BIA: {data.bia || 'Niet ingevuld'}

+
+
+ ); + } + return null; +}; + +export default function ComplexityDynamicsBubbleChart() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [excludedStatuses, setExcludedStatuses] = useState(['Closed', 'Deprecated']); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + try { + // Fetch all applications + const result = await searchApplications({}, 1, 10000); + + // Transform applications to bubble chart data + const bubbleData: BubbleDataPoint[] = result.applications + .filter(app => { + // Filter out excluded statuses + if (!app.status) return false; + return !excludedStatuses.includes(app.status); + }) + .map(app => { + const complexity = getNumericValue(app.complexityFactor); + const dynamics = getNumericValue(app.dynamicsFactor); + // Use overrideFTE if available, otherwise use calculated FTE + const fte = app.overrideFTE ?? app.requiredEffortApplicationManagement ?? 0; + + return { + x: complexity, + y: dynamics, + z: Math.max(0.1, fte), // Minimum size for visibility + bia: app.businessImpactAnalyse?.name || null, + biaId: app.businessImpactAnalyse?.objectId || 'none', + name: app.name, + id: app.id, + key: app.key, + }; + }) + .filter(point => point.x > 0 && point.y > 0); // Only include apps with both complexity and dynamics + + setData(bubbleData); + } catch (err) { + console.error('Error fetching bubble chart data:', err); + setError(err instanceof Error ? err.message : 'Failed to load bubble chart data'); + } finally { + setLoading(false); + } + } + fetchData(); + }, [excludedStatuses]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (data.length === 0) { + return ( +
+ Geen data beschikbaar voor de bubble chart. +
+ ); + } + + // Get unique BIA values for legend + const uniqueBIAs = Array.from(new Set(data.map(d => d.bia).filter(Boolean))).sort(); + + // Find max values for axis scaling + const maxComplexity = Math.max(...data.map(d => d.x), 1); + const maxDynamics = Math.max(...data.map(d => d.y), 1); + + return ( +
+ {/* Header */} +
+

Complexity vs Dynamics Bubble Chart

+

+ X-as: Complexity, Y-as: Dynamics, Grootte: FTE, Kleur: BIA +

+
+ + {/* Filters */} +
+
+ Uitgesloten statussen +
+
+ {ALL_STATUSES.map(status => { + const isExcluded = excludedStatuses.includes(status); + + return ( + + ); + })} +
+
+ + {/* Bubble Chart */} +
+ + + + i)} + /> + i)} + /> + + } /> + + {data.map((entry, index) => ( + + ))} + + + + + {/* Legend for BIA colors */} +
+

BIA Legenda:

+
+ {uniqueBIAs.map(bia => ( +
+
+ {bia} +
+ ))} +
+
+ Niet ingevuld +
+
+
+
+ + {/* Data Table */} +
+

+ Applicaties ({data.length}) +

+
+ + + + + + + + + + + + {[...data] + .sort((a, b) => b.z - a.z) // Sort by FTE descending + .slice(0, 50) // Show top 50 + .map((point) => ( + + + + + + + + ))} + +
+ Applicatie + + Complexity + + Dynamics + + FTE + + BIA +
+ + {point.name} + +
{point.key}
+
+ {point.x} + + {point.y} + + {point.z.toFixed(2)} + +
+ {point.bia || 'Niet ingevuld'} +
+
+ {data.length > 50 && ( +
+ Toont top 50 van {data.length} applicaties (gesorteerd op FTE) +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 21159aa..06cbf9b 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api'; import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types'; +import GovernanceModelBadge from './GovernanceModelBadge'; // Extended type to include stale indicator from API interface DashboardStatsWithMeta extends DashboardStats { @@ -13,35 +14,9 @@ export default function Dashboard() { const [stats, setStats] = useState(null); const [recentClassifications, setRecentClassifications] = useState([]); const [governanceModels, setGovernanceModels] = useState([]); - const [hoveredGovModel, setHoveredGovModel] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); - const hoverTimeoutRef = useRef(null); - - // Hover handlers with delayed hide to prevent flickering when moving between badges - const handleGovModelMouseEnter = useCallback((hoverKey: string) => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - hoverTimeoutRef.current = null; - } - setHoveredGovModel(hoverKey); - }, []); - - const handleGovModelMouseLeave = useCallback(() => { - hoverTimeoutRef.current = setTimeout(() => { - setHoveredGovModel(null); - }, 100); // Small delay to allow moving to another badge - }, []); - - // Cleanup timeout on unmount - useEffect(() => { - return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }; - }, []); const fetchData = useCallback(async (forceRefresh: boolean = false) => { if (forceRefresh) { @@ -144,7 +119,7 @@ export default function Dashboard() {
Totaal applicaties
- {stats?.totalApplications || 0} + {stats?.totalAllApplications || stats?.totalApplications || 0}
@@ -248,12 +223,11 @@ export default function Dashboard() {
{/* Governance model distribution */} -
-

+
+

Verdeling per regiemodel -

-
+
{stats?.byGovernanceModel && [ ...governanceModels @@ -261,94 +235,33 @@ export default function Dashboard() { .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), 'Niet ingesteld' ] - .filter(govModel => stats.byGovernanceModel[govModel] !== undefined || govModel === 'Niet ingesteld') .map((govModel) => { const count = stats.byGovernanceModel[govModel] || 0; - const colors = (() => { - if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' }; - if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' }; - if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' }; - if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' }; - if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' }; - if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' }; - if (govModel === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' }; - return { bg: '#6B7280', text: '#FFFFFF' }; - })(); - const shortLabel = govModel === 'Niet ingesteld' - ? '?' - : (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0)); const govModelData = governanceModels.find(g => g.name === govModel); - const isHovered = hoveredGovModel === govModel; - return ( -
handleGovModelMouseEnter(govModel)} - onMouseLeave={handleGovModelMouseLeave} - > -
- {shortLabel} +
+
+ + {govModel}
-
- {count} -
- - {/* Hover popup */} - {isHovered && govModel !== 'Niet ingesteld' && ( -
- {/* Arrow pointer */} -
+
+
- - {/* Header: Summary (Description) */} -
- {govModelData?.summary || govModel} - {govModelData?.description && ( - ({govModelData.description}) - )} -
- - {/* Remarks */} - {govModelData?.remarks && ( -
- {govModelData.remarks} -
- )} - - {/* Application section */} - {govModelData?.application && ( -
-
- Toepassing -
-
- {govModelData.application} -
-
- )} - - {/* Fallback message if no data */} - {!govModelData && ( -
- Geen aanvullende informatie beschikbaar -
- )}
- )} + + {count} + +
); })} diff --git a/frontend/src/components/DataCompletenessConfig.tsx b/frontend/src/components/DataCompletenessConfig.tsx new file mode 100644 index 0000000..e59cfd7 --- /dev/null +++ b/frontend/src/components/DataCompletenessConfig.tsx @@ -0,0 +1,821 @@ +import { useState, useEffect } from 'react'; +import { + getDataCompletenessConfig, + updateDataCompletenessConfig, + getSchema, + type DataCompletenessConfig, + type CompletenessFieldConfig, + type CompletenessCategoryConfig, + type SchemaResponse, +} from '../services/api'; + +// Mapping from schema fieldName to ApplicationDetails field path +// Some fields have different names in ApplicationDetails vs schema +const FIELD_NAME_TO_PATH_MAP: Record = { + // Direct mappings + 'organisation': 'organisation', + 'status': 'status', + 'businessImpactAnalyse': 'businessImpactAnalyse', + 'supplierProduct': 'supplierProduct', + 'businessOwner': 'businessOwner', + 'systemOwner': 'systemOwner', + 'functionalApplicationManagement': 'functionalApplicationManagement', + 'technicalApplicationManagement': 'technicalApplicationManagement', + 'technicalApplicationManagementPrimary': 'technicalApplicationManagementPrimary', + 'technicalApplicationManagementSecondary': 'technicalApplicationManagementSecondary', + 'description': 'description', + 'searchReference': 'searchReference', + 'businessImportance': 'businessImportance', + 'applicationManagementHosting': 'applicationManagementHosting', + 'applicationManagementTAM': 'applicationManagementTAM', + 'platform': 'platform', + + // Different names (schema fieldName -> ApplicationDetails property) + 'applicationFunction': 'applicationFunctions', // Note: plural in ApplicationDetails + 'applicationComponentHostingType': 'hostingType', + 'ictGovernanceModel': 'governanceModel', + 'applicationManagementApplicationType': 'applicationType', + 'applicationManagementDynamicsFactor': 'dynamicsFactor', + 'applicationManagementComplexityFactor': 'complexityFactor', + 'applicationManagementNumberOfUsers': 'numberOfUsers', + 'applicationManagementSubteam': 'applicationSubteam', + 'applicationManagementOverrideFTE': 'overrideFTE', + 'technischeArchitectuurTA': 'technischeArchitectuur', + + // Note: applicationTeam is not directly on ApplicationComponent, it's looked up via subteam +}; + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +export default function DataCompletenessConfig() { + const [config, setConfig] = useState(null); + const [schema, setSchema] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [editingCategory, setEditingCategory] = useState(null); + const [editingCategoryName, setEditingCategoryName] = useState(''); + const [editingCategoryDescription, setEditingCategoryDescription] = useState(''); + const [addingFieldToCategory, setAddingFieldToCategory] = useState(null); + const [newFieldName, setNewFieldName] = useState(''); + const [newFieldPath, setNewFieldPath] = useState(''); + const [newCategoryName, setNewCategoryName] = useState(''); + const [newCategoryDescription, setNewCategoryDescription] = useState(''); + const [availableFields, setAvailableFields] = useState>([]); + const [draggedField, setDraggedField] = useState<{ categoryId: string; fieldIndex: number; field: CompletenessFieldConfig } | null>(null); + const [draggedCategory, setDraggedCategory] = useState<{ categoryId: string; categoryIndex: number } | null>(null); + + useEffect(() => { + loadConfig(); + loadSchema(); + }, []); + + const loadSchema = async () => { + try { + const schemaData = await getSchema(); + setSchema(schemaData); + + // Extract fields from ApplicationComponent object type + const applicationComponentType = schemaData.objectTypes['ApplicationComponent']; + if (applicationComponentType) { + const fields = applicationComponentType.attributes + .filter(attr => { + // Include editable attributes or non-system attributes + return attr.isEditable || !attr.isSystem; + }) + .map(attr => { + // Map schema fieldName to ApplicationDetails field path + const fieldPath = FIELD_NAME_TO_PATH_MAP[attr.fieldName] || attr.fieldName; + return { + name: attr.name, + fieldPath: fieldPath, + type: attr.type, + description: attr.description, + }; + }) + // Remove duplicates and sort by name + .filter((field, index, self) => + index === self.findIndex(f => f.fieldPath === field.fieldPath) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + setAvailableFields(fields.map(f => ({ name: f.name, fieldPath: f.fieldPath }))); + } + } catch (err) { + console.error('Failed to load schema:', err); + // Continue without schema - user can still enter custom fields + } + }; + + const loadConfig = async () => { + try { + setLoading(true); + setError(null); + const data = await getDataCompletenessConfig(); + setConfig(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load configuration'); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!config) return; + + try { + setSaving(true); + setError(null); + setSuccess(null); + await updateDataCompletenessConfig(config); + setSuccess('Configuration saved successfully!'); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save configuration'); + } finally { + setSaving(false); + } + }; + + const addCategory = () => { + if (!config || !newCategoryName.trim()) return; + + const newCategory: CompletenessCategoryConfig = { + id: generateId(), + name: newCategoryName.trim(), + description: newCategoryDescription.trim(), + fields: [], + }; + + setConfig({ + ...config, + categories: [...config.categories, newCategory], + }); + + setNewCategoryName(''); + setNewCategoryDescription(''); + }; + + const deleteCategory = (categoryId: string) => { + if (!config) return; + if (!confirm('Are you sure you want to delete this category? This cannot be undone.')) return; + + setConfig({ + ...config, + categories: config.categories.filter(c => c.id !== categoryId), + }); + }; + + const startEditingCategory = (categoryId: string) => { + if (!config) return; + const category = config.categories.find(c => c.id === categoryId); + if (!category) return; + setEditingCategory(categoryId); + setEditingCategoryName(category.name); + setEditingCategoryDescription(category.description || ''); + }; + + const cancelEditingCategory = () => { + setEditingCategory(null); + setEditingCategoryName(''); + setEditingCategoryDescription(''); + }; + + const saveEditingCategory = (categoryId: string) => { + if (!config) return; + + setConfig({ + ...config, + categories: config.categories.map(cat => + cat.id === categoryId + ? { ...cat, name: editingCategoryName.trim(), description: editingCategoryDescription.trim() } + : cat + ), + }); + cancelEditingCategory(); + }; + + const addFieldToCategory = (categoryId: string) => { + if (!config || !newFieldName.trim() || !newFieldPath.trim()) return; + + const category = config.categories.find(c => c.id === categoryId); + if (!category) return; + + // Check if field already exists in this category + if (category.fields.some(f => f.fieldPath === newFieldPath.trim())) { + setError(`Field "${newFieldName.trim()}" is already in this category`); + setTimeout(() => setError(null), 3000); + return; + } + + const newField: CompletenessFieldConfig = { + id: generateId(), + name: newFieldName.trim(), + fieldPath: newFieldPath.trim(), + enabled: true, + }; + + setConfig({ + ...config, + categories: config.categories.map(cat => + cat.id === categoryId + ? { ...cat, fields: [...cat.fields, newField] } + : cat + ), + }); + + setNewFieldName(''); + setNewFieldPath(''); + setAddingFieldToCategory(null); + }; + + const removeFieldFromCategory = (categoryId: string, fieldId: string) => { + if (!config) return; + + setConfig({ + ...config, + categories: config.categories.map(cat => + cat.id === categoryId + ? { ...cat, fields: cat.fields.filter(f => f.id !== fieldId) } + : cat + ), + }); + }; + + const toggleField = (categoryId: string, fieldId: string) => { + if (!config) return; + + setConfig({ + ...config, + categories: config.categories.map(cat => + cat.id === categoryId + ? { + ...cat, + fields: cat.fields.map(field => + field.id === fieldId ? { ...field, enabled: !field.enabled } : field + ), + } + : cat + ), + }); + }; + + + const handleFieldDragStart = (categoryId: string, fieldIndex: number) => { + if (!config) return; + const category = config.categories.find(c => c.id === categoryId); + if (!category) return; + const field = category.fields[fieldIndex]; + if (!field) return; + setDraggedField({ categoryId, fieldIndex, field }); + }; + + const handleFieldDragOver = (e: React.DragEvent, categoryId: string, fieldIndex: number) => { + e.preventDefault(); + if (!draggedField || !config) return; + + // Same category - reorder within category + if (draggedField.categoryId === categoryId) { + if (draggedField.fieldIndex === fieldIndex) return; + + const category = config.categories.find(c => c.id === categoryId); + if (!category) return; + + const newFields = [...category.fields]; + const draggedItem = newFields[draggedField.fieldIndex]; + newFields.splice(draggedField.fieldIndex, 1); + newFields.splice(fieldIndex, 0, draggedItem); + + setConfig({ + ...config, + categories: config.categories.map(cat => + cat.id === categoryId ? { ...cat, fields: newFields } : cat + ), + }); + + setDraggedField({ ...draggedField, fieldIndex }); + } else { + // Different category - move to new category + const targetCategory = config.categories.find(c => c.id === categoryId); + if (!targetCategory) return; + + // Check if field already exists in target category + if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return; + + // Remove from source category + const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId); + if (!sourceCategory) return; + + const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id); + + // Add to target category at the specified index + const targetFields = [...targetCategory.fields]; + targetFields.splice(fieldIndex, 0, draggedField.field); + + setConfig({ + ...config, + categories: config.categories.map(cat => { + if (cat.id === draggedField.categoryId) { + return { ...cat, fields: sourceFields }; + } + if (cat.id === categoryId) { + return { ...cat, fields: targetFields }; + } + return cat; + }), + }); + + setDraggedField({ categoryId, fieldIndex, field: draggedField.field }); + } + }; + + const handleCategoryFieldDragOver = (e: React.DragEvent, categoryId: string) => { + e.preventDefault(); + if (!draggedField || !config) return; + + // Only allow dropping if dragging from a different category + if (draggedField.categoryId === categoryId) return; + + const targetCategory = config.categories.find(c => c.id === categoryId); + if (!targetCategory) return; + + // Check if field already exists in target category + if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return; + }; + + const handleFieldDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDraggedField(null); + }; + + const handleCategoryFieldDrop = (e: React.DragEvent, categoryId: string) => { + e.preventDefault(); + if (!draggedField || !config) return; + + // Only allow dropping if dragging from a different category + if (draggedField.categoryId === categoryId) { + setDraggedField(null); + return; + } + + const targetCategory = config.categories.find(c => c.id === categoryId); + if (!targetCategory) { + setDraggedField(null); + return; + } + + // Check if field already exists in target category + if (targetCategory.fields.some(f => f.id === draggedField.field.id)) { + setDraggedField(null); + return; + } + + // Remove from source category + const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId); + if (!sourceCategory) { + setDraggedField(null); + return; + } + + const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id); + + // Add to target category at the end + const targetFields = [...targetCategory.fields, draggedField.field]; + + setConfig({ + ...config, + categories: config.categories.map(cat => { + if (cat.id === draggedField.categoryId) { + return { ...cat, fields: sourceFields }; + } + if (cat.id === categoryId) { + return { ...cat, fields: targetFields }; + } + return cat; + }), + }); + + setDraggedField(null); + }; + + const handleCategoryDragStart = (categoryId: string, categoryIndex: number) => { + setDraggedCategory({ categoryId, categoryIndex }); + }; + + const handleCategoryBlockDragOver = (e: React.DragEvent, categoryId: string, categoryIndex: number) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggedCategory || !config) return; + if (draggedCategory.categoryId === categoryId) return; + if (draggedCategory.categoryIndex === categoryIndex) return; + + const newCategories = [...config.categories]; + const draggedItem = newCategories[draggedCategory.categoryIndex]; + newCategories.splice(draggedCategory.categoryIndex, 1); + newCategories.splice(categoryIndex, 0, draggedItem); + + setConfig({ + ...config, + categories: newCategories, + }); + + setDraggedCategory({ categoryId, categoryIndex }); + }; + + const handleCategoryDragEnd = () => { + setDraggedCategory(null); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error && !config) { + return ( +
+ {error} +
+ ); + } + + if (!config) { + return ( +
+ No configuration available +
+ ); + } + + return ( +
+ {/* Header */} +
+

Data Completeness Configuration

+

+ Create and configure categories and fields for the Data Completeness Score calculation. + Fields are dynamically loaded from the "Application Component" object type in the datamodel. +

+
+ + {/* Success/Error Messages */} + {success && ( +
+ {success} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Categories */} + {config.categories.map((category, categoryIndex) => { + const enabledCount = category.fields.filter(f => f.enabled).length; + const isEditing = editingCategory === category.id; + const isAddingField = addingFieldToCategory === category.id; + const isDraggedCategory = draggedCategory?.categoryId === category.id; + + return ( +
{ + // Only allow dragging when not editing and not adding a field + if (!isEditing && !isAddingField) { + handleCategoryDragStart(category.id, categoryIndex); + } else { + e.preventDefault(); + } + }} + onDragOver={(e) => { + // Only allow reordering when not editing and not adding a field + if (!isEditing && !isAddingField) { + handleCategoryBlockDragOver(e, category.id, categoryIndex); + } + }} + onDragEnd={handleCategoryDragEnd} + className={`bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6 ${ + isDraggedCategory ? 'opacity-50' : '' + } ${!isEditing && !isAddingField ? 'cursor-move' : ''}`} + > + {/* Category Header */} +
+
+ {!isEditing && !isAddingField && ( +
+ + + +
+ )} +
+ {isEditing ? ( +
+ setEditingCategoryName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="Category Name" + /> + setEditingCategoryDescription(e.target.value)} + placeholder="Description" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + +
+
+ ) : ( +
+

{category.name}

+

{category.description || 'No description'}

+
+ )} +
+
+
+ + {enabledCount} / {category.fields.length} enabled + + {!isEditing && ( + <> + + + + )} +
+
+ + + {/* Fields List */} +
handleCategoryFieldDragOver(e, category.id)} + onDrop={(e) => handleCategoryFieldDrop(e, category.id)} + > + {category.fields.length === 0 ? ( +
+ No fields in this category. {draggedField && draggedField.categoryId !== category.id ? 'Drop a field here' : 'Click "Add Field" to add one.'} +
+ ) : ( + category.fields.map((field, index) => ( +
handleFieldDragStart(category.id, index)} + onDragOver={(e) => handleFieldDragOver(e, category.id, index)} + onDrop={handleFieldDrop} + className={`flex items-center justify-between py-1.5 px-3 border border-gray-200 rounded hover:bg-gray-50 cursor-move ${ + draggedField?.categoryId === category.id && draggedField?.fieldIndex === index + ? 'opacity-50' + : '' + } ${ + draggedField && draggedField.categoryId !== category.id && draggedField.fieldIndex === index + ? 'border-blue-400 bg-blue-50' + : '' + }`} + > +
+ {/* Drag handle icon */} +
+ + + +
+ toggleField(category.id, field.id)} + onClick={(e) => e.stopPropagation()} + className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 flex-shrink-0" + /> + +
+
+
+ {field.enabled ? 'Enabled' : 'Disabled'} +
+ +
+
+ )) + )} +
+ + {/* Add Field Form - Below the list */} + {isAddingField && ( +
+

Add Field to {category.name}

+ +
+
+ + +

+ Only fields from the "Application Component" object type are available +

+
+ + {/* Display selected field info (read-only) */} + {newFieldPath && newFieldName && ( +
+
+ Selected Field:{' '} + {newFieldName} +
+
+ Field Path: {newFieldPath} +
+
+ )} +
+ +
+ + +
+
+ )} + + {/* Add Field Button - Below the list */} + {!isEditing && !isAddingField && ( +
+ +
+ )} +
+ ); + })} + + {/* Add New Category */} +
+

Add New Category

+
+
+ + setNewCategoryName(e.target.value)} + placeholder="e.g., Security, Compliance" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setNewCategoryDescription(e.target.value)} + placeholder="Brief description" + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ +
+
+
+ + {/* Save Button */} +
+ + +
+ + {/* Info Box */} +
+

About Data Completeness Configuration

+
    +
  • Fields are dynamically loaded from the "Application Component" object type in your Jira Assets schema
  • +
  • Create custom categories to organize fields for completeness checking
  • +
  • Add fields to categories by selecting from available schema fields or entering custom field paths
  • +
  • Only enabled fields are included in the completeness score calculation
  • +
  • Changes take effect immediately after saving
  • +
  • The field path determines which property in the ApplicationDetails object is checked
  • +
+
+
+ ); +} diff --git a/frontend/src/components/DataCompletenessScore.tsx b/frontend/src/components/DataCompletenessScore.tsx new file mode 100644 index 0000000..be506ab --- /dev/null +++ b/frontend/src/components/DataCompletenessScore.tsx @@ -0,0 +1,495 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api'; + +interface FieldCompleteness { + field: string; + category: 'general' | 'applicationManagement'; + filled: number; + total: number; + percentage: number; +} + +interface ApplicationCompleteness { + id: string; + key: string; + name: string; + team: string | null; + subteam: string | null; + generalScore: number; + applicationManagementScore: number; + overallScore: number; + filledFields: number; + totalFields: number; +} + +interface TeamCompleteness { + team: string; + generalScore: number; + applicationManagementScore: number; + overallScore: number; + applicationCount: number; + filledFields: number; + totalFields: number; +} + +interface CompletenessData { + overall: { + generalScore: number; + applicationManagementScore: number; + overallScore: number; + totalApplications: number; + filledFields: number; + totalFields: number; + categoryScores?: Record; // Dynamic category scores + }; + byField: FieldCompleteness[]; + byApplication: ApplicationCompleteness[]; + byTeam: TeamCompleteness[]; +} + +const API_BASE = '/api'; + +function getScoreColor(score: number): string { + if (score >= 90) return 'text-green-600 bg-green-50'; + if (score >= 75) return 'text-yellow-600 bg-yellow-50'; + if (score >= 50) return 'text-orange-600 bg-orange-50'; + return 'text-red-600 bg-red-50'; +} + +function getScoreBadgeColor(score: number): string { + if (score >= 90) return 'bg-green-500'; + if (score >= 75) return 'bg-yellow-500'; + if (score >= 50) return 'bg-orange-500'; + return 'bg-red-500'; +} + +export default function DataCompletenessScore() { + const [data, setData] = useState(null); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTeam, setSelectedTeam] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'score' | 'name'>('score'); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + + try { + // Fetch config and data in parallel + const [configResult, dataResponse] = await Promise.all([ + getDataCompletenessConfig(), + fetch(`${API_BASE}/dashboard/data-completeness`) + ]); + + setConfig(configResult); + + if (!dataResponse.ok) { + throw new Error('Failed to fetch data completeness data'); + } + const result = await dataResponse.json(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return ( +
+

Geen data beschikbaar

+
+ ); + } + + // Filter applications + const filteredApplications = data.byApplication + .filter(app => { + const matchesSearch = !searchQuery || + app.name.toLowerCase().includes(searchQuery.toLowerCase()) || + app.key.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesTeam = !selectedTeam || app.team === selectedTeam; + + return matchesSearch && matchesTeam; + }) + .sort((a, b) => { + if (sortBy === 'score') { + return b.overallScore - a.overallScore; + } + return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' }); + }); + + return ( +
+ {/* Header */} +
+

Data Completeness Score

+

+ Percentage van verplichte velden ingevuld per applicatie, per team en overall. +

+
+ + {/* Overall Score Cards */} +
+
+
Overall Score
+
+
+ {data.overall.overallScore.toFixed(1)}% +
+
+ ({data.overall.filledFields} / {data.overall.totalFields} velden) +
+
+
+
+
+
+
+
+ +
+
General Category
+
+
+ {data.overall.generalScore.toFixed(1)}% +
+
+ ({config?.categories.find(c => c.id === 'general')?.fields.filter(f => f.enabled).length || config?.categories[0]?.fields.filter(f => f.enabled).length || 0} velden) +
+
+
+
+
+
+
+
+ +
+
Application Management
+
+
+ {data.overall.applicationManagementScore.toFixed(1)}% +
+
+ ({config?.categories.find(c => c.id === 'applicationManagement')?.fields.filter(f => f.enabled).length || config?.categories[1]?.fields.filter(f => f.enabled).length || 0} velden) +
+
+
+
+
+
+
+
+
+ + {/* Field Completeness Table */} +
+

Veld Completeness

+
+ {/* Dynamic Categories */} + {config?.categories.map((category, index) => { + const categoryFields = data.byField.filter(f => f.category === category.id); + if (categoryFields.length === 0) return null; + + const categoryScore = data.overall.categoryScores?.[category.id] ?? 0; + + return ( +
0 ? 'mt-6' : ''}> +
+
+

{category.name}

+ {category.description && ( +

{category.description}

+ )} +
+
+ + {categoryScore.toFixed(1)}% + +
+
+
+ {categoryFields.map(field => ( +
+
+ {field.field} +
+
+
+
+
+
+
+ {field.percentage.toFixed(1)}% +
+
+ {field.filled} / {field.total} +
+
+ ))} +
+
+ ); + })} +
+
+ + {/* Team Scores */} +
+

Scores per Team

+
+ + + + + + + + + + + + + {data.byTeam + .sort((a, b) => b.overallScore - a.overallScore) + .map((team) => ( + setSelectedTeam(selectedTeam === team.team ? null : team.team)} + > + + + + + + + + ))} + +
+ Team + + Overall Score + + General + + App. Management + + Applicaties + + Velden +
+ {team.team || 'Niet toegekend'} + +
+
+ {team.overallScore.toFixed(1)}% +
+
+
+
+
+
+ + {team.generalScore.toFixed(1)}% + + + + {team.applicationManagementScore.toFixed(1)}% + + + {team.applicationCount} + + {team.filledFields} / {team.totalFields} +
+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Naam of key..." + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Team Filter */} +
+ + +
+ + {/* Sort By */} +
+ + +
+
+
+ + {/* Applications List */} +
+
+

+ Applicaties ({filteredApplications.length}) +

+
+
+ + + + + + + + + + + + + + {filteredApplications.length === 0 ? ( + + + + ) : ( + filteredApplications.map((app) => ( + + + + + + + + + + )) + )} + +
+ Key + + Naam + + Team + + Overall Score + + General + + App. Management + + Velden +
+ Geen applicaties gevonden +
+ {app.key} + + + {app.name} + + + {app.team || '-'} + +
+ + {app.overallScore.toFixed(1)}% + +
+
+
+
+
+ + {app.generalScore.toFixed(1)}% + + + + {app.applicationManagementScore.toFixed(1)}% + + + {app.filledFields} / {app.totalFields} +
+
+
+
+ ); +} diff --git a/frontend/src/components/DataModelDashboard.tsx b/frontend/src/components/DataModelDashboard.tsx index 839b27b..7d628a4 100644 --- a/frontend/src/components/DataModelDashboard.tsx +++ b/frontend/src/components/DataModelDashboard.tsx @@ -89,6 +89,8 @@ function ObjectTypeCard({ isRefreshing, refreshedCount, refreshError, + cacheCount, + jiraCount, }: { objectType: SchemaObjectTypeDefinition; isExpanded: boolean; @@ -98,12 +100,15 @@ function ObjectTypeCard({ isRefreshing: boolean; refreshedCount?: number; refreshError?: string; + cacheCount?: number; + jiraCount?: number; }) { const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference'); const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference'); - // Use refreshed count if available, otherwise use the original objectCount - const displayCount = refreshedCount ?? objectType.objectCount; + // Priority: refreshedCount > jiraCount > cacheCount > objectCount + // jiraCount is the actual count from Jira Assets API, which should match exactly + const displayCount = refreshedCount ?? jiraCount ?? cacheCount ?? objectType.objectCount; return (
@@ -630,6 +635,8 @@ export default function DataModelDashboard() { isRefreshing={refreshingTypes.has(objectType.typeName)} refreshedCount={refreshedCounts[objectType.typeName]} refreshError={refreshErrors[objectType.typeName]} + cacheCount={schema.cacheCounts?.[objectType.typeName]} + jiraCount={schema.jiraCounts?.[objectType.typeName]} />
))} diff --git a/frontend/src/components/EffortDisplay.tsx b/frontend/src/components/EffortDisplay.tsx index f77e371..b1cea70 100644 --- a/frontend/src/components/EffortDisplay.tsx +++ b/frontend/src/components/EffortDisplay.tsx @@ -55,6 +55,11 @@ export function EffortDisplay({ const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null }; const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null }; + // Calculate final min/max FTE by applying factors to base min/max + const factorMultiplier = numberOfUsersFactor.value * dynamicsFactor.value * complexityFactor.value; + const finalMinFTE = baseEffortMin !== null ? baseEffortMin * factorMultiplier : null; + const finalMaxFTE = baseEffortMax !== null ? baseEffortMax * factorMultiplier : null; + const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null; const applicationTypeName = breakdown?.applicationType ?? null; const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null; @@ -128,6 +133,11 @@ export function EffortDisplay({ {/* Main FTE display */}
{effectiveFte.toFixed(2)} FTE + {finalMinFTE !== null && finalMaxFTE !== null && finalMinFTE !== finalMaxFTE && ( + + (bandbreedte: {finalMinFTE.toFixed(2)} - {finalMaxFTE.toFixed(2)}) + + )} {hasOverride && ( (Override) )} diff --git a/frontend/src/components/FTEPerZiRADomain.tsx b/frontend/src/components/FTEPerZiRADomain.tsx new file mode 100644 index 0000000..bb29cfd --- /dev/null +++ b/frontend/src/components/FTEPerZiRADomain.tsx @@ -0,0 +1,406 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +interface FunctionFTE { + function: { + code: string; + name: string; + description: string; + }; + totalFTE: number; + minFTE: number; + maxFTE: number; + applicationCount: number; + applications: Array<{ + id: string; + key: string; + name: string; + fte: number; + minFte: number | null; + maxFte: number | null; + }>; +} + +interface DomainFTE { + domain: { + code: string; + name: string; + description: string; + }; + totalFTE: number; + minFTE: number; + maxFTE: number; + applicationCount: number; + functions: FunctionFTE[]; + applications: Array<{ + id: string; + key: string; + name: string; + fte: number; + minFte: number | null; + maxFte: number | null; + }>; +} + +interface FTEPerZiRADomainData { + overall: { + totalFTE: number; + totalMinFTE: number; + totalMaxFTE: number; + totalApplications: number; + domainCount: number; + }; + byDomain: DomainFTE[]; +} + +const API_BASE = '/api'; + +export default function FTEPerZiRADomain() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedDomains, setExpandedDomains] = useState>(new Set()); + const [expandedFunctions, setExpandedFunctions] = useState>(new Set()); + + useEffect(() => { + async function fetchData() { + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_BASE}/dashboard/fte-per-zira-domain`); + if (!response.ok) { + throw new Error('Failed to fetch FTE per ZiRA Domain data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + fetchData(); + }, []); + + const toggleDomain = (domainCode: string) => { + setExpandedDomains(prev => { + const newSet = new Set(prev); + if (newSet.has(domainCode)) { + newSet.delete(domainCode); + } else { + newSet.add(domainCode); + } + return newSet; + }); + }; + + const toggleFunction = (functionKey: string) => { + setExpandedFunctions(prev => { + const newSet = new Set(prev); + if (newSet.has(functionKey)) { + newSet.delete(functionKey); + } else { + newSet.add(functionKey); + } + return newSet; + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return
Geen data beschikbaar
; + } + + // Calculate max FTE for scaling the bars + const maxFTE = Math.max(...data.byDomain.map(d => d.totalFTE), 1); + + return ( +
+ {/* Header */} +
+

FTE per ZiRA Domain

+

+ Welke business domeinen vereisen het meeste IT management effort? +

+
+ + {/* Overall Statistics */} +
+
+
Totaal FTE
+
+ {data.overall.totalFTE.toFixed(2)} +
+ {data.overall.totalMinFTE > 0 && data.overall.totalMaxFTE > 0 && ( +
+ Range: {data.overall.totalMinFTE.toFixed(2)} - {data.overall.totalMaxFTE.toFixed(2)} FTE +
+ )} +
+
+
Aantal domeinen
+
{data.overall.domainCount}
+
+
+
Totaal applicaties
+
{data.overall.totalApplications}
+
+
+
Gemiddeld FTE per domein
+
+ {data.overall.domainCount > 0 + ? (data.overall.totalFTE / data.overall.domainCount).toFixed(2) + : '0.00'} +
+
+
+ + {/* FTE by Domain - Bar Chart */} +
+

+ FTE verdeling per ZiRA Domain en Applicatiefunctie +

+
+ {data.byDomain.map(domain => { + const percentage = (domain.totalFTE / maxFTE) * 100; + const isDomainExpanded = expandedDomains.has(domain.domain.code); + const maxFunctionFTE = Math.max(...domain.functions.map(f => f.totalFTE), 1); + + return ( +
+ {/* Domain Header */} +
+
+
+ +

+ {domain.domain.name} ({domain.domain.code}) +

+
+

{domain.domain.description}

+
+
+
+ {domain.totalFTE.toFixed(2)} FTE +
+ {domain.minFTE > 0 && domain.maxFTE > 0 && ( +
+ {domain.minFTE.toFixed(2)} - {domain.maxFTE.toFixed(2)} FTE +
+ )} +
+ {domain.applicationCount} applicatie{domain.applicationCount !== 1 ? 's' : ''} +
+
+ {domain.functions.length} functie{domain.functions.length !== 1 ? 's' : ''} +
+
+
+
+
+
+ {percentage > 10 && ( + + {domain.totalFTE.toFixed(2)} FTE + + )} +
+ {percentage <= 10 && ( + + {domain.totalFTE.toFixed(2)} FTE + + )} +
+
+ + {/* Expanded view with functions */} + {isDomainExpanded && ( +
+ {/* Functions within domain */} + {domain.functions.length > 0 && ( +
+

+ Applicatiefuncties ({domain.functions.length}) +

+
+ {domain.functions.map(func => { + const funcPercentage = (func.totalFTE / maxFunctionFTE) * 100; + const functionKey = `${domain.domain.code}-${func.function.code}`; + const isFunctionExpanded = expandedFunctions.has(functionKey); + + return ( +
+
+
+
+ + {func.function.code} +
{func.function.name}
+
+ {func.function.description && ( +

{func.function.description}

+ )} +
+
+
+ {func.totalFTE.toFixed(2)} FTE +
+ {func.minFTE > 0 && func.maxFTE > 0 && ( +
+ {func.minFTE.toFixed(2)} - {func.maxFTE.toFixed(2)} FTE +
+ )} +
+ {func.applicationCount} app{func.applicationCount !== 1 ? 's' : ''} +
+
+
+
+
+
+ {funcPercentage > 15 && ( + + {func.totalFTE.toFixed(2)} + + )} +
+ {funcPercentage <= 15 && ( + + {func.totalFTE.toFixed(2)} + + )} +
+
+ + {/* Expanded view with applications for this function */} + {isFunctionExpanded && ( +
+
+ Applicaties ({func.applications.length}) +
+
+ {func.applications.map(app => ( +
+
+ + {app.name} + + ({app.key}) +
+
+
+ {app.fte.toFixed(2)} FTE +
+ {app.minFte !== null && app.maxFte !== null && ( +
+ {app.minFte.toFixed(2)} - {app.maxFte.toFixed(2)} +
+ )} +
+
+ ))} +
+
+ )} +
+ ); + })} +
+
+ )} + + {/* All applications in domain (summary) */} +
+

+ Alle applicaties in dit domein ({domain.applications.length}) +

+
+ {domain.applications.map(app => ( +
+
+ + {app.name} + + ({app.key}) +
+
+
+ {app.fte.toFixed(2)} FTE +
+ {app.minFte !== null && app.maxFte !== null && ( +
+ {app.minFte.toFixed(2)} - {app.maxFte.toFixed(2)} FTE +
+ )} +
+
+ ))} +
+
+
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/GovernanceModelBadge.tsx b/frontend/src/components/GovernanceModelBadge.tsx new file mode 100644 index 0000000..096ddb9 --- /dev/null +++ b/frontend/src/components/GovernanceModelBadge.tsx @@ -0,0 +1,171 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { ReferenceValue } from '../types'; + +// Color scheme for governance models - matches exact names from Jira Assets +export const GOVERNANCE_MODEL_COLORS: Record = { + 'Regiemodel A': { bg: '#20556B', text: '#FFFFFF', letter: 'A' }, + 'Regiemodel B': { bg: '#286B86', text: '#FFFFFF', letter: 'B' }, + 'Regiemodel B+': { bg: '#286B86', text: '#FFFFFF', letter: 'B+' }, + 'Regiemodel C': { bg: '#81CBF2', text: '#20556B', letter: 'C' }, + 'Regiemodel D': { bg: '#F5A733', text: '#FFFFFF', letter: 'D' }, + 'Regiemodel E': { bg: '#E95053', text: '#FFFFFF', letter: 'E' }, + 'Niet ingesteld': { bg: '#EEEEEE', text: '#AAAAAA', letter: '?' }, +}; + +// Get governance model colors and letter - with fallback for unknown models +export function getGovernanceModelStyle(governanceModelName: string | null | undefined) { + const name = governanceModelName || 'Niet ingesteld'; + + // First try exact match + if (GOVERNANCE_MODEL_COLORS[name]) { + return GOVERNANCE_MODEL_COLORS[name]; + } + + // Try to match by pattern (e.g., "Regiemodel X" -> letter X) + const match = name.match(/Regiemodel\s+(.+)/i); + if (match) { + const letter = match[1]; + // Return a color based on the letter + if (letter === 'A') return { bg: '#20556B', text: '#FFFFFF', letter: 'A' }; + if (letter === 'B') return { bg: '#286B86', text: '#FFFFFF', letter: 'B' }; + if (letter === 'B+') return { bg: '#286B86', text: '#FFFFFF', letter: 'B+' }; + if (letter === 'C') return { bg: '#81CBF2', text: '#20556B', letter: 'C' }; + if (letter === 'D') return { bg: '#F5A733', text: '#FFFFFF', letter: 'D' }; + if (letter === 'E') return { bg: '#E95053', text: '#FFFFFF', letter: 'E' }; + return { bg: '#6B7280', text: '#FFFFFF', letter }; + } + + return { bg: '#6B7280', text: '#FFFFFF', letter: '?' }; +} + +interface GovernanceModelBadgeProps { + governanceModelName: string | null | undefined; + governanceModelData?: ReferenceValue | null; + size?: 'sm' | 'md' | 'lg'; + showPopup?: boolean; + className?: string; +} + +export default function GovernanceModelBadge({ + governanceModelName, + governanceModelData, + size = 'md', + showPopup = true, + className = '', +}: GovernanceModelBadgeProps) { + const [isHovered, setIsHovered] = useState(false); + const hoverTimeoutRef = useRef(null); + const badgeRef = useRef(null); + + const style = getGovernanceModelStyle(governanceModelName); + const name = governanceModelName || 'Niet ingesteld'; + + // Hover handlers with delayed hide to prevent flickering + const handleMouseEnter = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + hoverTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, 100); + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + // Size classes + const sizeClasses = { + sm: 'w-5 h-5 text-[8px]', + md: 'w-6 h-6 text-[10px]', + lg: 'w-8 h-8 text-xs', + }; + + const shouldShowPopup = showPopup && isHovered && name !== 'Niet ingesteld' && governanceModelData; + + return ( +
+
+ {style.letter} +
+ + {/* Hover popup */} + {shouldShowPopup && ( +
+ {/* Arrow pointer */} +
+ + {/* Header: Summary (Description) */} +
+ {governanceModelData.summary || name} + {governanceModelData.description && ( + + {' '} + ({governanceModelData.description}) + + )} +
+ + {/* Remarks */} + {governanceModelData.remarks && ( +
+ {governanceModelData.remarks} +
+ )} + + {/* Application section */} + {governanceModelData.application && ( +
+
+ Toepassing +
+
+ {governanceModelData.application} +
+
+ )} + + {/* Fallback message if no data */} + {!governanceModelData.summary && !governanceModelData.remarks && !governanceModelData.application && ( +
+ Geen aanvullende informatie beschikbaar +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/GovernanceModelHelper.tsx b/frontend/src/components/GovernanceModelHelper.tsx index b3c70bb..67b5fe8 100644 --- a/frontend/src/components/GovernanceModelHelper.tsx +++ b/frontend/src/components/GovernanceModelHelper.tsx @@ -2038,41 +2038,110 @@ export default function GovernanceModelHelper() { {/* AI Suggestion for BIA Classification */} {aiSuggestion?.managementClassification?.biaClassification && (() => { const aiValue = aiSuggestion.managementClassification.biaClassification.value; + const reasoning = aiSuggestion.managementClassification.biaClassification.reasoning || ''; const suggested = businessImpactAnalyses.find( (b) => b.name === aiValue || b.name.includes(aiValue) || aiValue.includes(b.name) ); const isAccepted = suggested && selectedBusinessImpactAnalyse?.objectId === suggested.objectId; + + // Detect if this is from Excel or AI + const isFromExcel = reasoning.includes('BIA.xlsx') || reasoning.includes('Excel'); + const isExactMatch = reasoning.includes('exacte match'); + const isFuzzyMatch = reasoning.includes('fuzzy match'); + + // Extract match percentage if it's a fuzzy match + const matchPercentMatch = reasoning.match(/(\d+)% overeenkomst/); + const matchPercent = matchPercentMatch ? matchPercentMatch[1] : null; + + // Extract Excel application name if available + const excelNameMatch = reasoning.match(/match met "([^"]+)"/); + const excelName = excelNameMatch ? excelNameMatch[1] : null; + + // Determine styling based on source + const bgColor = isFromExcel ? 'bg-green-50' : 'bg-blue-50'; + const borderColor = isFromExcel ? 'border-green-300' : 'border-blue-200'; + const iconColor = isFromExcel ? 'text-green-600' : 'text-blue-600'; + const badgeColor = isFromExcel + ? (isExactMatch ? 'bg-green-100 text-green-800' : 'bg-green-100 text-green-700') + : 'bg-blue-100 text-blue-700'; + return ( -
-
+
+
-

AI Suggestie:

-

{aiValue}

- {aiSuggestion.managementClassification.biaClassification.reasoning && ( -

{aiSuggestion.managementClassification.biaClassification.reasoning}

+ {/* Source Badge */} +
+ {isFromExcel ? ( + <> + + + + + {isExactMatch ? 'Excel (Exact)' : isFuzzyMatch ? `Excel (Fuzzy ${matchPercent}%)` : 'Excel'} + + + ) : ( + <> + + + + + AI Suggestie + + + )} +
+ + {/* Value */} +

{aiValue}

+ + {/* Additional Info */} + {isFromExcel && excelName && ( +

+ Match met: {excelName} +

+ )} + + {/* Reasoning */} + {reasoning && ( +

{reasoning}

+ )} + + {/* Warning for low-confidence fuzzy matches */} + {isFuzzyMatch && matchPercent && parseInt(matchPercent) < 75 && ( +
+ + + + Let op: Controleer of de match correct is ({matchPercent}% overeenkomst) +
+ )} +
+ + {/* Action Buttons */} +
+ {!isAccepted && suggested && ( + + )} + {isAccepted && ( + ✓ Geaccepteerd + )} + {!suggested && ( + ⚠ Niet gevonden )}
- {!isAccepted && suggested && ( - - )} - {isAccepted && ( - ✓ Geaccepteerd - )} - {!suggested && ( - ⚠ Niet gevonden - )}
); diff --git a/frontend/src/components/LifecyclePipeline.tsx b/frontend/src/components/LifecyclePipeline.tsx new file mode 100644 index 0000000..b3fef4f --- /dev/null +++ b/frontend/src/components/LifecyclePipeline.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +interface LifecycleApplication { + id: string; + key: string; + name: string; + status: string; + lifecycleStage: 'implementation' | 'poc' | 'production' | 'eos_eol' | 'deprecated' | 'shadow_undefined' | 'closed'; +} + +interface LifecycleData { + totalApplications: number; + byStage: { + implementation: number; + poc: number; + production: number; + eos_eol: number; + deprecated: number; + shadow_undefined: number; + closed: number; + }; + applications: LifecycleApplication[]; +} + +const API_BASE = '/api'; + +// Lifecycle stages in order +const LIFECYCLE_STAGES = [ + { key: 'implementation', label: 'Implementation', status: 'Implementation', color: 'bg-blue-500' }, + { key: 'poc', label: 'Proof of Concept', status: 'Proof of Concept', color: 'bg-purple-500' }, + { key: 'production', label: 'In Production', status: 'In Production', color: 'bg-green-500' }, + { key: 'eos_eol', label: 'End of Support / End of Life', status: ['End of support', 'End of life'], color: 'bg-orange-500' }, + { key: 'deprecated', label: 'Deprecated', status: 'Deprecated', color: 'bg-amber-500' }, + { key: 'shadow_undefined', label: 'Shadow IT / Undefined', status: ['Shadow IT', 'Undefined'], color: 'bg-indigo-500' }, + { key: 'closed', label: 'Closed', status: 'Closed', color: 'bg-gray-500' }, +] as const; + +export default function LifecyclePipeline() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedStage, setSelectedStage] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${API_BASE}/dashboard/lifecycle-pipeline`); + if (!response.ok) { + throw new Error('Failed to fetch lifecycle pipeline data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return ( +
+

Geen data beschikbaar

+
+ ); + } + + // Filter applications + const filteredApplications = data.applications.filter(app => { + const matchesSearch = !searchQuery || + app.name.toLowerCase().includes(searchQuery.toLowerCase()) || + app.key.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStage = !selectedStage || app.lifecycleStage === selectedStage; + + return matchesSearch && matchesStage; + }); + + return ( +
+ {/* Header */} +
+
+
+ + + +
+

Lifecycle Pipeline

+
+

+ Overzicht van applicaties verdeeld over de verschillende lifecycle fases en statussen +

+
+ + {/* Total Summary Card */} +
+
+
+
Totaal Applicaties
+
{data.totalApplications}
+
+
+ + + +
+
+
+ + {/* Summary Cards */} +
+ {LIFECYCLE_STAGES.map(stage => { + const count = data.byStage[stage.key as keyof typeof data.byStage] || 0; + const percentage = data.totalApplications > 0 + ? Math.round((count / data.totalApplications) * 100) + : 0; + + // Enhanced color gradients for each stage + const gradientClasses: Record = { + implementation: 'from-blue-500 to-blue-600', + poc: 'from-purple-500 to-purple-600', + production: 'from-green-500 to-green-600', + eos_eol: 'from-orange-500 to-orange-600', + deprecated: 'from-amber-500 to-amber-600', + shadow_undefined: 'from-indigo-500 to-indigo-600', + closed: 'from-gray-500 to-gray-600', + }; + + return ( +
setSelectedStage(selectedStage === stage.key ? null : stage.key)} + > +
+
{stage.label}
+ {selectedStage === stage.key && ( + + + + )} +
+
{count}
+
{percentage}% van totaal
+ {count > 0 && ( +
+
+
+
+
+ )} +
+ ); + })} +
+ + {/* Filters */} +
+
+ + + +

Zoeken

+
+
+ {/* Search */} +
+ +
+
+ + + +
+ setSearchQuery(e.target.value)} + placeholder="Naam of key..." + className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ {(searchQuery || selectedStage) && ( +
+ + {selectedStage && ( + + (Gefilterd op: {LIFECYCLE_STAGES.find(s => s.key === selectedStage)?.label}) + + )} +
+ )} +
+ + {/* Applications List */} +
+
+
+
+ + + +

+ Applicaties +

+ + {filteredApplications.length} + +
+ {filteredApplications.length !== data.totalApplications && ( + + Gefilterd van {data.totalApplications} totaal + + )} +
+
+
+ + + + + + + + + + + {filteredApplications.length === 0 ? ( + + + + ) : ( + filteredApplications.map((app) => { + const stage = LIFECYCLE_STAGES.find(s => s.key === app.lifecycleStage); + + return ( + + + + + + + ); + }) + )} + +
+ Key + + Naam + + Status + + Lifecycle Fase +
+
+ + + +

Geen applicaties gevonden

+

Probeer andere filters

+
+
+ + {app.key} + + + + {app.name} + + + + + + {app.status} + + {stage && ( + + {stage.label} + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ReportsDashboard.tsx b/frontend/src/components/ReportsDashboard.tsx index 885dcd4..afd0921 100644 --- a/frontend/src/components/ReportsDashboard.tsx +++ b/frontend/src/components/ReportsDashboard.tsx @@ -68,6 +68,84 @@ export default function ReportsDashboard() { color: 'cyan', available: true, }, + { + id: 'technical-debt-heatmap', + title: 'Technical Debt Heatmap', + description: 'Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie.', + icon: ( + + + + ), + href: '/reports/technical-debt-heatmap', + color: 'red', + available: true, + }, + { + id: 'lifecycle-pipeline', + title: 'Lifecycle Pipeline', + description: 'Funnel/timeline visualisatie van applicaties door de verschillende lifecycle fases: Implementation → PoC → Production → EoS → EoL', + icon: ( + + + + ), + href: '/reports/lifecycle-pipeline', + color: 'indigo', + available: true, + }, + { + id: 'data-completeness', + title: 'Data Completeness Score', + description: 'Percentage van verplichte velden ingevuld per applicatie, per team en overall.', + icon: ( + + + + ), + href: '/reports/data-completeness', + color: 'teal', + available: true, + }, + { + id: 'zira-domain-coverage', + title: 'ZiRA Domain Coverage', + description: 'Welke ZiRA functies zijn goed ondersteund versus gaps in IT coverage.', + icon: ( + + + + ), + href: '/reports/zira-domain-coverage', + color: 'emerald', + available: true, + }, + { + id: 'fte-per-zira-domain', + title: 'FTE per ZiRA Domain', + description: 'Welke business domeinen vereisen het meeste IT management effort?', + icon: ( + + + + ), + href: '/reports/fte-per-zira-domain', + color: 'amber', + available: true, + }, + { + id: 'complexity-dynamics-bubble', + title: 'Complexity vs Dynamics Bubble Chart', + description: 'X=Complexity, Y=Dynamics, Size=FTE, Color=BIA', + icon: ( + + + + ), + href: '/reports/complexity-dynamics-bubble', + color: 'pink', + available: true, + }, ]; const colorClasses = { @@ -101,6 +179,42 @@ export default function ReportsDashboard() { iconText: 'text-cyan-600', hover: 'hover:bg-cyan-100', }, + red: { + bg: 'bg-red-50', + iconBg: 'bg-red-100', + iconText: 'text-red-600', + hover: 'hover:bg-red-100', + }, + indigo: { + bg: 'bg-indigo-50', + iconBg: 'bg-indigo-100', + iconText: 'text-indigo-600', + hover: 'hover:bg-indigo-100', + }, + teal: { + bg: 'bg-teal-50', + iconBg: 'bg-teal-100', + iconText: 'text-teal-600', + hover: 'hover:bg-teal-100', + }, + emerald: { + bg: 'bg-emerald-50', + iconBg: 'bg-emerald-100', + iconText: 'text-emerald-600', + hover: 'hover:bg-emerald-100', + }, + amber: { + bg: 'bg-amber-50', + iconBg: 'bg-amber-100', + iconText: 'text-amber-600', + hover: 'hover:bg-amber-100', + }, + pink: { + bg: 'bg-pink-50', + iconBg: 'bg-pink-100', + iconText: 'text-pink-600', + hover: 'hover:bg-pink-100', + }, }; return ( diff --git a/frontend/src/components/TeamPortfolioHealth.tsx b/frontend/src/components/TeamPortfolioHealth.tsx new file mode 100644 index 0000000..c83cf9f --- /dev/null +++ b/frontend/src/components/TeamPortfolioHealth.tsx @@ -0,0 +1,314 @@ +import { useEffect, useState } from 'react'; +import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Legend, ResponsiveContainer } from 'recharts'; +import { getTeamPortfolioHealth, type TeamPortfolioHealthData } from '../services/api'; +import type { ApplicationStatus } from '../types'; + +const ALL_STATUSES: ApplicationStatus[] = [ + 'In Production', + 'Implementation', + 'Proof of Concept', + 'End of support', + 'End of life', + 'Deprecated', + 'Shadow IT', + 'Closed', + 'Undefined', +]; + +export default function TeamPortfolioHealth() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [excludedStatuses, setExcludedStatuses] = useState(['Closed', 'Deprecated']); + const [selectedTeams, setSelectedTeams] = useState>(new Set()); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + try { + const result = await getTeamPortfolioHealth(excludedStatuses); + if (result && result.teams) { + setData(result); + // Select all teams by default + setSelectedTeams(new Set(result.teams.map(t => t.team?.objectId || 'unassigned'))); + } else { + setError('Invalid data received from server'); + } + } catch (err) { + console.error('Error fetching team portfolio health:', err); + setError(err instanceof Error ? err.message : 'Failed to load team portfolio health data'); + } finally { + setLoading(false); + } + } + fetchData(); + }, [excludedStatuses]); + + const toggleTeam = (teamId: string) => { + setSelectedTeams(prev => { + const newSet = new Set(prev); + if (newSet.has(teamId)) { + newSet.delete(teamId); + } else { + newSet.add(teamId); + } + return newSet; + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data || data.teams.length === 0) { + return ( +
+ Geen team data beschikbaar. +
+ ); + } + + // Generate colors for teams + const teamColors: Record = {}; + const colors = [ + '#3B82F6', // blue + '#10B981', // green + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // purple + '#EC4899', // pink + '#06B6D4', // cyan + '#F97316', // orange + ]; + data.teams.forEach((team, index) => { + const teamId = team.team?.objectId || 'unassigned'; + teamColors[teamId] = colors[index % colors.length]; + }); + + // Prepare data for radar chart - recharts expects array of objects with all metrics + const selectedTeamsList = data.teams.filter(t => selectedTeams.has(t.team?.objectId || 'unassigned')); + + // Only create radar data if we have selected teams + const radarData = selectedTeamsList.length > 0 ? [ + { + metric: 'Complexity', + ...Object.fromEntries( + selectedTeamsList.map(team => { + const teamName = team.team?.name || 'Niet toegewezen'; + const value = Math.round((team.metrics?.complexity || 0) * 100); + return [teamName, value]; + }) + ), + }, + { + metric: 'Dynamics', + ...Object.fromEntries( + selectedTeamsList.map(team => { + const teamName = team.team?.name || 'Niet toegewezen'; + const value = Math.round((team.metrics?.dynamics || 0) * 100); + return [teamName, value]; + }) + ), + }, + { + metric: 'BIA', + ...Object.fromEntries( + selectedTeamsList.map(team => { + const teamName = team.team?.name || 'Niet toegewezen'; + const value = Math.round((team.metrics?.bia || 0) * 100); + return [teamName, value]; + }) + ), + }, + { + metric: 'Governance Maturity', + ...Object.fromEntries( + selectedTeamsList.map(team => { + const teamName = team.team?.name || 'Niet toegewezen'; + const value = Math.round((team.metrics?.governanceMaturity || 0) * 100); + return [teamName, value]; + }) + ), + }, + ] : []; + + return ( +
+ {/* Header */} +
+

Team Portfolio Health

+

+ Radar chart met complexiteit, dynamics, BIA en governance maturity per team. +

+
+ + {/* Filters */} +
+ {/* Status Filter */} +
+ +
+ {ALL_STATUSES.map(status => ( + + ))} +
+
+ + {/* Team Selection */} +
+ +
+ {data.teams.map(team => { + const teamId = team.team?.objectId || 'unassigned'; + const teamName = team.team?.name || 'Niet toegewezen'; + const isSelected = selectedTeams.has(teamId); + return ( + + ); + })} +
+
+
+ + {/* Radar Chart */} +
+

Radar Chart

+ {selectedTeams.size === 0 ? ( +
+ Selecteer ten minste één team om de radar chart te bekijken. +
+ ) : ( + + + + + + {selectedTeamsList.map((team, index) => { + const teamId = team.team?.objectId || 'unassigned'; + const teamName = team.team?.name || 'Niet toegewezen'; + const color = teamColors[teamId] || colors[index % colors.length]; + return ( + + ); + })} + + + + )} +
+ + {/* Metrics Table */} +
+

+ Metrics Overzicht +

+
+ + + + + + + + + + + + + {selectedTeamsList.map(team => ( + + + + + + + + + ))} + +
+ Team + + Applicaties + + Complexity + + Dynamics + + BIA + + Governance Maturity +
+ {team.team?.name || 'Niet toegewezen'} + + {team.applicationCount} + + {Math.round(team.metrics.complexity * 100)}% + + {Math.round(team.metrics.dynamics * 100)}% + + {Math.round(team.metrics.bia * 100)}% + + {Math.round(team.metrics.governanceMaturity * 100)}% +
+
+
+
+ ); +} diff --git a/frontend/src/components/TechnicalDebtHeatmap.tsx b/frontend/src/components/TechnicalDebtHeatmap.tsx new file mode 100644 index 0000000..b27327d --- /dev/null +++ b/frontend/src/components/TechnicalDebtHeatmap.tsx @@ -0,0 +1,469 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +interface TechnicalDebtApplication { + id: string; + key: string; + name: string; + status: string | null; + businessImpactAnalyse: string | null; + riskLevel: 'critical' | 'high' | 'medium' | 'low'; +} + +interface TechnicalDebtData { + totalApplications: number; + applications: TechnicalDebtApplication[]; + byStatus: Record; + byBIA: Record; + byRiskLevel: Record; +} + +const API_BASE = '/api'; + +// BIA levels ordered by impact (F = highest, A = lowest) +const BIA_LEVELS = ['F', 'E', 'D', 'C', 'B', 'A']; +const BIA_LABELS: Record = { + 'F': 'F - Levensbedreigend', + 'E': 'E - Kritiek zorgondersteunend', + 'D': 'D - Belangrijke zorgprocessen', + 'C': 'C - Standaard bedrijfsvoering', + 'B': 'B - Ondersteunende tooling', + 'A': 'A - Test/ontwikkelomgeving', +}; + +// Status levels ordered by severity +const STATUS_LEVELS = ['End of life', 'End of support', 'Deprecated']; +const STATUS_LABELS: Record = { + 'End of life': 'End of Life', + 'End of support': 'End of Support', + 'Deprecated': 'Deprecated', +}; + +// Risk calculation: High BIA (F, E, D) + EOL/EOS/Deprecated = critical risk +function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' { + if (!status || !bia) return 'low'; + + const isHighBIA = ['F', 'E', 'D'].includes(bia); + const isEOL = status === 'End of life'; + const isEOS = status === 'End of support'; + const isDeprecated = status === 'Deprecated'; + + if (isHighBIA && (isEOL || isEOS || isDeprecated)) { + if (isEOL && ['F', 'E'].includes(bia)) return 'critical'; + if (isEOL || (isEOS && bia === 'F')) return 'critical'; + if (isHighBIA) return 'high'; + } + + if (isEOL || isEOS) return 'high'; + if (isDeprecated) return 'medium'; + + return 'low'; +} + +function getRiskColor(riskLevel: string): string { + switch (riskLevel) { + case 'critical': + return 'bg-red-600 text-white'; + case 'high': + return 'bg-orange-500 text-white'; + case 'medium': + return 'bg-yellow-400 text-gray-900'; + case 'low': + return 'bg-gray-200 text-gray-700'; + default: + return 'bg-gray-100 text-gray-600'; + } +} + +function getRiskLabel(riskLevel: string): string { + switch (riskLevel) { + case 'critical': + return 'Kritiek'; + case 'high': + return 'Hoog'; + case 'medium': + return 'Gemiddeld'; + case 'low': + return 'Laag'; + default: + return 'Onbekend'; + } +} + +export default function TechnicalDebtHeatmap() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedStatus, setSelectedStatus] = useState(null); + const [selectedBIA, setSelectedBIA] = useState(null); + const [selectedRiskLevel, setSelectedRiskLevel] = useState(null); + + useEffect(() => { + async function fetchData() { + setLoading(true); + setError(null); + + try { + const response = await fetch(`${API_BASE}/dashboard/technical-debt`); + if (!response.ok) { + throw new Error('Failed to fetch technical debt data'); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return ( +
+

Geen data beschikbaar

+
+ ); + } + + // Filter applications based on search and filters + const filteredApplications = data.applications.filter(app => { + const matchesSearch = !searchQuery || + app.name.toLowerCase().includes(searchQuery.toLowerCase()) || + app.key.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesStatus = !selectedStatus || app.status === selectedStatus; + const matchesBIA = !selectedBIA || app.businessImpactAnalyse === selectedBIA; + const matchesRisk = !selectedRiskLevel || app.riskLevel === selectedRiskLevel; + + return matchesSearch && matchesStatus && matchesBIA && matchesRisk; + }); + + // Build heatmap data: status x BIA matrix + const heatmapData: Record> = {}; + STATUS_LEVELS.forEach(status => { + heatmapData[status] = {}; + BIA_LEVELS.forEach(bia => { + heatmapData[status][bia] = 0; + }); + }); + + data.applications.forEach(app => { + if (app.status && app.businessImpactAnalyse && heatmapData[app.status]) { + heatmapData[app.status][app.businessImpactAnalyse] = + (heatmapData[app.status][app.businessImpactAnalyse] || 0) + 1; + } + }); + + // Get max count for color intensity + const maxCount = Math.max( + ...Object.values(heatmapData).flatMap(row => Object.values(row)) + ); + + return ( +
+ {/* Header */} +
+

Technical Debt Heatmap

+

+ Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie. + Hoge BIA + EOL = kritiek risico. +

+
+ + {/* Summary Cards */} +
+
+
Totaal Applicaties
+
{data.totalApplications}
+
+
+
Kritiek Risico
+
{data.byRiskLevel.critical || 0}
+
+
+
Hoog Risico
+
{data.byRiskLevel.high || 0}
+
+
+
Gemiddeld Risico
+
{data.byRiskLevel.medium || 0}
+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Naam of key..." + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Status Filter */} +
+ + +
+ + {/* BIA Filter */} +
+ + +
+ + {/* Risk Level Filter */} +
+ + +
+
+
+ + {/* Heatmap */} +
+

Heatmap: Status × BIA Classificatie

+
+ + + + + {BIA_LEVELS.map(bia => ( + + ))} + + + + + {STATUS_LEVELS.map(status => { + const rowTotal = BIA_LEVELS.reduce( + (sum, bia) => sum + (heatmapData[status][bia] || 0), + 0 + ); + + return ( + + + {BIA_LEVELS.map(bia => { + const count = heatmapData[status][bia] || 0; + const intensity = maxCount > 0 ? count / maxCount : 0; + const bgColor = intensity > 0.7 + ? 'bg-red-600' + : intensity > 0.4 + ? 'bg-orange-500' + : intensity > 0.1 + ? 'bg-yellow-400' + : 'bg-gray-100'; + const textColor = intensity > 0.1 ? 'text-white' : 'text-gray-700'; + + return ( + + ); + })} + + + ); + })} + + + {BIA_LEVELS.map(bia => { + const colTotal = STATUS_LEVELS.reduce( + (sum, status) => sum + (heatmapData[status][bia] || 0), + 0 + ); + return ( + + ); + })} + + + +
+ Status \ BIA + + {BIA_LABELS[bia]} + + Totaal +
+ {STATUS_LABELS[status]} + + {count > 0 ? count : '-'} + + {rowTotal} +
+ Totaal + + {colTotal} + + {data.totalApplications} +
+
+
+
+
+ Hoog (≥70%) +
+
+
+ Gemiddeld (40-70%) +
+
+
+ Laag (10-40%) +
+
+
+ Geen data +
+
+
+ + {/* Applications List */} +
+
+

+ Applicaties ({filteredApplications.length}) +

+
+
+ + + + + + + + + + + + {filteredApplications.length === 0 ? ( + + + + ) : ( + filteredApplications.map((app) => ( + + + + + + + + )) + )} + +
+ Key + + Naam + + Status + + BIA + + Risico +
+ Geen applicaties gevonden +
+ {app.key} + + + {app.name} + + + {app.status || '-'} + + {app.businessImpactAnalyse + ? `${app.businessImpactAnalyse} - ${BIA_LABELS[app.businessImpactAnalyse] || app.businessImpactAnalyse}` + : '-' + } + + + {getRiskLabel(app.riskLevel)} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ZiRADomainCoverage.tsx b/frontend/src/components/ZiRADomainCoverage.tsx new file mode 100644 index 0000000..8e8e91e --- /dev/null +++ b/frontend/src/components/ZiRADomainCoverage.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +interface ReferenceValue { + objectId: string; + key: string; + name: string; +} + +interface FunctionCoverage { + functionId: string; + functionKey: string; + functionName: string; + functionDescription: string | null; + category: ReferenceValue | null; + applicationCount: number; + applications: Array<{ id: string; key: string; name: string }>; + coverageStatus: 'gap' | 'low' | 'medium' | 'well-supported'; +} + +interface CategoryCoverage { + category: ReferenceValue; + functions: FunctionCoverage[]; + totalFunctions: number; + coveredFunctions: number; + gapFunctions: number; +} + +interface ZiRADomainCoverageData { + overall: { + totalFunctions: number; + coveredFunctions: number; + gapFunctions: number; + lowCoverageFunctions: number; + mediumCoverageFunctions: number; + wellSupportedFunctions: number; + coveragePercentage: number; + }; + byCategory: CategoryCoverage[]; + allFunctions: FunctionCoverage[]; +} + +const API_BASE = '/api'; + +function getCoverageStatusColor(status: string): string { + switch (status) { + case 'well-supported': + return 'bg-green-100 text-green-800 border-green-300'; + case 'medium': + return 'bg-blue-100 text-blue-800 border-blue-300'; + case 'low': + return 'bg-yellow-100 text-yellow-800 border-yellow-300'; + case 'gap': + return 'bg-red-100 text-red-800 border-red-300'; + default: + return 'bg-gray-100 text-gray-800 border-gray-300'; + } +} + +function getCoverageStatusLabel(status: string): string { + switch (status) { + case 'well-supported': + return 'Goed ondersteund'; + case 'medium': + return 'Gemiddeld'; + case 'low': + return 'Laag'; + case 'gap': + return 'Gap'; + default: + return 'Onbekend'; + } +} + +export default function ZiRADomainCoverage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedStatus, setSelectedStatus] = useState(null); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + + useEffect(() => { + async function fetchData() { + try { + setLoading(true); + setError(null); + const response = await fetch(`${API_BASE}/dashboard/zira-domain-coverage`); + if (!response.ok) { + throw new Error('Failed to fetch ZiRA domain coverage data'); + } + const result = await response.json(); + setData(result); + // Expand all categories by default + if (result.byCategory) { + setExpandedCategories(new Set(result.byCategory.map((cat: CategoryCoverage) => cat.category.objectId))); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load data'); + } finally { + setLoading(false); + } + } + fetchData(); + }, []); + + const toggleCategory = (categoryId: string) => { + setExpandedCategories(prev => { + const newSet = new Set(prev); + if (newSet.has(categoryId)) { + newSet.delete(categoryId); + } else { + newSet.add(categoryId); + } + return newSet; + }); + }; + + // Filter functions based on search and filters + const filteredCategories = data?.byCategory + ? data.byCategory.map(category => { + let filteredFunctions = category.functions; + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filteredFunctions = filteredFunctions.filter( + f => + f.functionName.toLowerCase().includes(query) || + f.functionKey.toLowerCase().includes(query) || + (f.functionDescription && f.functionDescription.toLowerCase().includes(query)) + ); + } + + // Filter by category + if (selectedCategory && category.category.objectId !== selectedCategory) { + filteredFunctions = []; + } + + // Filter by status + if (selectedStatus) { + filteredFunctions = filteredFunctions.filter(f => f.coverageStatus === selectedStatus); + } + + return { + ...category, + functions: filteredFunctions, + }; + }) + : []; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return
Geen data beschikbaar
; + } + + return ( +
+ {/* Header */} +
+

ZiRA Domain Coverage

+

+ Analyse van welke ZiRA functies goed ondersteund worden versus gaps in IT coverage +

+
+ + {/* Overall Statistics */} +
+
+
Totaal functies
+
{data.overall.totalFunctions}
+
+
+
Ondersteund
+
{data.overall.coveredFunctions}
+
+ {data.overall.coveragePercentage.toFixed(1)}% coverage +
+
+
+
Gaps
+
{data.overall.gapFunctions}
+
+ Geen applicaties +
+
+
+
Goed ondersteund
+
{data.overall.wellSupportedFunctions}
+
+ ≥10 applicaties +
+
+
+ + {/* Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Zoek op functie naam of key..." + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + +
+
+ + +
+
+
+ + {/* Coverage by Category */} +
+ {filteredCategories.map(category => { + const isExpanded = expandedCategories.has(category.category.objectId); + const hasFunctions = category.functions.length > 0; + + if (!hasFunctions) return null; + + return ( +
+ {/* Category Header */} +
toggleCategory(category.category.objectId)} + className="px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors" + > +
+
+ + + +

{category.category.name}

+ + ({category.functions.length} van {category.totalFunctions}) + +
+
+ + {category.coveredFunctions} ondersteund + + + {category.gapFunctions} gaps + +
+
+
+ + {/* Category Functions */} + {isExpanded && ( +
+ {category.functions.map(func => ( +
+
+
+
+ {func.functionKey} +

{func.functionName}

+ + {getCoverageStatusLabel(func.coverageStatus)} + +
+ {func.functionDescription && ( +

{func.functionDescription}

+ )} +
+ + {func.applicationCount} applicatie{func.applicationCount !== 1 ? 's' : ''} + + {func.applications.length > 0 && ( +
+ + Bekijk applicaties + +
+ {func.applications.map(app => ( +
+ + {app.name} + + ({app.key}) +
+ ))} +
+
+ )} +
+
+
+
+ ))} +
+ )} +
+ ); + })} +
+ + {/* Summary of Gaps */} + {data.overall.gapFunctions > 0 && ( +
+

+ Functies zonder IT ondersteuning ({data.overall.gapFunctions}) +

+
+ {data.allFunctions + .filter(f => f.coverageStatus === 'gap') + .map(func => ( +
+
{func.functionName}
+
{func.functionKey}
+ {func.category && ( +
Categorie: {func.category.name}
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3d43d93..1780a02 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,6 +10,8 @@ import type { TeamDashboardData, ApplicationStatus, EffortCalculationBreakdown, + BIAComparisonResponse, + BusinessImportanceComparisonResponse, } from '../types'; const API_BASE = '/api'; @@ -426,6 +428,30 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] return fetchApi(`/applications/team-dashboard?${queryString}`); } +// ============================================================================= +// Team Portfolio Health +// ============================================================================= + +export interface TeamPortfolioHealthData { + teams: Array<{ + team: ReferenceValue | null; + metrics: { + complexity: number; + dynamics: number; + bia: number; + governanceMaturity: number; + }; + applicationCount: number; + }>; +} + +export async function getTeamPortfolioHealth(excludedStatuses: ApplicationStatus[] = []): Promise { + const params = new URLSearchParams(); + params.append('excludedStatuses', excludedStatuses.join(',')); + const queryString = params.toString(); + return fetchApi(`/applications/team-portfolio-health?${queryString}`); +} + // ============================================================================= // Configuration // ============================================================================= @@ -676,8 +702,64 @@ export interface SchemaResponse { totalAttributes: number; }; objectTypes: Record; + cacheCounts?: Record; // Cache counts by type name (from objectsByType) + jiraCounts?: Record; // Actual counts from Jira Assets API } export async function getSchema(): Promise { return fetchApi('/schema'); } + +// ============================================================================= +// Data Completeness Configuration +// ============================================================================= + +export interface CompletenessFieldConfig { + id: string; + name: string; + fieldPath: string; + enabled: boolean; +} + +export interface CompletenessCategoryConfig { + id: string; + name: string; + description: string; + fields: CompletenessFieldConfig[]; +} + +export interface DataCompletenessConfig { + metadata: { + version: string; + description: string; + lastUpdated: string; + }; + categories: CompletenessCategoryConfig[]; // Array of categories (dynamic) +} + +export async function getDataCompletenessConfig(): Promise { + return fetchApi('/configuration/data-completeness'); +} + +export async function updateDataCompletenessConfig(config: DataCompletenessConfig): Promise<{ success: boolean; message: string }> { + return fetchApi<{ success: boolean; message: string }>('/configuration/data-completeness', { + method: 'PUT', + body: JSON.stringify(config), + }); +} + +// ============================================================================= +// BIA Comparison +// ============================================================================= + +export async function getBIAComparison(): Promise { + return fetchApi('/applications/bia-comparison'); +} + +// ============================================================================= +// Business Importance vs BIA Comparison +// ============================================================================= + +export async function getBusinessImportanceComparison(): Promise { + return fetchApi('/applications/business-importance-comparison'); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a22e03e..9e6afd7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -47,8 +47,10 @@ export interface ApplicationListItem { minFTE?: number | null; // Minimum FTE from configuration range maxFTE?: number | null; // Maximum FTE from configuration range overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value) + businessImpactAnalyse?: ReferenceValue | null; // Business Impact Analyse applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM + dataCompletenessPercentage?: number; // Data completeness percentage (0-100) } // Full application details @@ -85,6 +87,7 @@ export interface ApplicationDetails { applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572) + dataCompletenessPercentage?: number; // Data completeness percentage (0-100) } // Search filters @@ -355,3 +358,61 @@ export interface ChatResponse { message: ChatMessage; suggestion?: AISuggestion; // Updated suggestion if AI provided one } + +// BIA Comparison types +export interface BIAComparisonItem { + id: string; + key: string; + name: string; + searchReference: string | null; + currentBIA: ReferenceValue | null; + excelBIA: string | null; + excelApplicationName: string | null; + matchStatus: 'match' | 'mismatch' | 'not_found' | 'no_excel_bia'; + matchType: 'exact' | 'search_reference' | 'fuzzy' | null; + matchConfidence?: number; + allMatches?: Array<{ + excelApplicationName: string; + biaValue: string; + matchType: 'exact' | 'search_reference' | 'partial_starts' | 'partial_contains' | 'fuzzy'; + confidence: number; + }>; +} + +export interface BIAComparisonResponse { + applications: BIAComparisonItem[]; + summary: { + total: number; + matched: number; + mismatched: number; + notFound: number; + noExcelBIA: number; + }; +} + +// Business Importance vs BIA Comparison types +export interface BusinessImportanceComparisonItem { + id: string; + key: string; + name: string; + searchReference: string | null; + businessImportance: string | null; + businessImportanceNormalized: number | null; // 0-6 scale + businessImpactAnalyse: ReferenceValue | null; + biaClass: string | null; // A-F + biaClassNormalized: number | null; // 1-6 scale (A=1, F=6) + discrepancyScore: number; // Absolute difference + discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data'; +} + +export interface BusinessImportanceComparisonResponse { + applications: BusinessImportanceComparisonItem[]; + summary: { + total: number; + withBothFields: number; + highBiLowBia: number; // High Business Importance, Low BIA + lowBiHighBia: number; // Low Business Importance, High BIA + aligned: number; + missingData: number; + }; +} diff --git a/management-parameters.json b/management-parameters.json deleted file mode 100644 index 10a6f3c..0000000 --- a/management-parameters.json +++ /dev/null @@ -1,284 +0,0 @@ -{ - "version": "2024.1", - "source": "Zuyderland ICMT - Application Management Framework", - "lastUpdated": "2024-12-19", - "referenceData": { - "applicationStatuses": [ - { - "key": "status", - "name": "Status", - "description": "Algemene status", - "order": 0, - "color": "#6b7280", - "includeInFilter": true - }, - { - "key": "prod", - "name": "In Production", - "description": "Productie - actief in gebruik", - "order": 1, - "color": "#22c55e", - "includeInFilter": true - }, - { - "key": "impl", - "name": "Implementation", - "description": "In implementatie", - "order": 2, - "color": "#3b82f6", - "includeInFilter": true - }, - { - "key": "poc", - "name": "Proof of Concept", - "description": "Proefproject", - "order": 3, - "color": "#8b5cf6", - "includeInFilter": true - }, - { - "key": "eos", - "name": "End of support", - "description": "Geen ondersteuning meer van leverancier", - "order": 4, - "color": "#f97316", - "includeInFilter": true - }, - { - "key": "eol", - "name": "End of life", - "description": "Einde levensduur, wordt uitgefaseerd", - "order": 5, - "color": "#ef4444", - "includeInFilter": true - }, - { - "key": "deprecated", - "name": "Deprecated", - "description": "Verouderd, wordt uitgefaseerd", - "order": 6, - "color": "#f97316", - "includeInFilter": true - }, - { - "key": "shadow", - "name": "Shadow IT", - "description": "Niet-geautoriseerde IT", - "order": 7, - "color": "#eab308", - "includeInFilter": true - }, - { - "key": "closed", - "name": "Closed", - "description": "Afgesloten", - "order": 8, - "color": "#6b7280", - "includeInFilter": true - }, - { - "key": "undefined", - "name": "Undefined", - "description": "Niet gedefinieerd", - "order": 9, - "color": "#9ca3af", - "includeInFilter": true - } - ], - "dynamicsFactors": [ - { - "key": "1", - "name": "Stabiel", - "description": "Weinig wijzigingen, uitgekristalliseerd systeem, < 2 releases/jaar", - "order": 1, - "color": "#22c55e" - }, - { - "key": "2", - "name": "Gemiddeld", - "description": "Regelmatige wijzigingen, 2-4 releases/jaar, incidentele projecten", - "order": 2, - "color": "#eab308" - }, - { - "key": "3", - "name": "Hoog", - "description": "Veel wijzigingen, > 4 releases/jaar, continue doorontwikkeling", - "order": 3, - "color": "#f97316" - }, - { - "key": "4", - "name": "Zeer hoog", - "description": "Continu in beweging, grote transformatieprojecten, veel nieuwe functionaliteit", - "order": 4, - "color": "#ef4444" - } - ], - "complexityFactors": [ - { - "key": "1", - "name": "Laag", - "description": "Standalone applicatie, geen/weinig integraties, standaard configuratie", - "order": 1, - "color": "#22c55e" - }, - { - "key": "2", - "name": "Gemiddeld", - "description": "Enkele integraties, beperkt maatwerk, standaard governance", - "order": 2, - "color": "#eab308" - }, - { - "key": "3", - "name": "Hoog", - "description": "Veel integraties, significant maatwerk, meerdere stakeholdergroepen", - "order": 3, - "color": "#f97316" - }, - { - "key": "4", - "name": "Zeer hoog", - "description": "Platform met meerdere workloads, uitgebreide governance, veel maatwerk", - "order": 4, - "color": "#ef4444" - } - ], - "numberOfUsers": [ - { - "key": "1", - "name": "< 100", - "minUsers": 0, - "maxUsers": 99, - "order": 1 - }, - { - "key": "2", - "name": "100 - 500", - "minUsers": 100, - "maxUsers": 500, - "order": 2 - }, - { - "key": "3", - "name": "500 - 2.000", - "minUsers": 500, - "maxUsers": 2000, - "order": 3 - }, - { - "key": "4", - "name": "2.000 - 5.000", - "minUsers": 2000, - "maxUsers": 5000, - "order": 4 - }, - { - "key": "5", - "name": "5.000 - 10.000", - "minUsers": 5000, - "maxUsers": 10000, - "order": 5 - }, - { - "key": "6", - "name": "10.000 - 15.000", - "minUsers": 10000, - "maxUsers": 15000, - "order": 6 - }, - { - "key": "7", - "name": "> 15.000", - "minUsers": 15000, - "maxUsers": null, - "order": 7 - } - ], - "governanceModels": [ - { - "key": "A", - "name": "Centraal Beheer", - "shortDescription": "ICMT voert volledig beheer uit", - "description": "Volledige dienstverlening door ICMT. Dit is het standaardmodel voor kernapplicaties.", - "applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.", - "icmtInvolvement": "Volledig", - "businessInvolvement": "Minimaal", - "supplierInvolvement": "Via ICMT", - "order": 1, - "color": "#3b82f6" - }, - { - "key": "B", - "name": "Federatief Beheer", - "shortDescription": "ICMT + business delen beheer", - "description": "ICMT en business delen de verantwoordelijkheid. Geschikt voor applicaties met een sterke key user organisatie.", - "applicability": "Kernapplicaties met BIA-classificatie D, E of F (belangrijk tot zeer kritiek). Voorbeelden: EPD (HiX), ERP (AFAS), Microsoft 365, kritieke zorgapplicaties.", - "icmtInvolvement": "Gedeeld", - "businessInvolvement": "Gedeeld", - "supplierInvolvement": "Via ICMT/Business", - "order": 2, - "color": "#8b5cf6" - }, - { - "key": "C", - "name": "Uitbesteed met ICMT-Regie", - "shortDescription": "Leverancier beheert, ICMT regisseert", - "description": "Leverancier voert beheer uit, ICMT houdt regie. Dit is het standaardmodel voor SaaS waar ICMT contractpartij is.", - "applicability": "SaaS-applicaties waar ICMT het contract beheert. Voorbeelden: AFAS, diverse zorg-SaaS oplossingen. De mate van FAB-dienstverlening hangt af van de BIA-classificatie.", - "icmtInvolvement": "Regie", - "businessInvolvement": "Gebruiker", - "supplierInvolvement": "Volledig beheer", - "contractHolder": "ICMT", - "order": 3, - "color": "#06b6d4" - }, - { - "key": "D", - "name": "Uitbesteed met Business-Regie", - "shortDescription": "Leverancier beheert, business regisseert", - "description": "Business onderhoudt de leveranciersrelatie. ICMT heeft beperkte betrokkenheid.", - "applicability": "SaaS-applicaties waar de business zelf het contract en de leveranciersrelatie beheert. Voorbeelden: niche SaaS tools, afdelingsspecifieke oplossingen, tools waar de business expertise heeft die ICMT niet heeft.", - "icmtInvolvement": "Beperkt", - "businessInvolvement": "Regie", - "supplierInvolvement": "Volledig beheer", - "contractHolder": "Business", - "order": 4, - "color": "#14b8a6" - }, - { - "key": "E", - "name": "Volledig Decentraal Beheer", - "shortDescription": "Business voert volledig beheer uit", - "description": "Business voert zelf beheer uit. ICMT heeft minimale betrokkenheid.", - "applicability": "Afdelingsspecifieke tools met beperkte impact, Shadow IT die in kaart is gebracht. Voorbeelden: standalone afdelingstools, pilotapplicaties, persoonlijke productiviteitstools.", - "icmtInvolvement": "Minimaal", - "businessInvolvement": "Volledig", - "supplierInvolvement": "Direct met business", - "order": 5, - "color": "#6b7280" - } - ] - }, - "visualizations": { - "capacityMatrix": { - "description": "Matrix voor capaciteitsplanning gebaseerd op Dynamiek x Complexiteit", - "formula": "Beheerlast = Dynamiek * Complexiteit * log(Gebruikers)", - "weightings": { - "dynamics": 1.0, - "complexity": 1.2, - "users": 0.3 - } - }, - "governanceDecisionTree": { - "description": "Beslisboom voor keuze regiemodel", - "factors": [ - "BIA-classificatie", - "Hosting type (SaaS/On-prem)", - "Contracthouder", - "Key user maturity" - ] - } - } -} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..b5a88d9 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,123 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 10M; + + # Gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m; + limit_req_zone $binary_remote_addr zone=general_limit:10m rate=200r/m; + + # SSL Configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Upstream backend + upstream backend { + server backend:3001; + keepalive 32; + } + + # Upstream frontend + upstream frontend { + server frontend:80; + } + + # HTTP to HTTPS redirect + server { + listen 80; + server_name _; + return 301 https://$host$request_uri; + } + + # HTTPS server + server { + listen 443 ssl http2; + server_name _; + + # SSL certificates (pas aan naar je eigen certificaten) + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://jira.zuyderland.nl;" always; + + # API routes with rate limiting + location /api { + limit_req zone=api_limit burst=20 nodelay; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_cache_bypass $http_upgrade; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 300s; + } + + # Frontend + location / { + limit_req zone=general_limit burst=50 nodelay; + + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Health check (no rate limit) + location /health { + access_log off; + proxy_pass http://backend/health; + } + } +} diff --git a/package-lock.json b/package-lock.json index 9edbd9e..93395df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "express-rate-limit": "^7.4.1", "helmet": "^8.0.0", "openai": "^6.15.0", + "pg": "^8.13.1", "winston": "^3.17.0", "xlsx": "^0.18.5" }, @@ -37,6 +38,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.9.0", + "@types/pg": "^8.11.10", "@types/xlsx": "^0.0.35", "tsx": "^4.19.2", "typescript": "^5.6.3" @@ -50,6 +52,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", + "recharts": "^3.6.0", "zustand": "^5.0.1" }, "devDependencies": { @@ -948,6 +951,42 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", @@ -1282,6 +1321,18 @@ "text-hex": "1.0.x" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1378,6 +1429,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1436,6 +1550,18 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1505,6 +1631,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/xlsx": { "version": "0.0.35", "resolved": "https://registry.npmjs.org/@types/xlsx/-/xlsx-0.0.35.tgz", @@ -2293,6 +2425,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -2328,6 +2581,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2518,6 +2777,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2594,6 +2863,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3109,6 +3384,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3121,6 +3406,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3685,6 +3979,95 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3888,6 +4271,45 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -4037,6 +4459,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", + "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4116,6 +4568,51 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4126,6 +4623,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4504,6 +5007,15 @@ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", "dev": true }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -4727,6 +5239,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4941,6 +5459,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4965,6 +5492,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", @@ -5195,6 +5744,15 @@ "node": ">=0.8" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/scripts/backup-database.sh b/scripts/backup-database.sh new file mode 100755 index 0000000..c6824ed --- /dev/null +++ b/scripts/backup-database.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Backup script for CMDB cache database +# Run daily via cron: 0 2 * * * /path/to/backup-database.sh + +BACKUP_DIR="${BACKUP_DIR:-./backups}" +DATE=$(date +%Y%m%d_%H%M%S) +CONTAINER_NAME="${CONTAINER_NAME:-zuyderland-cmdb-gui-backend-1}" + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Backup SQLite database +echo "Backing up CMDB cache database..." +docker exec "$CONTAINER_NAME" sqlite3 /app/data/cmdb-cache.db ".backup '/tmp/cmdb-cache-$DATE.db'" +docker cp "$CONTAINER_NAME:/tmp/cmdb-cache-$DATE.db" "$BACKUP_DIR/cmdb-cache-$DATE.db" +docker exec "$CONTAINER_NAME" rm "/tmp/cmdb-cache-$DATE.db" + +# Compress backup +echo "Compressing backup..." +gzip "$BACKUP_DIR/cmdb-cache-$DATE.db" + +# Cleanup old backups (keep last 30 days) +echo "Cleaning up old backups..." +find "$BACKUP_DIR" -name "cmdb-cache-*.db.gz" -mtime +30 -delete + +echo "Backup completed: $BACKUP_DIR/cmdb-cache-$DATE.db.gz" diff --git a/scripts/build-and-push.sh b/scripts/build-and-push.sh new file mode 100755 index 0000000..a7150ea --- /dev/null +++ b/scripts/build-and-push.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + +# Configuration - pas aan naar jouw Gitea instellingen +GITEA_HOST="${GITEA_HOST:-git.zuyderland.nl}" +REPO_PATH="${REPO_PATH:-icmt/cmdb-gui}" +VERSION="${1:-latest}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🔨 Building Docker images...${NC}" +echo "Registry: ${GITEA_HOST}/${REPO_PATH}" +echo "Version: ${VERSION}" +echo "" + +# Check if logged in +if ! docker info | grep -q "Username"; then + echo -e "${YELLOW}⚠️ Not logged in to Docker registry${NC}" + echo "Please login first:" + echo " docker login ${GITEA_HOST}" + exit 1 +fi + +# Build backend +echo -e "${GREEN}📦 Building backend...${NC}" +docker build -t ${GITEA_HOST}/${REPO_PATH}/backend:${VERSION} \ + -f backend/Dockerfile.prod ./backend + +# Build frontend +echo -e "${GREEN}📦 Building frontend...${NC}" +docker build -t ${GITEA_HOST}/${REPO_PATH}/frontend:${VERSION} \ + -f frontend/Dockerfile.prod ./frontend + +# Push images +echo -e "${GREEN}📤 Pushing images to registry...${NC}" +docker push ${GITEA_HOST}/${REPO_PATH}/backend:${VERSION} +docker push ${GITEA_HOST}/${REPO_PATH}/frontend:${VERSION} + +echo "" +echo -e "${GREEN}✅ Build and push complete!${NC}" +echo "" +echo "To deploy, run:" +echo " docker-compose -f docker-compose.prod.registry.yml pull" +echo " docker-compose -f docker-compose.prod.registry.yml up -d" +echo "" +echo "Or use the deploy script:" +echo " ./scripts/deploy.sh ${VERSION}" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..a164ed5 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -e + +VERSION="${1:-latest}" +COMPOSE_FILE="docker-compose.prod.registry.yml" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 Deploying version: ${VERSION}${NC}" +echo "" + +# Check if compose file exists +if [ ! -f "$COMPOSE_FILE" ]; then + echo -e "${RED}❌ Compose file not found: ${COMPOSE_FILE}${NC}" + echo "Please create it first (see docs/GITEA-DOCKER-REGISTRY.md)" + exit 1 +fi + +# Check if logged in +if ! docker info | grep -q "Username"; then + echo -e "${YELLOW}⚠️ Not logged in to Docker registry${NC}" + echo "Please login first:" + echo " docker login " + exit 1 +fi + +# Update image tags in compose file if using version tags +if [ "$VERSION" != "latest" ]; then + echo -e "${YELLOW}📝 Updating image tags to v${VERSION}...${NC}" + # Create backup + cp ${COMPOSE_FILE} ${COMPOSE_FILE}.bak + # Replace :latest with :v${VERSION} in image tags + sed -i.tmp "s|:latest|:v${VERSION}|g" ${COMPOSE_FILE} + rm ${COMPOSE_FILE}.tmp 2>/dev/null || true +fi + +# Pull latest images +echo -e "${GREEN}📥 Pulling images...${NC}" +docker-compose -f ${COMPOSE_FILE} pull + +# Deploy +echo -e "${GREEN}🚀 Starting services...${NC}" +docker-compose -f ${COMPOSE_FILE} up -d + +# Wait a bit for services to start +echo -e "${YELLOW}⏳ Waiting for services to start...${NC}" +sleep 5 + +# Show status +echo -e "${GREEN}📊 Service status:${NC}" +docker-compose -f ${COMPOSE_FILE} ps + +# Cleanup old images (optional) +echo -e "${YELLOW}🧹 Cleaning up unused images...${NC}" +docker image prune -f + +echo "" +echo -e "${GREEN}✅ Deployment complete!${NC}" +echo "" +echo "View logs:" +echo " docker-compose -f ${COMPOSE_FILE} logs -f" +echo "" +echo "Check status:" +echo " docker-compose -f ${COMPOSE_FILE} ps" +echo "" +echo "Stop services:" +echo " docker-compose -f ${COMPOSE_FILE} down" diff --git a/scripts/generate-session-secret.sh b/scripts/generate-session-secret.sh new file mode 100755 index 0000000..cac7ce9 --- /dev/null +++ b/scripts/generate-session-secret.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Generate a secure random session secret for production use + +echo "Generating secure SESSION_SECRET..." +SECRET=$(openssl rand -hex 32) +echo "" +echo "Add this to your .env.production file:" +echo "SESSION_SECRET=$SECRET" +echo "" diff --git a/zira-taxonomy.json b/zira-taxonomy.json deleted file mode 100644 index 82da0ac..0000000 --- a/zira-taxonomy.json +++ /dev/null @@ -1,649 +0,0 @@ -{ - "version": "2024.1", - "source": "ZiRA - Ziekenhuis Referentie Architectuur (Nictiz)", - "lastUpdated": "2024-12-19", - "domains": [ - { - "code": "STU", - "name": "Sturing", - "description": "Applicatiefuncties ter ondersteuning van besturing en management", - "functions": [ - { - "code": "STU-001", - "name": "Beleid & Innovatie", - "description": "Functionaliteit voor ondersteuning van het bepalen en beheren van beleid, ontwikkeling producten & diensten, planning & control cyclus en ondersteunende managementinformatie", - "keywords": ["beleid", "innovatie", "strategie", "planning", "control", "managementinformatie", "BI", "business intelligence"] - }, - { - "code": "STU-002", - "name": "Proces & Architectuur", - "description": "Functionaliteit voor het ontwikkelen en beheren van de enterprise architectuur (organisatie, processen, informatie, applicatie, techniek)", - "keywords": ["architectuur", "proces", "enterprise", "TOGAF", "ArchiMate", "modellering", "BPM"] - }, - { - "code": "STU-003", - "name": "Project & Portfoliomanagement", - "description": "Functionaliteit voor het beheren van projecten en programma's", - "keywords": ["project", "portfolio", "programma", "PMO", "planning", "resource", "Jira", "MS Project"] - }, - { - "code": "STU-004", - "name": "Kwaliteitsinformatiemanagement", - "description": "Functionaliteit voor de ondersteuning van het maken, verwerken en beheren van kwaliteitsdocumenten (inclusief protocollen)", - "keywords": ["kwaliteit", "protocol", "procedure", "document", "QMS", "ISO", "accreditatie", "Zenya"] - }, - { - "code": "STU-005", - "name": "Performance & Verantwoording", - "description": "Functionaliteit voor het beheren van productieafspraken, KPI's inclusief beheer van de verantwoording in het kader van wet & regelgeving alsmede prestaties en maatschappelijk verantwoordschap", - "keywords": ["KPI", "dashboard", "verantwoording", "rapportage", "compliance", "prestatie", "IGJ"] - }, - { - "code": "STU-006", - "name": "Marketing & Contractmanagement", - "description": "Functionaliteit voor ondersteuning van marktanalyses en contractmanagement", - "keywords": ["marketing", "contract", "leverancier", "SLA", "marktanalyse", "CRM"] - } - ] - }, - { - "code": "ONZ", - "name": "Onderzoek", - "description": "Applicatiefuncties ter ondersteuning van wetenschappelijk onderzoek", - "functions": [ - { - "code": "ONZ-001", - "name": "Onderzoek ontwikkeling", - "description": "Functionaliteit voor de administratieve ondersteuning voor het indienen van een onderzoeksaanvraag, het opstellen van een onderzoeksprotocol, het opstellen van een onderzoeksvoorstel en de medisch etische keuring", - "keywords": ["onderzoek", "protocol", "METC", "ethiek", "aanvraag", "voorstel"] - }, - { - "code": "ONZ-002", - "name": "Onderzoekvoorbereiding", - "description": "Functionaliteit voor de administratieve voorbereiding van het onderzoek als aanvraag van vergunningen en financieringen", - "keywords": ["vergunning", "financiering", "subsidie", "grant", "voorbereiding"] - }, - { - "code": "ONZ-003", - "name": "Onderzoeksmanagement", - "description": "Functionaliteit voor de administratieve uitvoering van het onderzoek als aanvraag patientenselectie, verkrijgen consent", - "keywords": ["consent", "inclusie", "patientselectie", "trial", "studie", "CTMS"] - }, - { - "code": "ONZ-004", - "name": "Researchdatamanagement", - "description": "Functionaliteit voor het verzamelen, bewerken, analyseren en publiceren van onderzoeksdata", - "keywords": ["research", "data", "analyse", "statistiek", "SPSS", "R", "Castor", "REDCap"] - }, - { - "code": "ONZ-005", - "name": "Onderzoekpublicatie", - "description": "Functionaliteit voor de opslag van publicaties van onderzoeksresultaten", - "keywords": ["publicatie", "artikel", "repository", "Pure", "bibliografie"] - } - ] - }, - { - "code": "ZRG-SAM", - "name": "Zorg - Samenwerking", - "description": "Applicatiefuncties ter ondersteuning van samenwerking met patiënt en ketenpartners", - "functions": [ - { - "code": "ZRG-SAM-001", - "name": "Dossier inzage", - "description": "Functionaliteit die het mogelijk maakt voor patiënten om digitale inzage te krijgen in medische dossiers die de zorgverleners over hen bijhouden", - "keywords": ["portaal", "inzage", "dossier", "patient", "MijnZuyderland", "toegang"] - }, - { - "code": "ZRG-SAM-002", - "name": "Behandelondersteuning", - "description": "Functionaliteit voor het voorlichten en coachen van en communiceren met de patiënt over zijn zorg met als doel de patiënt te helpen bij het bereiken van de behandeldoelen en (mede)verantwoordelijkheid te geven voor behandelkeuzes en behandeling (patientempowerment)", - "keywords": ["voorlichting", "coaching", "empowerment", "educatie", "patient", "zelfmanagement"] - }, - { - "code": "ZRG-SAM-003", - "name": "Interactie PGO", - "description": "Functionaliteit voor ondersteuning en integraties met een persoonlijke gezondheidsomgeving", - "keywords": ["PGO", "PHR", "persoonlijk", "gezondheidsomgeving", "MedMij"] - }, - { - "code": "ZRG-SAM-004", - "name": "Patientenforum", - "description": "Functionaliteit voor het aanbieden van een online omgeving voor patienten (bv discussieforum voor patienten onderling)", - "keywords": ["forum", "community", "patient", "discussie", "lotgenoten"] - }, - { - "code": "ZRG-SAM-005", - "name": "Preventie", - "description": "Functionaliteit ter bevordering van de gezondheid en ter voorkoming van klachten en problemen", - "keywords": ["preventie", "screening", "gezondheid", "vroegdetectie", "risico"] - }, - { - "code": "ZRG-SAM-006", - "name": "Gezondheidsvragen", - "description": "Functionaliteit voor het on-line invullen van vragenlijsten bijvoorbeeld anamnestische vragenlijsten of gezondheidsvragenlijsten", - "keywords": ["vragenlijst", "anamnese", "intake", "PROM", "ePRO", "formulier"] - }, - { - "code": "ZRG-SAM-007", - "name": "Kwaliteit en tevredenheidsmeting", - "description": "Functionaliteit om de effecten van behandelingen en de patiënttevredenheid te kunnen meten en vaststellen", - "keywords": ["tevredenheid", "kwaliteit", "PREM", "CQI", "NPS", "enquete", "feedback"] - }, - { - "code": "ZRG-SAM-008", - "name": "Tele-consultatie", - "description": "Functionaliteit om een zorgprofessional remote (niet in elkaars fysieke aanwezigheid) te raadplegen in het kader van een gezondheidsvraag", - "keywords": ["teleconsultatie", "videoconsult", "beeldbellen", "remote", "consult"] - }, - { - "code": "ZRG-SAM-009", - "name": "Zelfmonitoring", - "description": "Functionaliteit om de eigen gezondheidstoestand te bewaken", - "keywords": ["zelfmonitoring", "thuismeten", "wearable", "app", "meten"] - }, - { - "code": "ZRG-SAM-010", - "name": "Tele-monitoring", - "description": "Functionaliteit waarmee de patient op afstand (tele) gevolgd en begeleid (monitoring) wordt door de zorgverlener met behulp van bij de patient aanwezige meetapparatuur", - "keywords": ["telemonitoring", "remote", "monitoring", "thuiszorg", "hartfalen", "COPD"] - }, - { - "code": "ZRG-SAM-011", - "name": "On-line afspraken", - "description": "Functionaliteit voor het on-line maken van afspraken", - "keywords": ["afspraak", "online", "boeken", "reserveren", "planning"] - }, - { - "code": "ZRG-SAM-012", - "name": "Dossieruitwisseling", - "description": "Functionaliteit voor het versturen en ontvangen en verwerken van dossierinformatie door bijvoorbeeld verwijzer, overdragende of consulterend arts", - "keywords": ["uitwisseling", "overdracht", "verwijzing", "XDS", "LSP", "Zorgplatform"] - }, - { - "code": "ZRG-SAM-013", - "name": "Interactie externe bronnen", - "description": "Functionaliteit voor informatieuitwisseling met derden voor het verzamelen van additionele gegevens", - "keywords": ["extern", "koppeling", "integratie", "bron", "register"] - }, - { - "code": "ZRG-SAM-014", - "name": "Samenwerking betrokken zorgverleners", - "description": "Functionaliteit voor het coördineren van zorg met andere zorgverleners en het documenteren daarvan", - "keywords": ["samenwerking", "keten", "MDO", "multidisciplinair", "consult"] - } - ] - }, - { - "code": "ZRG-CON", - "name": "Zorg - Consultatie & Behandeling", - "description": "Applicatiefuncties ter ondersteuning van het primaire zorgproces", - "functions": [ - { - "code": "ZRG-CON-001", - "name": "Dossierraadpleging", - "description": "Functionaliteit voor het raadplegen van het dossier via verschillende views als patiëntgeschiedenis, decursus, samenvatting, problemen, diagnoses en allergieën", - "keywords": ["dossier", "raadplegen", "EPD", "decursus", "samenvatting", "overzicht"] - }, - { - "code": "ZRG-CON-002", - "name": "Dossiervoering", - "description": "Functionaliteit voor het bijwerken van het dossier aan de hand van gegevens uit consult, behandeling en input vanuit andere bronnen", - "keywords": ["dossier", "registratie", "EPD", "notitie", "verslag", "brief"] - }, - { - "code": "ZRG-CON-003", - "name": "Medicatie", - "description": "Functionaliteit van de ondersteuning van de medicamenteuze behandeling", - "keywords": ["medicatie", "voorschrijven", "EVS", "apotheek", "recept", "CPOE"] - }, - { - "code": "ZRG-CON-004", - "name": "Operatie", - "description": "Functionaliteit voor de ondersteuning van het operatieve proces", - "keywords": ["OK", "operatie", "chirurgie", "planning", "anesthesie", "perioperatief"] - }, - { - "code": "ZRG-CON-005", - "name": "Patientbewaking", - "description": "Functionaliteit voor bewaking van de patienten (bv medische alarmering, monitoring, dwaaldetectie, valdetectie)", - "keywords": ["monitoring", "bewaking", "alarm", "IC", "telemetrie", "vitale functies"] - }, - { - "code": "ZRG-CON-006", - "name": "Beslissingsondersteuning", - "description": "Functionaliteit voor de ondersteuning van besluiten van de zorgverlener", - "keywords": ["CDSS", "beslissing", "advies", "alert", "waarschuwing", "protocol"] - }, - { - "code": "ZRG-CON-007", - "name": "Verzorgingondersteuning", - "description": "Functionaliteit voor de ondersteuning van het verzorgingsproces als aanvragen van verzorgingsdiensten", - "keywords": ["verzorging", "verpleging", "zorgplan", "ADL", "voeding"] - }, - { - "code": "ZRG-CON-008", - "name": "Ordermanagement", - "description": "Functionaliteit voor de uitvoering van de closed order loop van onderzoeken (aanvraag, planning, oplevering, acceptatie)", - "keywords": ["order", "aanvraag", "lab", "onderzoek", "workflow", "ORM"] - }, - { - "code": "ZRG-CON-009", - "name": "Resultaat afhandeling", - "description": "Functionaliteit voor de analyse en rapportage van resultaten en notificatie naar zorgverleners en/of patient", - "keywords": ["resultaat", "uitslag", "notificatie", "rapport", "bevinding"] - }, - { - "code": "ZRG-CON-010", - "name": "Kwaliteitsbewaking", - "description": "Functionaliteit voor de bewaking en signalering van (mogelijke) fouten (verkeerde patient, verkeerde dosis, verkeerde tijd, verkeerde vervolgstap)", - "keywords": ["kwaliteit", "veiligheid", "controle", "check", "alert", "CDSS"] - } - ] - }, - { - "code": "ZRG-AOZ", - "name": "Zorg - Aanvullend onderzoek", - "description": "Applicatiefuncties ter ondersteuning van diagnostisch onderzoek", - "functions": [ - { - "code": "ZRG-AOZ-001", - "name": "Laboratoriumonderzoek", - "description": "Functionaliteit voor de ondersteuning van processen op laboratoria (kcl, microbiologie, pathologie, klinische genetica, apotheeklab, etc)", - "keywords": ["lab", "LIMS", "laboratorium", "KCL", "microbiologie", "pathologie", "genetica"] - }, - { - "code": "ZRG-AOZ-002", - "name": "Beeldvormend onderzoek", - "description": "Functionaliteit voor de ondersteuning van Beeldvormend onderzoek voor bijvoorbeeld Radiologie, Nucleair, Cardologie inclusief beeldmanagement (zoals VNA)", - "keywords": ["PACS", "RIS", "radiologie", "CT", "MRI", "echo", "VNA", "DICOM"] - }, - { - "code": "ZRG-AOZ-003", - "name": "Functieonderzoek", - "description": "Functionaliteit voor de ondersteuning van Functieonderzoek (voorbeelden ECG, Longfunctie, Audiologie)", - "keywords": ["ECG", "longfunctie", "audiologie", "functie", "EEG", "EMG"] - } - ] - }, - { - "code": "ZRG-ZON", - "name": "Zorg - Zorgondersteuning", - "description": "Applicatiefuncties ter ondersteuning van de zorglogistiek", - "functions": [ - { - "code": "ZRG-ZON-001", - "name": "Zorgrelatiebeheer", - "description": "Functionaliteit voor beheren van alle gegevens van zorgrelaties (zorgaanbieders, zorgverleners, zorgverzekeraars e.d.)", - "keywords": ["AGB", "zorgverlener", "verwijzer", "huisarts", "verzekeraar", "register"] - }, - { - "code": "ZRG-ZON-002", - "name": "Zorgplanning", - "description": "Functionaliteit voor het maken en beheren van afspraken, opnames, overplaatsingen, ontslag en verwijzing", - "keywords": ["planning", "afspraak", "agenda", "opname", "ontslag", "bed"] - }, - { - "code": "ZRG-ZON-003", - "name": "Resource planning", - "description": "Functionaliteit voor het plannen van resources (personen, zorgverleners) en middelen", - "keywords": ["resource", "capaciteit", "rooster", "personeel", "middelen"] - }, - { - "code": "ZRG-ZON-004", - "name": "Patiëntadministratie", - "description": "Functionaliteit voor beheer van demografie, contactpersonen en alle andere (niet medische) informatie nodig voor het ondersteunen van het consult en de behandeling", - "keywords": ["ZIS", "administratie", "demografie", "patient", "registratie", "NAW"] - }, - { - "code": "ZRG-ZON-005", - "name": "Patiëntenlogistiek", - "description": "Functionaliteit voor de ondersteuning van het verplaatsen van mensen en middelen (bv transportlogistiek, route ondersteuning, track & tracing, aanmeldregistratie, wachtrijmanagement, oproep)", - "keywords": ["logistiek", "transport", "wachtrij", "aanmeldzuil", "tracking", "routing"] - }, - { - "code": "ZRG-ZON-006", - "name": "Zorgfacturering", - "description": "Functionaliteit voor de vastlegging van de verrichting en factureren van het zorgproduct", - "keywords": ["facturatie", "DBC", "DOT", "declaratie", "verrichting", "tarief"] - } - ] - }, - { - "code": "OND", - "name": "Onderwijs", - "description": "Applicatiefuncties ter ondersteuning van medisch onderwijs", - "functions": [ - { - "code": "OND-001", - "name": "Onderwijsportfolio", - "description": "Functionaliteit voor creatie en beheer van het onderwijsportfolio", - "keywords": ["portfolio", "EPA", "competentie", "voortgang", "student"] - }, - { - "code": "OND-002", - "name": "Learning Content Management", - "description": "Functionaliteit creatie en beheer van onderwijscontent", - "keywords": ["LMS", "content", "cursus", "module", "e-learning"] - }, - { - "code": "OND-003", - "name": "Educatie", - "description": "Functionaliteit voor het geven van educatie dmv digitale middelen", - "keywords": ["educatie", "training", "scholing", "e-learning", "webinar"] - }, - { - "code": "OND-004", - "name": "Toetsing", - "description": "Functionaliteit voor het geven en beoordelen van toetsen", - "keywords": ["toets", "examen", "beoordeling", "assessment", "evaluatie"] - }, - { - "code": "OND-005", - "name": "Student Informatie", - "description": "Functionaliteit voor het beheren van alle informatie van en over de student", - "keywords": ["SIS", "student", "opleiding", "registratie", "inschrijving"] - }, - { - "code": "OND-006", - "name": "Onderwijs rooster & planning", - "description": "Functionaliteit voor het roosteren en plannen van het onderwijsprogramma", - "keywords": ["rooster", "planning", "stage", "coschap", "onderwijs"] - } - ] - }, - { - "code": "BED", - "name": "Bedrijfsondersteuning", - "description": "Applicatiefuncties ter ondersteuning van bedrijfsvoering", - "functions": [ - { - "code": "BED-001", - "name": "Vastgoed", - "description": "Functionaliteit die beheer, bouw en exploitatie van gebouwen en de daaraan verbonden faciliteiten en goederenstromen ondersteunt", - "keywords": ["vastgoed", "gebouw", "facilitair", "onderhoud", "FMIS"] - }, - { - "code": "BED-002", - "name": "Inkoop", - "description": "Functionaliteit die inkopen van producten en diensten alsook het beheren van leveranciers en contracten ondersteunt", - "keywords": ["inkoop", "procurement", "leverancier", "bestelling", "contract"] - }, - { - "code": "BED-003", - "name": "Voorraadbeheer", - "description": "Beheren/beheersen van de in- en uitgaande goederenstroom (door middel van planningtools) inclusief supply chain", - "keywords": ["voorraad", "magazijn", "supply chain", "logistiek", "inventaris"] - }, - { - "code": "BED-004", - "name": "Kennismanagement", - "description": "Functionaliteit die het creëeren en delen van gezamenlijke kennis ondersteunt", - "keywords": ["kennis", "wiki", "intranet", "SharePoint", "documentatie"] - }, - { - "code": "BED-005", - "name": "Datamanagement", - "description": "Functionaliteit voor ondersteunen van datamanagement, inclusief reference & master datamangement, metadatamanagement, dataanalytics", - "keywords": ["data", "master data", "metadata", "analytics", "datawarehouse", "BI"] - }, - { - "code": "BED-006", - "name": "Voorlichting", - "description": "Functionaliteit die het geven van voorlichting via verschillende kanalen ondersteunt", - "keywords": ["website", "CMS", "communicatie", "voorlichting", "publicatie"] - }, - { - "code": "BED-007", - "name": "Hotelservice", - "description": "Functionaliteit die de hotelfunctie ondersteunt, hierbij inbegrepen zijn parkeren, catering, kassa", - "keywords": ["catering", "restaurant", "parkeren", "kassa", "hotel"] - }, - { - "code": "BED-008", - "name": "Klachtenafhandeling", - "description": "Functionaliteit die de afhandeling van klachten ondersteunt", - "keywords": ["klacht", "melding", "incident", "feedback", "MIC", "MIM"] - }, - { - "code": "BED-009", - "name": "Personeelbeheer", - "description": "Functionaliteit die het administreren en managen van medewerkers ondersteunt", - "keywords": ["HR", "HRM", "personeel", "medewerker", "werving", "talent"] - }, - { - "code": "BED-010", - "name": "Tijdsregistratie", - "description": "Functionaliteit waarmee het registreren van de bestede tijd van individuen wordt ondersteund", - "keywords": ["tijd", "uren", "registratie", "klokken", "rooster"] - }, - { - "code": "BED-011", - "name": "Financieel beheer", - "description": "Functionaliteit waarmee de financiële administratie en verwerking van financiële stromen wordt ondersteund", - "keywords": ["financieel", "boekhouding", "factuur", "budget", "ERP", "SAP"] - }, - { - "code": "BED-012", - "name": "Salarisverwerking", - "description": "Functionaliteit waarmee het uitbetalen van salarissen aan medewerkers wordt ondersteund", - "keywords": ["salaris", "loon", "payroll", "verloning"] - }, - { - "code": "BED-013", - "name": "Beheren medische technologie", - "description": "Functionaliteit die beheer, onderhoud en gebruik van diverse medische apparatuur ondersteunt", - "keywords": ["MT", "medische techniek", "apparatuur", "onderhoud", "kalibratie"] - }, - { - "code": "BED-014", - "name": "Beveiliging", - "description": "Functionaliteit die ondersteunt bij het uitvoeren van de veiligheid, kwaliteit en milieu taken en verplichtingen", - "keywords": ["beveiliging", "VGM", "ARBO", "milieu", "veiligheid"] - }, - { - "code": "BED-015", - "name": "Relatiebeheer", - "description": "Functionaliteit ter ondersteuning van relatiebeheer in brede zin", - "keywords": ["CRM", "relatie", "stakeholder", "contact", "netwerk"] - }, - { - "code": "BED-016", - "name": "ICT-change en servicemanagement", - "description": "Functies voor het faciliteren van hulpvragen en oplossingen", - "keywords": ["ITSM", "servicedesk", "incident", "change", "TOPdesk", "ServiceNow"] - } - ] - }, - { - "code": "GEN-WRK", - "name": "Generieke ICT - Werkplek en samenwerken", - "description": "Generieke ICT-functies voor werkplek en samenwerking", - "functions": [ - { - "code": "GEN-WRK-001", - "name": "Beheren werkplek", - "description": "Functionaliteit voor beheren hardware (PC, monitor, mobile device, printers, scanners, bedside, tv e.d.) en software op de werkplek of bed-site (LCM, CMDB, deployment, virtual desktop)", - "keywords": ["werkplek", "PC", "laptop", "VDI", "Citrix", "deployment", "SCCM", "Intune"] - }, - { - "code": "GEN-WRK-002", - "name": "Printing & scanning", - "description": "Functionaliteit voor het afdrukken en scannen", - "keywords": ["print", "scan", "printer", "MFP", "document"] - }, - { - "code": "GEN-WRK-003", - "name": "Kantoorautomatisering", - "description": "Functionaliteit voor standaard kantoorondersteuning (tekstverwerking, spreadsheet, e-mail en agenda)", - "keywords": ["Office", "Microsoft 365", "Word", "Excel", "Outlook", "email", "agenda"] - }, - { - "code": "GEN-WRK-004", - "name": "Unified communications", - "description": "Functionaliteit voor de (geïntegreerde) communicatie tussen mensen via verschillende kanalen (spraak, instant messaging, video)", - "keywords": ["Teams", "telefonie", "video", "chat", "communicatie", "VoIP"] - }, - { - "code": "GEN-WRK-005", - "name": "Document & Beeld beheer", - "description": "Functionaliteit voor het beheren van documenten en beelden", - "keywords": ["DMS", "document", "archief", "SharePoint", "OneDrive"] - }, - { - "code": "GEN-WRK-006", - "name": "Content management", - "description": "Functionaliteit voor het verzamelen, managen en publiceren van (niet-patientgebonden) informatie in elke vorm of medium", - "keywords": ["CMS", "website", "intranet", "publicatie", "content"] - }, - { - "code": "GEN-WRK-007", - "name": "Publieke ICT services", - "description": "Functionaliteit voor het aanbieden van bv radio en tv, internet, e-books, netflix", - "keywords": ["gastnetwerk", "wifi", "entertainment", "internet", "publiek"] - } - ] - }, - { - "code": "GEN-IAM", - "name": "Generieke ICT - Identiteit, toegang en beveiliging", - "description": "Generieke ICT-functies voor identity en access management", - "functions": [ - { - "code": "GEN-IAM-001", - "name": "Identiteit & Authenticatie", - "description": "Functionaliteit voor het identificeren en authenticeren van individuen in systemen", - "keywords": ["IAM", "identiteit", "authenticatie", "SSO", "MFA", "Active Directory", "Entra"] - }, - { - "code": "GEN-IAM-002", - "name": "Autorisatie management", - "description": "Functionaliteit voor beheren van rechten en toegang", - "keywords": ["autorisatie", "RBAC", "rechten", "toegang", "rollen"] - }, - { - "code": "GEN-IAM-003", - "name": "Auditing & monitoring", - "description": "Functionaliteit voor audits en monitoring in het kader van rechtmatig gebruik en toegang", - "keywords": ["audit", "logging", "SIEM", "compliance", "NEN7513"] - }, - { - "code": "GEN-IAM-004", - "name": "Certificate service", - "description": "Functionaliteit voor uitgifte en beheer van certificaten", - "keywords": ["certificaat", "PKI", "SSL", "TLS", "signing"] - }, - { - "code": "GEN-IAM-005", - "name": "ICT Preventie en protectie", - "description": "Functionaliteit voor beheersen van kwetsbaarheden en penetraties", - "keywords": ["security", "antivirus", "EDR", "firewall", "vulnerability", "pentest"] - } - ] - }, - { - "code": "GEN-DC", - "name": "Generieke ICT - Datacenter", - "description": "Generieke ICT-functies voor datacenter en hosting", - "functions": [ - { - "code": "GEN-DC-001", - "name": "Hosting servercapaciteit", - "description": "Functionaliteit voor het leveren van serverinfrastructuur (CPU power)", - "keywords": ["server", "hosting", "VM", "compute", "cloud", "Azure"] - }, - { - "code": "GEN-DC-002", - "name": "Datacenter housing", - "description": "Functionaliteit voor beheren van het datacenter, bijvoorbeeld fysieke toegang, cooling", - "keywords": ["datacenter", "housing", "colocation", "rack", "cooling"] - }, - { - "code": "GEN-DC-003", - "name": "Hosting data storage", - "description": "Functionaliteit voor data opslag", - "keywords": ["storage", "SAN", "NAS", "opslag", "disk"] - }, - { - "code": "GEN-DC-004", - "name": "Data archiving", - "description": "Functionaliteit voor het archiveren van gegevens", - "keywords": ["archief", "archivering", "retentie", "backup", "cold storage"] - }, - { - "code": "GEN-DC-005", - "name": "Backup & recovery", - "description": "Functionaliteit voor back-up en herstel", - "keywords": ["backup", "restore", "recovery", "DR", "disaster recovery"] - }, - { - "code": "GEN-DC-006", - "name": "Database management", - "description": "Functionaliteit voor het beheren van databases", - "keywords": ["database", "SQL", "Oracle", "DBA", "DBMS"] - }, - { - "code": "GEN-DC-007", - "name": "Provisioning & automation service", - "description": "Functionaliteit voor het distribueren en automatiseren van diensten/applicaties", - "keywords": ["automation", "provisioning", "deployment", "DevOps", "CI/CD"] - }, - { - "code": "GEN-DC-008", - "name": "Monitoring & alerting", - "description": "Functionaliteit voor het monitoren en analyseren van het datacentrum", - "keywords": ["monitoring", "APM", "alerting", "Zabbix", "Splunk", "observability"] - }, - { - "code": "GEN-DC-009", - "name": "Servermanagement", - "description": "Functionaliteit voor het beheren van servers", - "keywords": ["server", "beheer", "patching", "configuratie", "lifecycle"] - } - ] - }, - { - "code": "GEN-CON", - "name": "Generieke ICT - Connectiviteit", - "description": "Generieke ICT-functies voor netwerk en connectiviteit", - "functions": [ - { - "code": "GEN-CON-001", - "name": "Netwerkmanagement", - "description": "Functionaliteit voor het beheren van het netwerk zoals bijv. acceptatie van hardware op netwerk/DC-LAN, Campus-LAN, WAN", - "keywords": ["netwerk", "LAN", "WAN", "switch", "router", "wifi"] - }, - { - "code": "GEN-CON-002", - "name": "Locatiebepaling", - "description": "Functies voor het traceren en volgen van items of eigendom, nu of in het verleden. Bijvoorbeeld RFID-toepassingen", - "keywords": ["RFID", "RTLS", "tracking", "locatie", "asset tracking"] - }, - { - "code": "GEN-CON-003", - "name": "DNS & IP Adress management", - "description": "Functionaliteit voor het beheren van DNS en IP adressen", - "keywords": ["DNS", "DHCP", "IP", "IPAM", "domain"] - }, - { - "code": "GEN-CON-004", - "name": "Remote Access", - "description": "Functionaliteit voor toegang op afstand zoals inbelfaciliteiten", - "keywords": ["VPN", "remote", "thuiswerken", "toegang", "DirectAccess"] - }, - { - "code": "GEN-CON-005", - "name": "Load Balancing", - "description": "Functionaliteit voor beheren van server en netwerkbelasting", - "keywords": ["load balancer", "F5", "HAProxy", "traffic", "availability"] - }, - { - "code": "GEN-CON-006", - "name": "Gegevensuitwisseling", - "description": "Functionaliteit voor de ondersteuning van het gegevensuitwisseling (ESB, Message broker)", - "keywords": ["integratie", "ESB", "API", "HL7", "FHIR", "message broker", "MuleSoft"] - } - ] - } - ] -}