Improve Team-indeling dashboard UI and cache invalidation
- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping
This commit is contained in:
30
.env.example
30
.env.example
@@ -5,36 +5,6 @@ JIRA_SCHEMA_ID=your_schema_id
|
||||
|
||||
JIRA_API_BATCH_SIZE=20
|
||||
|
||||
# Object Type IDs (retrieve via API)
|
||||
JIRA_APPLICATION_COMPONENT_TYPE_ID=your_type_id
|
||||
JIRA_APPLICATION_FUNCTION_TYPE_ID=your_function_type_id
|
||||
JIRA_DYNAMICS_FACTOR_TYPE_ID=your_dynamics_factor_type_id
|
||||
JIRA_COMPLEXITY_FACTOR_TYPE_ID=your_complexity_factor_type_id
|
||||
JIRA_NUMBER_OF_USERS_TYPE_ID=your_number_of_users_type_id
|
||||
JIRA_GOVERNANCE_MODEL_TYPE_ID=your_governance_model_type_id
|
||||
JIRA_APPLICATION_CLUSTER_TYPE_ID=your_application_cluster_type_id
|
||||
JIRA_APPLICATION_TYPE_TYPE_ID=your_application_type_type_id
|
||||
JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID=your_business_impact_analyse_type_id
|
||||
JIRA_HOSTING_TYPE_TYPE_ID=your_hosting_type_type_id
|
||||
JIRA_HOSTING_TYPE_ID=your_hosting_type_id
|
||||
JIRA_TAM_TYPE_ID=your_tam_type_id
|
||||
|
||||
# Attribute IDs (retrieve via API - needed for updates)
|
||||
JIRA_ATTR_APPLICATION_FUNCTION=attribute_id
|
||||
JIRA_ATTR_DYNAMICS_FACTOR=attribute_id
|
||||
JIRA_ATTR_COMPLEXITY_FACTOR=attribute_id
|
||||
JIRA_ATTR_NUMBER_OF_USERS=attribute_id
|
||||
JIRA_ATTR_GOVERNANCE_MODEL=attribute_id
|
||||
JIRA_ATTR_APPLICATION_CLUSTER=attribute_id
|
||||
JIRA_ATTR_APPLICATION_TYPE=attribute_id
|
||||
JIRA_ATTR_PLATFORM=attribute_id
|
||||
JIRA_ATTR_BUSINESS_IMPACT_ANALYSE=attribute_id
|
||||
JIRA_ATTR_HOSTING_TYPE=attribute_id
|
||||
JIRA_ATTR_TECHNISCHE_ARCHITECTUUR=attribute_id
|
||||
JIRA_ATTR_HOSTING=attribute_id
|
||||
JIRA_ATTR_TAM=attribute_id
|
||||
|
||||
|
||||
# Claude API
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -93,19 +93,41 @@ zira-classificatie-tool/
|
||||
│ ├── App.tsx # Main component with routing
|
||||
│ ├── index.css # Tailwind CSS imports
|
||||
│ ├── components/
|
||||
│ │ ├── Dashboard.tsx # Overview statistics
|
||||
│ │ ├── ApplicationList.tsx # Search & filter view
|
||||
│ │ └── ApplicationDetail.tsx # Edit & AI classify
|
||||
│ │ ├── SearchDashboard.tsx # Main dashboard with CMDB search
|
||||
│ │ ├── Dashboard.tsx # App Component statistics
|
||||
│ │ ├── ApplicationList.tsx # Application overview & filter
|
||||
│ │ ├── ApplicationDetail.tsx # Edit & AI classify
|
||||
│ │ ├── TeamDashboard.tsx # Team FTE dashboard
|
||||
│ │ ├── ConfigurationV25.tsx # FTE configuration
|
||||
│ │ └── ReportsDashboard.tsx # Reports overview
|
||||
│ ├── services/api.ts # API client
|
||||
│ ├── stores/
|
||||
│ │ ├── searchStore.ts # Filter state (Zustand)
|
||||
│ │ └── navigationStore.ts # Navigation state
|
||||
│ │ ├── navigationStore.ts # Navigation state
|
||||
│ │ └── authStore.ts # Authentication state
|
||||
│ └── types/index.ts # TypeScript interfaces
|
||||
└── data/
|
||||
├── zira-taxonomy.json
|
||||
└── management-parameters.json
|
||||
```
|
||||
|
||||
## Navigation Structure
|
||||
|
||||
The application uses a hierarchical menu structure:
|
||||
|
||||
```
|
||||
Dashboard (/) # CMDB search page
|
||||
│
|
||||
├── Application Component (/app-components)
|
||||
│ ├── Dashboard (/app-components) # Statistics & overview
|
||||
│ ├── Overzicht (/app-components/overview) # Application list & filter
|
||||
│ └── FTE Config (/app-components/fte-config) # FTE calculation config
|
||||
│
|
||||
└── Rapporten (/reports)
|
||||
├── Overzicht (/reports) # Reports dashboard
|
||||
└── Team-indeling (/reports/team-dashboard) # Team FTE dashboard
|
||||
```
|
||||
|
||||
## Key Domain Concepts
|
||||
|
||||
### ZiRA (Ziekenhuis Referentie Architectuur)
|
||||
@@ -134,17 +156,21 @@ Dutch hospital reference architecture with 90+ application functions organized i
|
||||
```env
|
||||
# Jira Data Center
|
||||
JIRA_HOST=https://jira.zuyderland.nl
|
||||
JIRA_PAT=<personal_access_token> # Service account PAT (fallback when OAuth disabled)
|
||||
JIRA_SCHEMA_ID=<schema_id>
|
||||
|
||||
# Jira OAuth 2.0 (optional - enables user authentication)
|
||||
JIRA_OAUTH_ENABLED=false # Set to 'true' to enable OAuth
|
||||
# Jira Authentication Method: 'pat' or 'oauth'
|
||||
JIRA_AUTH_METHOD=pat # Choose: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0)
|
||||
|
||||
# For PAT authentication (JIRA_AUTH_METHOD=pat)
|
||||
JIRA_PAT=<personal_access_token> # Personal Access Token for Jira API access
|
||||
|
||||
# For OAuth 2.0 authentication (JIRA_AUTH_METHOD=oauth)
|
||||
JIRA_OAUTH_CLIENT_ID=<oauth_client_id> # From Jira Application Link
|
||||
JIRA_OAUTH_CLIENT_SECRET=<oauth_secret> # From Jira Application Link
|
||||
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||
JIRA_OAUTH_SCOPES=READ WRITE
|
||||
|
||||
# Session Configuration
|
||||
# Session Configuration (required for OAuth)
|
||||
SESSION_SECRET=<random_secret_string> # Change in production!
|
||||
|
||||
# Jira Object Type IDs
|
||||
@@ -179,17 +205,34 @@ FRONTEND_URL=http://localhost:5173
|
||||
|
||||
## Authentication
|
||||
|
||||
The application supports two authentication modes:
|
||||
The application supports two authentication methods, configured via `JIRA_AUTH_METHOD`:
|
||||
|
||||
### 1. Service Account Mode (Default)
|
||||
- Uses a single PAT (`JIRA_PAT`) for all Jira API calls
|
||||
### 1. Personal Access Token (PAT) Mode (`JIRA_AUTH_METHOD=pat`)
|
||||
- **Default mode** - Uses a single PAT for all Jira API calls
|
||||
- Users don't need to log in
|
||||
- All changes are attributed to the service account
|
||||
- Best for: Development, internal tools, or when user attribution isn't required
|
||||
|
||||
### 2. OAuth 2.0 Mode
|
||||
**Configuration:**
|
||||
```env
|
||||
JIRA_AUTH_METHOD=pat
|
||||
JIRA_PAT=your_personal_access_token
|
||||
```
|
||||
|
||||
### 2. OAuth 2.0 Mode (`JIRA_AUTH_METHOD=oauth`)
|
||||
- Each user logs in with their own Jira credentials
|
||||
- API calls are made under the user's account
|
||||
- Better audit trail and access control
|
||||
- Best for: Production environments where user attribution matters
|
||||
|
||||
**Configuration:**
|
||||
```env
|
||||
JIRA_AUTH_METHOD=oauth
|
||||
JIRA_OAUTH_CLIENT_ID=your_client_id
|
||||
JIRA_OAUTH_CLIENT_SECRET=your_client_secret
|
||||
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||
SESSION_SECRET=your_secure_random_string
|
||||
```
|
||||
|
||||
### Setting up OAuth 2.0 (Jira Data Center 8.14+)
|
||||
|
||||
@@ -199,17 +242,11 @@ The application supports two authentication modes:
|
||||
- Set Redirect URL: `http://localhost:3001/api/auth/callback`
|
||||
- Note the Client ID and Secret
|
||||
|
||||
2. **Configure Environment:**
|
||||
```env
|
||||
JIRA_OAUTH_ENABLED=true
|
||||
JIRA_OAUTH_CLIENT_ID=your_client_id
|
||||
JIRA_OAUTH_CLIENT_SECRET=your_client_secret
|
||||
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||
```
|
||||
2. **Configure Environment Variables** (see above)
|
||||
|
||||
3. **For Production:**
|
||||
- Update callback URL to production domain
|
||||
- Set `SESSION_SECRET` to a random string
|
||||
- Set `SESSION_SECRET` to a cryptographically secure random string
|
||||
- Use HTTPS
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"generate-schema": "tsx scripts/generate-schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
|
||||
982
backend/scripts/generate-schema.ts
Normal file
982
backend/scripts/generate-schema.ts
Normal file
@@ -0,0 +1,982 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Schema Generator - Fetches Jira Assets schema DYNAMICALLY and generates:
|
||||
* - TypeScript types (jira-types.ts)
|
||||
* - Schema metadata (jira-schema.ts)
|
||||
* - Database schema (db-schema.sql)
|
||||
*
|
||||
* This script connects to the Jira Assets API to discover ALL object types
|
||||
* and their attributes, ensuring the data model is always in sync with the
|
||||
* actual CMDB configuration.
|
||||
*
|
||||
* Usage: npm run generate-schema
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// Try multiple possible .env locations
|
||||
const envPaths = [
|
||||
path.resolve(__dirname, '../../.env'), // backend/.env
|
||||
path.resolve(__dirname, '../../../.env'), // project root .env
|
||||
];
|
||||
let envLoaded = '';
|
||||
for (const envPath of envPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
envLoaded = envPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const JIRA_HOST = process.env.JIRA_HOST || '';
|
||||
const JIRA_PAT = process.env.JIRA_PAT || '';
|
||||
const JIRA_SCHEMA_ID = process.env.JIRA_SCHEMA_ID || '';
|
||||
|
||||
const OUTPUT_DIR = path.resolve(__dirname, '../src/generated');
|
||||
|
||||
// =============================================================================
|
||||
// Interfaces
|
||||
// =============================================================================
|
||||
|
||||
interface JiraObjectSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
objectSchemaKey: string;
|
||||
description?: string;
|
||||
objectCount?: number;
|
||||
}
|
||||
|
||||
interface JiraObjectType {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconId?: number;
|
||||
objectCount?: number;
|
||||
parentObjectTypeId?: number;
|
||||
objectSchemaId: number;
|
||||
inherited?: boolean;
|
||||
abstractObjectType?: boolean;
|
||||
parentObjectTypeInherited?: boolean;
|
||||
}
|
||||
|
||||
interface JiraAttribute {
|
||||
id: number;
|
||||
name: string;
|
||||
label?: string;
|
||||
type: number;
|
||||
typeValue?: string;
|
||||
defaultType?: { id: number; name: string };
|
||||
referenceObjectTypeId?: number;
|
||||
referenceObjectType?: { id: number; name: string };
|
||||
referenceType?: { id: number; name: string };
|
||||
minimumCardinality?: number;
|
||||
maximumCardinality?: number;
|
||||
editable?: boolean;
|
||||
hidden?: boolean;
|
||||
system?: boolean;
|
||||
options?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GeneratedAttribute {
|
||||
jiraId: number;
|
||||
name: string;
|
||||
fieldName: string;
|
||||
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
|
||||
isMultiple: boolean;
|
||||
isEditable: boolean;
|
||||
isRequired: boolean;
|
||||
isSystem: boolean;
|
||||
referenceTypeId?: number;
|
||||
referenceTypeName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GeneratedObjectType {
|
||||
jiraTypeId: number;
|
||||
name: string;
|
||||
typeName: string;
|
||||
syncPriority: number;
|
||||
objectCount: number;
|
||||
attributes: GeneratedAttribute[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Jira Type Mapping
|
||||
// =============================================================================
|
||||
|
||||
// Jira attribute type mappings (based on Jira Insight/Assets API)
|
||||
// See: https://docs.atlassian.com/jira-servicemanagement-docs/REST/5.x/insight/1.0/objecttypeattribute
|
||||
const JIRA_TYPE_MAP: Record<number, GeneratedAttribute['type']> = {
|
||||
0: 'text', // Default/Text
|
||||
1: 'integer', // Integer
|
||||
2: 'boolean', // Boolean
|
||||
3: 'float', // Double/Float
|
||||
4: 'date', // Date
|
||||
5: 'datetime', // DateTime
|
||||
6: 'url', // URL
|
||||
7: 'email', // Email
|
||||
8: 'textarea', // Textarea
|
||||
9: 'select', // Select
|
||||
10: 'reference', // Reference (Object)
|
||||
11: 'user', // User
|
||||
12: 'reference', // Confluence (treated as reference)
|
||||
13: 'reference', // Group (treated as reference)
|
||||
14: 'reference', // Version (treated as reference)
|
||||
15: 'reference', // Project (treated as reference)
|
||||
16: 'status', // Status
|
||||
};
|
||||
|
||||
// Priority types - these sync first as they are reference data
|
||||
const PRIORITY_TYPE_NAMES = new Set([
|
||||
'Application Component',
|
||||
'Server',
|
||||
'Flows',
|
||||
]);
|
||||
|
||||
// Reference data types - these sync with lower priority
|
||||
const REFERENCE_TYPE_PATTERNS = [
|
||||
/Factor$/,
|
||||
/Model$/,
|
||||
/Type$/,
|
||||
/Category$/,
|
||||
/Importance$/,
|
||||
/Analyse$/,
|
||||
/Organisation$/,
|
||||
/Function$/,
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Jira Schema Fetcher
|
||||
// =============================================================================
|
||||
|
||||
class JiraSchemaFetcher {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(host: string, pat: string) {
|
||||
this.baseUrl = `${host}/rest/insight/1.0`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${pat}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schema info
|
||||
*/
|
||||
async fetchSchema(schemaId: string): Promise<JiraObjectSchema | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/${schemaId}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch schema ${schemaId}: ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
console.error(`Response: ${text}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching schema ${schemaId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ALL object types in the schema
|
||||
*/
|
||||
async fetchAllObjectTypes(schemaId: string): Promise<JiraObjectType[]> {
|
||||
try {
|
||||
// Try the objecttypes/flat endpoint first (returns all types in flat structure)
|
||||
let response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Fallback to regular objecttypes endpoint
|
||||
response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch object types: ${response.status} ${response.statusText}`);
|
||||
const text = await response.text();
|
||||
console.error(`Response: ${text}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Handle both array and object responses
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
} else if (result.objectTypes) {
|
||||
return result.objectTypes;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching object types:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attributes for a specific object type
|
||||
*/
|
||||
async fetchAttributes(typeId: number): Promise<JiraAttribute[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objecttype/${typeId}/attributes`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch attributes for type ${typeId}: ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching attributes for type ${typeId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/list`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Convert a string to camelCase while preserving existing casing patterns
|
||||
* E.g., "Application Function" -> "applicationFunction"
|
||||
* "ICT Governance Model" -> "ictGovernanceModel"
|
||||
* "ApplicationFunction" -> "applicationFunction"
|
||||
*/
|
||||
function toCamelCase(str: string): string {
|
||||
// First split on spaces and special chars
|
||||
const words = str
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0);
|
||||
|
||||
if (words.length === 0) return '';
|
||||
|
||||
// If it's a single word that's already camelCase or PascalCase, just lowercase first char
|
||||
if (words.length === 1) {
|
||||
const word = words[0];
|
||||
return word.charAt(0).toLowerCase() + word.slice(1);
|
||||
}
|
||||
|
||||
// Multiple words - first word lowercase, rest capitalize first letter
|
||||
return words
|
||||
.map((word, index) => {
|
||||
if (index === 0) {
|
||||
// First word: if all uppercase (acronym), lowercase it, otherwise just lowercase first char
|
||||
if (word === word.toUpperCase() && word.length > 1) {
|
||||
return word.toLowerCase();
|
||||
}
|
||||
return word.charAt(0).toLowerCase() + word.slice(1);
|
||||
}
|
||||
// Other words: capitalize first letter, keep rest as-is
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a string to PascalCase while preserving existing casing patterns
|
||||
* E.g., "Application Function" -> "ApplicationFunction"
|
||||
* "ICT Governance Model" -> "IctGovernanceModel"
|
||||
* "applicationFunction" -> "ApplicationFunction"
|
||||
*/
|
||||
function toPascalCase(str: string): string {
|
||||
// First split on spaces and special chars
|
||||
const words = str
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 0);
|
||||
|
||||
if (words.length === 0) return '';
|
||||
|
||||
// If it's a single word, just capitalize first letter
|
||||
if (words.length === 1) {
|
||||
const word = words[0];
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
// Multiple words - capitalize first letter of each
|
||||
return words
|
||||
.map(word => {
|
||||
// If all uppercase (acronym) and first word, just capitalize first letter
|
||||
if (word === word.toUpperCase() && word.length > 1) {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function mapJiraType(typeId: number): GeneratedAttribute['type'] {
|
||||
return JIRA_TYPE_MAP[typeId] || 'unknown';
|
||||
}
|
||||
|
||||
function determineSyncPriority(typeName: string, objectCount: number): number {
|
||||
// Application Component and related main types first
|
||||
if (PRIORITY_TYPE_NAMES.has(typeName)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Reference data types last
|
||||
for (const pattern of REFERENCE_TYPE_PATTERNS) {
|
||||
if (pattern.test(typeName)) {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Medium priority for types with more objects
|
||||
if (objectCount > 100) {
|
||||
return 2;
|
||||
}
|
||||
if (objectCount > 10) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
return 8;
|
||||
}
|
||||
|
||||
function parseAttribute(
|
||||
attr: JiraAttribute,
|
||||
allTypeConfigs: Map<number, { name: string; typeName: string }>
|
||||
): GeneratedAttribute {
|
||||
const typeId = attr.type || attr.defaultType?.id || 0;
|
||||
let type = mapJiraType(typeId);
|
||||
const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1;
|
||||
const isEditable = attr.editable !== false && !attr.hidden;
|
||||
const isRequired = (attr.minimumCardinality ?? 0) > 0;
|
||||
const isSystem = attr.system === true;
|
||||
|
||||
// CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes!
|
||||
// The presence of referenceObjectTypeId is the true indicator of a reference type.
|
||||
const refTypeId = attr.referenceObjectTypeId || attr.referenceObjectType?.id || attr.referenceType?.id;
|
||||
if (refTypeId) {
|
||||
type = 'reference';
|
||||
}
|
||||
|
||||
const result: GeneratedAttribute = {
|
||||
jiraId: attr.id,
|
||||
name: attr.name,
|
||||
fieldName: toCamelCase(attr.name),
|
||||
type,
|
||||
isMultiple,
|
||||
isEditable,
|
||||
isRequired,
|
||||
isSystem,
|
||||
description: attr.description,
|
||||
};
|
||||
|
||||
// Handle reference types - add reference metadata
|
||||
if (type === 'reference' && refTypeId) {
|
||||
result.referenceTypeId = refTypeId;
|
||||
const refConfig = allTypeConfigs.get(refTypeId);
|
||||
result.referenceTypeName = refConfig?.typeName ||
|
||||
attr.referenceObjectType?.name ||
|
||||
attr.referenceType?.name ||
|
||||
`Type${refTypeId}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Code Generation Functions
|
||||
// =============================================================================
|
||||
|
||||
function generateTypeScriptType(attrType: GeneratedAttribute['type'], isMultiple: boolean, isReference: boolean): string {
|
||||
let tsType: string;
|
||||
|
||||
if (isReference) {
|
||||
tsType = 'ObjectReference';
|
||||
} else {
|
||||
switch (attrType) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'url':
|
||||
case 'email':
|
||||
case 'select':
|
||||
case 'user':
|
||||
case 'status':
|
||||
tsType = 'string';
|
||||
break;
|
||||
case 'integer':
|
||||
case 'float':
|
||||
tsType = 'number';
|
||||
break;
|
||||
case 'boolean':
|
||||
tsType = 'boolean';
|
||||
break;
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
tsType = 'string'; // ISO date string
|
||||
break;
|
||||
default:
|
||||
tsType = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
return `${tsType}[]`;
|
||||
}
|
||||
return `${tsType} | null`;
|
||||
}
|
||||
|
||||
function generateTypesFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'// Generated from Jira Assets Schema via REST API',
|
||||
`// Generated at: ${generatedAt.toISOString()}`,
|
||||
'//',
|
||||
'// Re-generate with: npm run generate-schema',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Base Types',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'/** Reference to another CMDB object */',
|
||||
'export interface ObjectReference {',
|
||||
' objectId: string;',
|
||||
' objectKey: string;',
|
||||
' label: string;',
|
||||
' // Optional enriched data from referenced object',
|
||||
' factor?: number;',
|
||||
'}',
|
||||
'',
|
||||
'/** Base interface for all CMDB objects */',
|
||||
'export interface BaseCMDBObject {',
|
||||
' id: string;',
|
||||
' objectKey: string;',
|
||||
' label: string;',
|
||||
' _objectType: string;',
|
||||
' _jiraUpdatedAt: string;',
|
||||
' _jiraCreatedAt: string;',
|
||||
'}',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Object Type Interfaces',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(`/** ${objType.name} (Jira Type ID: ${objType.jiraTypeId}, ${objType.objectCount} objects) */`);
|
||||
lines.push(`export interface ${objType.typeName} extends BaseCMDBObject {`);
|
||||
lines.push(` _objectType: '${objType.typeName}';`);
|
||||
lines.push('');
|
||||
|
||||
// Group attributes by type
|
||||
const scalarAttrs = objType.attributes.filter(a => a.type !== 'reference');
|
||||
const refAttrs = objType.attributes.filter(a => a.type === 'reference');
|
||||
|
||||
if (scalarAttrs.length > 0) {
|
||||
lines.push(' // Scalar attributes');
|
||||
for (const attr of scalarAttrs) {
|
||||
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, false);
|
||||
const comment = attr.description ? ` // ${attr.description}` : '';
|
||||
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (refAttrs.length > 0) {
|
||||
lines.push(' // Reference attributes');
|
||||
for (const attr of refAttrs) {
|
||||
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, true);
|
||||
const comment = attr.referenceTypeName ? ` // -> ${attr.referenceTypeName}` : '';
|
||||
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Generate union type
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Union Types');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
lines.push('/** Union of all CMDB object types */');
|
||||
lines.push('export type CMDBObject =');
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
||||
lines.push(` | ${objectTypes[i].typeName}${suffix}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Generate type name literal union
|
||||
lines.push('/** All valid object type names */');
|
||||
lines.push('export type CMDBObjectTypeName =');
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const suffix = i < objectTypes.length - 1 ? '' : ';';
|
||||
lines.push(` | '${objectTypes[i].typeName}'${suffix}`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Generate type guards
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Type Guards');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(`export function is${objType.typeName}(obj: CMDBObject): obj is ${objType.typeName} {`);
|
||||
lines.push(` return obj._objectType === '${objType.typeName}';`);
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateSchemaFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'// Generated from Jira Assets Schema via REST API',
|
||||
`// Generated at: ${generatedAt.toISOString()}`,
|
||||
'//',
|
||||
'// Re-generate with: npm run generate-schema',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Schema Type Definitions',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'export interface AttributeDefinition {',
|
||||
' jiraId: number;',
|
||||
' name: string;',
|
||||
' fieldName: string;',
|
||||
" type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';",
|
||||
' isMultiple: boolean;',
|
||||
' isEditable: boolean;',
|
||||
' isRequired: boolean;',
|
||||
' isSystem: boolean;',
|
||||
' referenceTypeId?: number;',
|
||||
' referenceTypeName?: string;',
|
||||
' description?: string;',
|
||||
'}',
|
||||
'',
|
||||
'export interface ObjectTypeDefinition {',
|
||||
' jiraTypeId: number;',
|
||||
' name: string;',
|
||||
' typeName: string;',
|
||||
' syncPriority: number;',
|
||||
' objectCount: number;',
|
||||
' attributes: AttributeDefinition[];',
|
||||
'}',
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Schema Metadata',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
`export const SCHEMA_GENERATED_AT = '${generatedAt.toISOString()}';`,
|
||||
`export const SCHEMA_OBJECT_TYPE_COUNT = ${objectTypes.length};`,
|
||||
`export const SCHEMA_TOTAL_ATTRIBUTES = ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)};`,
|
||||
'',
|
||||
'// =============================================================================',
|
||||
'// Object Type Definitions',
|
||||
'// =============================================================================',
|
||||
'',
|
||||
'export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {',
|
||||
];
|
||||
|
||||
for (let i = 0; i < objectTypes.length; i++) {
|
||||
const objType = objectTypes[i];
|
||||
const comma = i < objectTypes.length - 1 ? ',' : '';
|
||||
|
||||
lines.push(` '${objType.typeName}': {`);
|
||||
lines.push(` jiraTypeId: ${objType.jiraTypeId},`);
|
||||
lines.push(` name: '${objType.name.replace(/'/g, "\\'")}',`);
|
||||
lines.push(` typeName: '${objType.typeName}',`);
|
||||
lines.push(` syncPriority: ${objType.syncPriority},`);
|
||||
lines.push(` objectCount: ${objType.objectCount},`);
|
||||
lines.push(' attributes: [');
|
||||
|
||||
for (let j = 0; j < objType.attributes.length; j++) {
|
||||
const attr = objType.attributes[j];
|
||||
const attrComma = j < objType.attributes.length - 1 ? ',' : '';
|
||||
|
||||
let attrLine = ` { jiraId: ${attr.jiraId}, name: '${attr.name.replace(/'/g, "\\'")}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`;
|
||||
|
||||
if (attr.referenceTypeId) {
|
||||
attrLine += `, referenceTypeId: ${attr.referenceTypeId}, referenceTypeName: '${attr.referenceTypeName}'`;
|
||||
}
|
||||
if (attr.description) {
|
||||
attrLine += `, description: '${attr.description.replace(/'/g, "\\'").replace(/\n/g, ' ')}'`;
|
||||
}
|
||||
|
||||
attrLine += ` }${attrComma}`;
|
||||
lines.push(attrLine);
|
||||
}
|
||||
|
||||
lines.push(' ],');
|
||||
lines.push(` }${comma}`);
|
||||
}
|
||||
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Generate lookup maps
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Lookup Maps');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
|
||||
// Type ID to name map
|
||||
lines.push('/** Map from Jira Type ID to TypeScript type name */');
|
||||
lines.push('export const TYPE_ID_TO_NAME: Record<number, string> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` ${objType.jiraTypeId}: '${objType.typeName}',`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Type name to ID map
|
||||
lines.push('/** Map from TypeScript type name to Jira Type ID */');
|
||||
lines.push('export const TYPE_NAME_TO_ID: Record<string, number> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` '${objType.typeName}': ${objType.jiraTypeId},`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Jira name to TypeScript name map
|
||||
lines.push('/** Map from Jira object type name to TypeScript type name */');
|
||||
lines.push('export const JIRA_NAME_TO_TYPE: Record<string, string> = {');
|
||||
for (const objType of objectTypes) {
|
||||
lines.push(` '${objType.name.replace(/'/g, "\\'")}': '${objType.typeName}',`);
|
||||
}
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
|
||||
// Helper functions
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('// Helper Functions');
|
||||
lines.push('// =============================================================================');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and field name */');
|
||||
lines.push('export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.fieldName === fieldName);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and Jira attribute ID */');
|
||||
lines.push('export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.jiraId === jiraId);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute definition by type and Jira attribute name */');
|
||||
lines.push('export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return undefined;');
|
||||
lines.push(' return objectType.attributes.find(a => a.name === attrName);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get attribute Jira ID by type and attribute name - throws if not found */');
|
||||
lines.push('export function getAttributeId(typeName: string, attrName: string): number {');
|
||||
lines.push(' const attr = getAttributeByName(typeName, attrName);');
|
||||
lines.push(' if (!attr) {');
|
||||
lines.push(' throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);');
|
||||
lines.push(' }');
|
||||
lines.push(' return attr.jiraId;');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get all reference attributes for a type */');
|
||||
lines.push('export function getReferenceAttributes(typeName: string): AttributeDefinition[] {');
|
||||
lines.push(' const objectType = OBJECT_TYPES[typeName];');
|
||||
lines.push(' if (!objectType) return [];');
|
||||
lines.push(" return objectType.attributes.filter(a => a.type === 'reference');");
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
lines.push('/** Get all object types sorted by sync priority */');
|
||||
lines.push('export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {');
|
||||
lines.push(' return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function generateDatabaseSchema(generatedAt: Date): string {
|
||||
const lines: string[] = [
|
||||
'-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
|
||||
'-- Generated from Jira Assets Schema via REST API',
|
||||
`-- Generated at: ${generatedAt.toISOString()}`,
|
||||
'--',
|
||||
'-- 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 JSON 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 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)',
|
||||
');',
|
||||
'',
|
||||
'-- 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_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);',
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main
|
||||
// =============================================================================
|
||||
|
||||
async function main() {
|
||||
const generatedAt = new Date();
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ CMDB Schema Generator - Jira Assets API ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
|
||||
// Validate configuration
|
||||
if (!JIRA_HOST) {
|
||||
console.error('❌ ERROR: JIRA_HOST environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_HOST=https://jira.your-domain.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!JIRA_PAT) {
|
||||
console.error('❌ ERROR: JIRA_PAT environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_PAT=your-personal-access-token');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!JIRA_SCHEMA_ID) {
|
||||
console.error('❌ ERROR: JIRA_SCHEMA_ID environment variable is required');
|
||||
console.error(' Set this in your .env file: JIRA_SCHEMA_ID=6');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (envLoaded) {
|
||||
console.log(`🔧 Environment: ${envLoaded}`);
|
||||
}
|
||||
console.log(`📡 Jira Host: ${JIRA_HOST}`);
|
||||
console.log(`📋 Schema ID: ${JIRA_SCHEMA_ID}`);
|
||||
console.log(`📁 Output Dir: ${OUTPUT_DIR}`);
|
||||
console.log('');
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
console.log(`📁 Created output directory: ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
const fetcher = new JiraSchemaFetcher(JIRA_HOST, JIRA_PAT);
|
||||
|
||||
// Test connection
|
||||
console.log('🔌 Testing connection to Jira Assets API...');
|
||||
const connected = await fetcher.testConnection();
|
||||
if (!connected) {
|
||||
console.error('❌ Failed to connect to Jira Assets API');
|
||||
console.error(' Please check your JIRA_HOST and JIRA_PAT settings');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ Connection successful');
|
||||
console.log('');
|
||||
|
||||
// Fetch schema info
|
||||
console.log('📋 Fetching schema information...');
|
||||
const schema = await fetcher.fetchSchema(JIRA_SCHEMA_ID);
|
||||
if (!schema) {
|
||||
console.error(`❌ Failed to fetch schema ${JIRA_SCHEMA_ID}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` Schema: ${schema.name} (${schema.objectSchemaKey})`);
|
||||
console.log(` Total objects: ${schema.objectCount || 'unknown'}`);
|
||||
console.log('');
|
||||
|
||||
// Fetch ALL object types from the schema
|
||||
console.log('📦 Fetching all object types from schema...');
|
||||
const allObjectTypes = await fetcher.fetchAllObjectTypes(JIRA_SCHEMA_ID);
|
||||
|
||||
if (allObjectTypes.length === 0) {
|
||||
console.error('❌ No object types found in schema');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(` Found ${allObjectTypes.length} object types`);
|
||||
console.log('');
|
||||
|
||||
// Build a map of all type IDs to names for reference resolution
|
||||
const typeConfigs = new Map<number, { name: string; typeName: string }>();
|
||||
for (const ot of allObjectTypes) {
|
||||
typeConfigs.set(ot.id, {
|
||||
name: ot.name,
|
||||
typeName: toPascalCase(ot.name),
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch attributes for each object type
|
||||
console.log('🔍 Fetching attributes for each object type:');
|
||||
console.log('');
|
||||
|
||||
const generatedTypes: GeneratedObjectType[] = [];
|
||||
let totalAttributes = 0;
|
||||
|
||||
for (const objType of allObjectTypes) {
|
||||
const typeName = toPascalCase(objType.name);
|
||||
process.stdout.write(` ${objType.name.padEnd(50)} `);
|
||||
|
||||
const attributes = await fetcher.fetchAttributes(objType.id);
|
||||
|
||||
if (attributes.length === 0) {
|
||||
console.log('⚠️ (no attributes)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedAttributes = attributes.map(attr => parseAttribute(attr, typeConfigs));
|
||||
const syncPriority = determineSyncPriority(objType.name, objType.objectCount || 0);
|
||||
|
||||
generatedTypes.push({
|
||||
jiraTypeId: objType.id,
|
||||
name: objType.name,
|
||||
typeName,
|
||||
syncPriority,
|
||||
objectCount: objType.objectCount || 0,
|
||||
attributes: parsedAttributes,
|
||||
});
|
||||
|
||||
totalAttributes += attributes.length;
|
||||
console.log(`✅ ${attributes.length} attributes`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`📊 Summary: ${generatedTypes.length} types, ${totalAttributes} attributes total`);
|
||||
console.log('');
|
||||
|
||||
// Sort by sync priority
|
||||
generatedTypes.sort((a, b) => a.syncPriority - b.syncPriority);
|
||||
|
||||
// Generate files
|
||||
console.log('📝 Generating output files:');
|
||||
console.log('');
|
||||
|
||||
// 1. TypeScript types
|
||||
const typesContent = generateTypesFile(generatedTypes, generatedAt);
|
||||
const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts');
|
||||
fs.writeFileSync(typesPath, typesContent, 'utf-8');
|
||||
console.log(` ✅ ${typesPath}`);
|
||||
|
||||
// 2. Schema metadata
|
||||
const schemaContent = generateSchemaFile(generatedTypes, generatedAt);
|
||||
const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts');
|
||||
fs.writeFileSync(schemaPath, schemaContent, 'utf-8');
|
||||
console.log(` ✅ ${schemaPath}`);
|
||||
|
||||
// 3. Database DDL
|
||||
const dbContent = generateDatabaseSchema(generatedAt);
|
||||
const dbPath = path.join(OUTPUT_DIR, 'db-schema.sql');
|
||||
fs.writeFileSync(dbPath, dbContent, 'utf-8');
|
||||
console.log(` ✅ ${dbPath}`);
|
||||
|
||||
console.log('');
|
||||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Schema generation complete! ║');
|
||||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
console.log(`Generated at: ${generatedAt.toISOString()}`);
|
||||
console.log(`Object types: ${generatedTypes.length}`);
|
||||
console.log(`Attributes: ${totalAttributes}`);
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Review the generated files in src/generated/');
|
||||
console.log(' 2. Rebuild the application: npm run build');
|
||||
console.log(' 3. Restart the server to pick up the new schema');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Run
|
||||
main().catch(error => {
|
||||
console.error('');
|
||||
console.error('❌ Schema generation failed:', error);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,17 +1,42 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Load .env from project root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Try multiple possible .env locations (handles both tsx watch and compiled dist)
|
||||
const possibleEnvPaths = [
|
||||
path.resolve(process.cwd(), '.env'), // Project root from cwd
|
||||
path.resolve(__dirname, '../../../.env'), // From src/config/ to project root
|
||||
path.resolve(__dirname, '../../../../.env'), // From dist/config/ to project root
|
||||
];
|
||||
|
||||
for (const envPath of possibleEnvPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
console.log(`Environment loaded from: ${envPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication method type
|
||||
export type JiraAuthMethod = 'pat' | 'oauth';
|
||||
|
||||
interface Config {
|
||||
// Jira Assets
|
||||
jiraHost: string;
|
||||
jiraPat: string;
|
||||
jiraSchemaId: string;
|
||||
|
||||
// Jira OAuth 2.0 Configuration
|
||||
jiraOAuthEnabled: boolean;
|
||||
// Jira Authentication Method ('pat' or 'oauth')
|
||||
jiraAuthMethod: JiraAuthMethod;
|
||||
|
||||
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
|
||||
jiraPat: string;
|
||||
|
||||
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
|
||||
jiraOAuthClientId: string;
|
||||
jiraOAuthClientSecret: string;
|
||||
jiraOAuthCallbackUrl: string;
|
||||
@@ -20,38 +45,6 @@ interface Config {
|
||||
// Session Configuration
|
||||
sessionSecret: string;
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: string;
|
||||
jiraApplicationFunctionTypeId: string;
|
||||
jiraDynamicsFactorTypeId: string;
|
||||
jiraComplexityFactorTypeId: string;
|
||||
jiraNumberOfUsersTypeId: string;
|
||||
jiraGovernanceModelTypeId: string;
|
||||
jiraApplicationClusterTypeId: string;
|
||||
jiraApplicationTypeTypeId: string;
|
||||
jiraHostingTypeTypeId: string;
|
||||
jiraBusinessImpactAnalyseTypeId: string;
|
||||
jiraApplicationManagementHostingTypeId: string; // Object Type ID for "Application Management - Hosting"
|
||||
jiraApplicationManagementTAMTypeId: string; // Object Type ID for "Application Management - TAM"
|
||||
|
||||
// Attribute IDs
|
||||
jiraAttrApplicationFunction: string;
|
||||
jiraAttrDynamicsFactor: string;
|
||||
jiraAttrComplexityFactor: string;
|
||||
jiraAttrNumberOfUsers: string;
|
||||
jiraAttrGovernanceModel: string;
|
||||
jiraAttrApplicationCluster: string;
|
||||
jiraAttrApplicationType: string;
|
||||
jiraAttrPlatform: string;
|
||||
jiraAttrHostingType: string;
|
||||
jiraAttrBusinessImpactAnalyse: string;
|
||||
jiraAttrTechnischeArchitectuur: string; // Attribute ID for "Technische Architectuur (TA)"
|
||||
jiraAttrTechnicalApplicationManagementPrimary: string; // Attribute ID for "Technical Application Management Primary"
|
||||
jiraAttrTechnicalApplicationManagementSecondary: string; // Attribute ID for "Technical Application Management Secondary"
|
||||
jiraAttrOverrideFTE: string; // Attribute ID for "Application Management - Override FTE"
|
||||
jiraAttrApplicationManagementHosting: string; // Attribute ID for "Application Management - Hosting" (4939)
|
||||
jiraAttrApplicationManagementTAM: string; // Attribute ID for "Application Management - TAM" (4945)
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: string;
|
||||
openaiApiKey: string;
|
||||
@@ -72,26 +65,44 @@ interface Config {
|
||||
jiraApiBatchSize: number;
|
||||
}
|
||||
|
||||
function getEnvVar(name: string, defaultValue?: string): string {
|
||||
const value = process.env[name] || defaultValue;
|
||||
if (!value) {
|
||||
throw new Error(`Environment variable ${name} is required but not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
|
||||
return process.env[name] || defaultValue;
|
||||
}
|
||||
|
||||
// Helper to determine auth method with backward compatibility
|
||||
function getJiraAuthMethod(): JiraAuthMethod {
|
||||
// Check new JIRA_AUTH_METHOD first
|
||||
const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase();
|
||||
if (authMethod === 'oauth') return 'oauth';
|
||||
if (authMethod === 'pat') return 'pat';
|
||||
|
||||
// Backward compatibility: check JIRA_OAUTH_ENABLED
|
||||
const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true';
|
||||
if (oauthEnabled) return 'oauth';
|
||||
|
||||
// Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist
|
||||
const hasPat = !!getOptionalEnvVar('JIRA_PAT');
|
||||
const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET');
|
||||
|
||||
if (hasPat) return 'pat';
|
||||
if (hasOAuthCredentials) return 'oauth';
|
||||
|
||||
// Default to 'pat' (will show warning during validation)
|
||||
return 'pat';
|
||||
}
|
||||
|
||||
export const config: Config = {
|
||||
// Jira Assets
|
||||
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||
|
||||
// Jira OAuth 2.0 Configuration
|
||||
jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true',
|
||||
// Jira Authentication Method
|
||||
jiraAuthMethod: getJiraAuthMethod(),
|
||||
|
||||
// Jira Personal Access Token (for PAT authentication)
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
|
||||
// Jira OAuth 2.0 Configuration (for OAuth authentication)
|
||||
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
|
||||
@@ -100,38 +111,6 @@ export const config: Config = {
|
||||
// Session Configuration
|
||||
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'),
|
||||
jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'),
|
||||
jiraDynamicsFactorTypeId: getOptionalEnvVar('JIRA_DYNAMICS_FACTOR_TYPE_ID'),
|
||||
jiraComplexityFactorTypeId: getOptionalEnvVar('JIRA_COMPLEXITY_FACTOR_TYPE_ID'),
|
||||
jiraNumberOfUsersTypeId: getOptionalEnvVar('JIRA_NUMBER_OF_USERS_TYPE_ID'),
|
||||
jiraGovernanceModelTypeId: getOptionalEnvVar('JIRA_GOVERNANCE_MODEL_TYPE_ID'),
|
||||
jiraApplicationClusterTypeId: getOptionalEnvVar('JIRA_APPLICATION_CLUSTER_TYPE_ID'),
|
||||
jiraApplicationTypeTypeId: getOptionalEnvVar('JIRA_APPLICATION_TYPE_TYPE_ID'),
|
||||
jiraHostingTypeTypeId: getOptionalEnvVar('JIRA_HOSTING_TYPE_TYPE_ID', '39'),
|
||||
jiraBusinessImpactAnalyseTypeId: getOptionalEnvVar('JIRA_BUSINESS_IMPACT_ANALYSE_TYPE_ID', '41'),
|
||||
jiraApplicationManagementHostingTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_HOSTING_TYPE_ID', '438'),
|
||||
jiraApplicationManagementTAMTypeId: getOptionalEnvVar('JIRA_APPLICATION_MANAGEMENT_TAM_TYPE_ID', '439'),
|
||||
|
||||
// Attribute IDs
|
||||
jiraAttrApplicationFunction: getOptionalEnvVar('JIRA_ATTR_APPLICATION_FUNCTION'),
|
||||
jiraAttrDynamicsFactor: getOptionalEnvVar('JIRA_ATTR_DYNAMICS_FACTOR'),
|
||||
jiraAttrComplexityFactor: getOptionalEnvVar('JIRA_ATTR_COMPLEXITY_FACTOR'),
|
||||
jiraAttrNumberOfUsers: getOptionalEnvVar('JIRA_ATTR_NUMBER_OF_USERS'),
|
||||
jiraAttrGovernanceModel: getOptionalEnvVar('JIRA_ATTR_GOVERNANCE_MODEL'),
|
||||
jiraAttrApplicationCluster: getOptionalEnvVar('JIRA_ATTR_APPLICATION_CLUSTER'),
|
||||
jiraAttrApplicationType: getOptionalEnvVar('JIRA_ATTR_APPLICATION_TYPE'),
|
||||
jiraAttrPlatform: getOptionalEnvVar('JIRA_ATTR_PLATFORM'),
|
||||
jiraAttrHostingType: getOptionalEnvVar('JIRA_ATTR_HOSTING_TYPE', '355'),
|
||||
jiraAttrBusinessImpactAnalyse: getOptionalEnvVar('JIRA_ATTR_BUSINESS_IMPACT_ANALYSE', '368'),
|
||||
jiraAttrTechnischeArchitectuur: getOptionalEnvVar('JIRA_ATTR_TECHNISCHE_ARCHITECTUUR', '572'),
|
||||
jiraAttrTechnicalApplicationManagementPrimary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_PRIMARY', '377'),
|
||||
jiraAttrTechnicalApplicationManagementSecondary: getOptionalEnvVar('JIRA_ATTR_TECHNICAL_APPLICATION_MANAGEMENT_SECONDARY', '1330'),
|
||||
jiraAttrOverrideFTE: getOptionalEnvVar('JIRA_ATTR_OVERRIDE_FTE', '4932'),
|
||||
jiraAttrApplicationManagementHosting: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_HOSTING', '4939'),
|
||||
jiraAttrApplicationManagementTAM: getOptionalEnvVar('JIRA_ATTR_APPLICATION_MANAGEMENT_TAM', '4945'),
|
||||
|
||||
// AI API Keys
|
||||
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
||||
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
||||
@@ -154,10 +133,34 @@ export const config: Config = {
|
||||
|
||||
export function validateConfig(): void {
|
||||
const missingVars: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!config.jiraPat) missingVars.push('JIRA_PAT');
|
||||
// Validate authentication configuration based on selected method
|
||||
console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`);
|
||||
|
||||
if (config.jiraAuthMethod === 'pat') {
|
||||
if (!config.jiraPat) {
|
||||
missingVars.push('JIRA_PAT (required for PAT authentication)');
|
||||
}
|
||||
} else if (config.jiraAuthMethod === 'oauth') {
|
||||
if (!config.jiraOAuthClientId) {
|
||||
missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)');
|
||||
}
|
||||
if (!config.jiraOAuthClientSecret) {
|
||||
missingVars.push('JIRA_OAUTH_CLIENT_SECRET (required for OAuth authentication)');
|
||||
}
|
||||
if (!config.sessionSecret || config.sessionSecret === 'change-this-secret-in-production') {
|
||||
warnings.push('SESSION_SECRET should be set to a secure random value for OAuth sessions');
|
||||
}
|
||||
}
|
||||
|
||||
// General required config
|
||||
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID');
|
||||
if (!config.anthropicApiKey) missingVars.push('ANTHROPIC_API_KEY');
|
||||
if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled');
|
||||
|
||||
if (warnings.length > 0) {
|
||||
warnings.forEach(w => console.warn(`Warning: ${w}`));
|
||||
}
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`);
|
||||
|
||||
54
backend/src/generated/db-schema.sql
Normal file
54
backend/src/generated/db-schema.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
-- Generated from Jira Assets Schema via REST API
|
||||
-- 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 JSON 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 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)
|
||||
);
|
||||
|
||||
-- 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_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);
|
||||
894
backend/src/generated/jira-schema.ts
Normal file
894
backend/src/generated/jira-schema.ts
Normal file
@@ -0,0 +1,894 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
// Generated from Jira Assets Schema via REST API
|
||||
// Generated at: 2026-01-09T02:12:50.973Z
|
||||
//
|
||||
// Re-generate with: npm run generate-schema
|
||||
|
||||
// =============================================================================
|
||||
// Schema Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export interface AttributeDefinition {
|
||||
jiraId: number;
|
||||
name: string;
|
||||
fieldName: string;
|
||||
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
|
||||
isMultiple: boolean;
|
||||
isEditable: boolean;
|
||||
isRequired: boolean;
|
||||
isSystem: boolean;
|
||||
referenceTypeId?: number;
|
||||
referenceTypeName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ObjectTypeDefinition {
|
||||
jiraTypeId: number;
|
||||
name: string;
|
||||
typeName: string;
|
||||
syncPriority: number;
|
||||
objectCount: number;
|
||||
attributes: AttributeDefinition[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema Metadata
|
||||
// =============================================================================
|
||||
|
||||
export const SCHEMA_GENERATED_AT = '2026-01-09T02:12:50.973Z';
|
||||
export const SCHEMA_OBJECT_TYPE_COUNT = 35;
|
||||
export const SCHEMA_TOTAL_ATTRIBUTES = 365;
|
||||
|
||||
// =============================================================================
|
||||
// Object Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {
|
||||
'ApplicationComponent': {
|
||||
jiraTypeId: 38,
|
||||
name: 'Application Component',
|
||||
typeName: 'ApplicationComponent',
|
||||
syncPriority: 1,
|
||||
objectCount: 596,
|
||||
attributes: [
|
||||
{ jiraId: 569, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Niet aanpassen. GUID - Enterprise Architect' },
|
||||
{ jiraId: 341, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 425, name: 'SearchReference', fieldName: 'searchReference', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Additionele zoekwoorden t.b.v. search' },
|
||||
{ jiraId: 342, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Unieke naam object' },
|
||||
{ jiraId: 343, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 344, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 354, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: '* Application description' },
|
||||
{ jiraId: 4538, name: 'Organisation', fieldName: 'organisation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 390, referenceTypeName: 'Organisation' },
|
||||
{ jiraId: 4666, name: 'ApplicationFunction', fieldName: 'applicationFunction', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 403, referenceTypeName: 'ApplicationFunction' },
|
||||
{ jiraId: 2416, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Application Lifecycle Management' },
|
||||
{ jiraId: 416, name: 'Confluence Space', fieldName: 'confluenceSpace', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 399, name: 'Business Importance', fieldName: 'businessImportance', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 44, referenceTypeName: 'BusinessImportance' },
|
||||
{ jiraId: 4540, name: 'Zenya ID', fieldName: 'zenyaID', type: 'integer', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4615, name: 'Zenya URL', fieldName: 'zenyaURL', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 368, name: 'Business Impact Analyse', fieldName: 'businessImpactAnalyse', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 41, referenceTypeName: 'BusinessImpactAnalyse' },
|
||||
{ jiraId: 355, name: 'Application Component Hosting Type', fieldName: 'applicationComponentHostingType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 39, referenceTypeName: 'HostingType' },
|
||||
{ jiraId: 394, name: 'CustomDevelopment', fieldName: 'customDevelopment', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er sprake van eigen programmatuur?' },
|
||||
{ jiraId: 4927, name: 'Platform', fieldName: 'platform', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 358, name: 'Referenced Application Component', fieldName: 'referencedApplicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Welk application component maakt onderdeel uit van een ander Application Component' },
|
||||
{ jiraId: 359, name: 'Authentication Method', fieldName: 'authenticationMethod', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: '*HIDDEN* zie CMDB-488' },
|
||||
{ jiraId: 362, name: 'Monitoring', fieldName: 'monitoring', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Cross reference naar Application Component' },
|
||||
{ jiraId: 373, name: 'PII Data', fieldName: 'piiData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt applicatie gebruik van Persoonlijk identificeerbare informatie?' },
|
||||
{ jiraId: 374, name: 'Medical Data', fieldName: 'medicalData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt de Application Component gebruik van medische data?' },
|
||||
{ jiraId: 363, name: 'Supplier Product', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is de leverancier van de Application Component?' },
|
||||
{ jiraId: 364, name: 'Supplier Technical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 365, name: 'Supplier Implementation', fieldName: 'supplierImplementation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is leverancier van de implementatie?' },
|
||||
{ jiraId: 366, name: 'Supplier Consultancy', fieldName: 'supplierConsultancy', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 2365, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'Verantwoordelijk voor waarde en bedrijfsvoering van het systeem' },
|
||||
{ jiraId: 2366, name: 'System Owner', fieldName: 'systemOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 103, referenceTypeName: 'User', description: 'Verantwoordelijk voor technische werking en beheer van het systeem.' },
|
||||
{ jiraId: 2371, name: 'Functional Application Management', fieldName: 'functionalApplicationManagement', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4613, name: 'Technical Application Management', fieldName: 'technicalApplicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group' },
|
||||
{ jiraId: 377, name: 'Technical Application Management Primary', fieldName: 'technicalApplicationManagementPrimary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1330, name: 'Technical Application Management Secondary', fieldName: 'technicalApplicationManagementSecondary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1331, name: 'Medische Techniek', fieldName: 'medischeTechniek', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er een link met medische techniek' },
|
||||
{ jiraId: 572, name: 'Technische Architectuur (TA)', fieldName: 'technischeArchitectuurTA', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Komt uit Enterprise Architect mee' },
|
||||
{ jiraId: 4497, name: 'Measures', fieldName: 'measures', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4749, name: 'GenerateConfluenceSpace', fieldName: 'generateConfluenceSpace', type: 'boolean', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden' },
|
||||
{ jiraId: 4793, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4799, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4906, name: 'ICT Governance Model', fieldName: 'ictGovernanceModel', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 431, referenceTypeName: 'IctGovernanceModel' },
|
||||
{ jiraId: 4918, name: 'Application Management - Application Type', fieldName: 'applicationManagementApplicationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 436, referenceTypeName: 'ApplicationManagementApplicationType', description: 'De Type Classificatie bepaalt de aard en scope van het IT-object.' },
|
||||
{ jiraId: 4939, name: 'Application Management - Hosting', fieldName: 'applicationManagementHosting', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 438, referenceTypeName: 'ApplicationManagementHosting', description: 'Het Hosting-veld geeft aan waar de infrastructuur draait. Dit bepaalt mede de technische verantwoordelijkheden en compliance-eisen.' },
|
||||
{ jiraId: 4945, name: 'Application Management - TAM', fieldName: 'applicationManagementTAM', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 439, referenceTypeName: 'ApplicationManagementTam', description: 'Dit veld geeft aan wie het technisch applicatiebeheer uitvoert. Dit is de primaire factor voor het bepalen van het regiemodel.' },
|
||||
{ jiraId: 4903, name: 'Application Management - Dynamics Factor', fieldName: 'applicationManagementDynamicsFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 434, referenceTypeName: 'ApplicationManagementDynamicsFactor' },
|
||||
{ jiraId: 4904, name: 'Application Management - Complexity Factor', fieldName: 'applicationManagementComplexityFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 432, referenceTypeName: 'ApplicationManagementComplexityFactor' },
|
||||
{ jiraId: 4905, name: 'Application Management - Number of Users', fieldName: 'applicationManagementNumberOfUsers', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 433, referenceTypeName: 'ApplicationManagementNumberOfUsers' },
|
||||
{ jiraId: 4911, name: 'Application Management - Subteam', fieldName: 'applicationManagementSubteam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' },
|
||||
{ jiraId: 4932, name: 'Application Management - Override FTE', fieldName: 'applicationManagementOverrideFTE', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Flows': {
|
||||
jiraTypeId: 59,
|
||||
name: 'Flows',
|
||||
typeName: 'Flows',
|
||||
syncPriority: 1,
|
||||
objectCount: 903,
|
||||
attributes: [
|
||||
{ jiraId: 1104, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1105, name: 'Present In Import Enterprise Architect', fieldName: 'presentInImportEnterpriseArchitect', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 558, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 559, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 567, name: 'Source', fieldName: 'source', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Source' },
|
||||
{ jiraId: 4535, name: 'Search Reference Source', fieldName: 'searchReferenceSource', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 568, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Target' },
|
||||
{ jiraId: 4536, name: 'Search Reference Target', fieldName: 'searchReferenceTarget', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 564, name: 'Type', fieldName: 'type', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 565, name: 'Protocol', fieldName: 'protocol', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 566, name: 'Details', fieldName: 'details', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 570, name: 'Broker', fieldName: 'broker', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 571, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 560, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 561, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4801, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Server': {
|
||||
jiraTypeId: 48,
|
||||
name: 'Server',
|
||||
typeName: 'Server',
|
||||
syncPriority: 1,
|
||||
objectCount: 909,
|
||||
attributes: [
|
||||
{ jiraId: 417, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 418, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 450, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 441, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4500, name: 'VMLocation', fieldName: 'vMLocation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4501, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4762, name: 'AzureSubscription', fieldName: 'azureSubscription', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 418, referenceTypeName: 'AzureSubscription' },
|
||||
{ jiraId: 455, name: 'VMOSType', fieldName: 'vMOSType', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2406, name: 'Reboot group', fieldName: 'rebootGroup', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 251, referenceTypeName: 'Rebootgroups' },
|
||||
{ jiraId: 444, name: 'MemoryMB', fieldName: 'memoryMB', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2383, name: 'CPUCores', fieldName: 'cPUCores', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2384, name: 'NICCount', fieldName: 'nICCount', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2385, name: 'DataDisks', fieldName: 'dataDisks', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4720, name: 'PrivateIPAddress', fieldName: 'privateIPAddress', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4764, name: 'Cluster', fieldName: 'cluster', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4765, name: 'VMSize', fieldName: 'vMSize', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 419, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 420, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4592, name: 'DBA', fieldName: 'dBA', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4792, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4802, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4873, name: 'State', fieldName: 'state', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'AzureSubscription': {
|
||||
jiraTypeId: 418,
|
||||
name: 'AzureSubscription',
|
||||
typeName: 'AzureSubscription',
|
||||
syncPriority: 2,
|
||||
objectCount: 151,
|
||||
attributes: [
|
||||
{ jiraId: 4755, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4756, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the subscription' },
|
||||
{ jiraId: 4761, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4760, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server' },
|
||||
{ jiraId: 4759, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4757, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4758, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4803, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'PackageBuild': {
|
||||
jiraTypeId: 424,
|
||||
name: 'PackageBuild',
|
||||
typeName: 'PackageBuild',
|
||||
syncPriority: 2,
|
||||
objectCount: 496,
|
||||
attributes: [
|
||||
{ jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' },
|
||||
{ jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' },
|
||||
{ jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4824, name: 'BuildNumber', fieldName: 'buildNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4825, name: 'Architecture', fieldName: 'architecture', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4826, name: 'Environment', fieldName: 'environment', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4827, name: 'Language', fieldName: 'language', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4828, name: 'AppDelivery', fieldName: 'appDelivery', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4829, name: 'ADlocalMemberships', fieldName: 'aDlocalMemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4830, name: 'ADmemberships', fieldName: 'aDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4831, name: 'EntraIDmemberships', fieldName: 'entraIDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4832, name: 'Platform', fieldName: 'platform', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4833, name: 'InGoldenImage', fieldName: 'inGoldenImage', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4837, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Package': {
|
||||
jiraTypeId: 422,
|
||||
name: 'Package',
|
||||
typeName: 'Package',
|
||||
syncPriority: 2,
|
||||
objectCount: 299,
|
||||
attributes: [
|
||||
{ jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 4814, name: 'BusinessImportance', fieldName: 'businessImportance', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4815, name: 'Authentication', fieldName: 'authentication', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4834, name: 'MaintenanceContract', fieldName: 'maintenanceContract', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4838, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Certificate': {
|
||||
jiraTypeId: 406,
|
||||
name: 'Certificate',
|
||||
typeName: 'Certificate',
|
||||
syncPriority: 2,
|
||||
objectCount: 508,
|
||||
attributes: [
|
||||
{ jiraId: 4675, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4676, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Naam van het certificaat' },
|
||||
{ jiraId: 4686, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date)' },
|
||||
{ jiraId: 4695, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 407, referenceTypeName: 'CertificateType', description: 'Type certificaat' },
|
||||
{ jiraId: 4696, name: 'Classification Type', fieldName: 'classificationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 408, referenceTypeName: 'CertificateClassificationType', description: 'Classificatie type' },
|
||||
{ jiraId: 4698, name: 'Requester', fieldName: 'requester', type: 'boolean', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'De aanvrager (medewerker) van het betreffende certificaat' },
|
||||
{ jiraId: 4682, name: 'Issuing Authority', fieldName: 'issuingAuthority', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie die verantwoordelijk is voor de uitgifte van het certificaat' },
|
||||
{ jiraId: 4702, name: 'Issuing Supplier', fieldName: 'issuingSupplier', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie waar het certificaat is besteld.' },
|
||||
{ jiraId: 4697, name: 'Autorenew', fieldName: 'autorenew', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Indien een expiry date beschikbaar, dan op False' },
|
||||
{ jiraId: 4721, name: 'Expiry Date', fieldName: 'expiryDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: ' Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970' },
|
||||
{ jiraId: 4753, name: 'ReminderInDays', fieldName: 'reminderInDays', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Aantal dagen vanaf welk moment er een reminder mechanisme wordt gestart' },
|
||||
{ jiraId: 4797, name: 'ReminderMailbox', fieldName: 'reminderMailbox', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4683, name: 'Certificate Owner', fieldName: 'certificateOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'De (eind)verantwoordelijke (medewerker) van het certificaat binnen Zuyderland' },
|
||||
{ jiraId: 4699, name: 'IT Operations Team', fieldName: 'itOperationsTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor de installatie / configuratie certificaat' },
|
||||
{ jiraId: 4700, name: 'Application Management', fieldName: 'applicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor het applicatiebeheer van de gekoppelde Application Component / Dienst' },
|
||||
{ jiraId: 4701, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Verwijzing naar Application Component' },
|
||||
{ jiraId: 4680, name: 'Domain', fieldName: 'domain', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 252, referenceTypeName: 'Domain', description: 'Verwijzing naar Domain' },
|
||||
{ jiraId: 4681, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server', description: 'Verwijzing naar Server' },
|
||||
{ jiraId: 4679, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Algemene omschrijving' },
|
||||
{ jiraId: 4719, name: 'Extra Installatie', fieldName: 'extraInstallatie', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4677, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4678, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'PrivilegedUser': {
|
||||
jiraTypeId: 401,
|
||||
name: 'Privileged User',
|
||||
typeName: 'PrivilegedUser',
|
||||
syncPriority: 2,
|
||||
objectCount: 728,
|
||||
attributes: [
|
||||
{ jiraId: 4616, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4617, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4620, name: 'UPN', fieldName: 'uPN', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'User Principal Name' },
|
||||
{ jiraId: 4621, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Description' },
|
||||
{ jiraId: 4622, name: 'Topdesk ticket', fieldName: 'topdeskTicket', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4623, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4624, name: 'E-Mail', fieldName: 'eMail', type: 'textarea', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4625, name: 'Mobile', fieldName: 'mobile', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4626, name: 'MethodsRegistered', fieldName: 'methodsRegistered', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4627, name: 'Creation Date', fieldName: 'creationDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4628, name: 'Expiry Date', fieldName: 'expiryDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4629, name: 'Last Updated', fieldName: 'lastUpdated', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4630, name: 'Password Last Set', fieldName: 'passwordLastSet', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4631, name: 'Password never expires', fieldName: 'passwordNeverExpires', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4632, name: 'MFA Registered', fieldName: 'mfaRegistered', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4633, name: 'Self-Service Password Reset Used', fieldName: 'selfServicePasswordResetUsed', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4653, name: 'Self-Service Password Reset Enabled', fieldName: 'selfServicePasswordResetEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4634, name: 'Last Logon', fieldName: 'lastLogon', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4618, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4619, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4652, name: 'SID', fieldName: 'sID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4800, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Domain': {
|
||||
jiraTypeId: 252,
|
||||
name: 'Domain',
|
||||
typeName: 'Domain',
|
||||
syncPriority: 2,
|
||||
objectCount: 796,
|
||||
attributes: [
|
||||
{ jiraId: 2398, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 2399, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 2465, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2466, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4672, name: 'Registration date', fieldName: 'registrationDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4668, name: 'Domain Type', fieldName: 'domainType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2471, name: 'Domain Function', fieldName: 'domainFunction', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4669, name: 'DNS Management', fieldName: 'dnsManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 4673, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 2467, name: 'Application Component Monitoring', fieldName: 'applicationComponentMonitoring', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4543, name: 'Website availability', fieldName: 'websiteAvailability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Intern/extern/local' },
|
||||
{ jiraId: 4685, name: 'Redirect URL', fieldName: 'redirectURL', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4674, name: 'Mail enabled', fieldName: 'mailEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2472, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit' },
|
||||
{ jiraId: 2474, name: 'Confluence', fieldName: 'confluence', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2400, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 2401, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'Supplier': {
|
||||
jiraTypeId: 37,
|
||||
name: 'Supplier',
|
||||
typeName: 'Supplier',
|
||||
syncPriority: 2,
|
||||
objectCount: 471,
|
||||
attributes: [
|
||||
{ jiraId: 337, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 338, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 339, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 340, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 1143, name: 'Address', fieldName: 'address', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4635, name: 'Street', fieldName: 'street', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4636, name: 'House number', fieldName: 'houseNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4637, name: 'City', fieldName: 'city', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4638, name: 'Postal code', fieldName: 'postalCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4639, name: 'Country code', fieldName: 'countryCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1144, name: 'Company Telephone', fieldName: 'companyTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1145, name: 'Company Website', fieldName: 'companyWebsite', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1146, name: 'Company email', fieldName: 'companyEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1147, name: 'Servicedesk Telephone', fieldName: 'servicedeskTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1148, name: 'Servicedesk email', fieldName: 'servicedeskEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1149, name: 'Consultant 1', fieldName: 'consultant1', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2412, name: 'Consultant 1 e-mail', fieldName: 'consultant1EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1150, name: 'Consultant 2', fieldName: 'consultant2', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2413, name: 'Consultant 2 e-mail', fieldName: 'consultant2EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1151, name: 'Consultant 3', fieldName: 'consultant3', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2414, name: 'Consultant 3 e-mail', fieldName: 'consultant3EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Software': {
|
||||
jiraTypeId: 421,
|
||||
name: 'Software',
|
||||
typeName: 'Software',
|
||||
syncPriority: 2,
|
||||
objectCount: 307,
|
||||
attributes: [
|
||||
{ jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
|
||||
{ jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
|
||||
{ jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }
|
||||
],
|
||||
},
|
||||
'SoftwarePatch': {
|
||||
jiraTypeId: 423,
|
||||
name: 'SoftwarePatch',
|
||||
typeName: 'SoftwarePatch',
|
||||
syncPriority: 2,
|
||||
objectCount: 555,
|
||||
attributes: [
|
||||
{ jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' },
|
||||
{ jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' },
|
||||
{ jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Manual': {
|
||||
jiraTypeId: 430,
|
||||
name: 'Manual',
|
||||
typeName: 'Manual',
|
||||
syncPriority: 5,
|
||||
objectCount: 23,
|
||||
attributes: [
|
||||
{ jiraId: 4865, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4866, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4869, name: 'Link', fieldName: 'link', type: 'float', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, description: 'Confluence manual page' },
|
||||
{ jiraId: 4870, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4867, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4868, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4871, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 430, referenceTypeName: 'Manual', description: 'If applicable' }
|
||||
],
|
||||
},
|
||||
'Measures': {
|
||||
jiraTypeId: 391,
|
||||
name: 'Measures',
|
||||
typeName: 'Measures',
|
||||
syncPriority: 5,
|
||||
objectCount: 11,
|
||||
attributes: [
|
||||
{ jiraId: 4512, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4513, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4516, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4522, name: 'Obliged', fieldName: 'obliged', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4521, name: 'Availability', fieldName: 'availability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4523, name: 'Optional', fieldName: 'optional', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4514, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4515, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'Rebootgroups': {
|
||||
jiraTypeId: 251,
|
||||
name: 'Rebootgroups',
|
||||
typeName: 'Rebootgroups',
|
||||
syncPriority: 5,
|
||||
objectCount: 14,
|
||||
attributes: [
|
||||
{ jiraId: 2391, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 2392, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 2397, name: 'Reboot day', fieldName: 'rebootDay', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2395, name: 'Reboot time', fieldName: 'rebootTime', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2396, name: 'Install information', fieldName: 'installInformation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2393, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 2394, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementTeam': {
|
||||
jiraTypeId: 440,
|
||||
name: 'Application Management - Team',
|
||||
typeName: 'ApplicationManagementTeam',
|
||||
syncPriority: 5,
|
||||
objectCount: 11,
|
||||
attributes: [
|
||||
{ jiraId: 4946, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4947, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4948, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4949, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4950, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementSubteam': {
|
||||
jiraTypeId: 435,
|
||||
name: 'Application Management - Subteam',
|
||||
typeName: 'ApplicationManagementSubteam',
|
||||
syncPriority: 5,
|
||||
objectCount: 19,
|
||||
attributes: [
|
||||
{ jiraId: 4907, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4951, name: 'Application Management - Team', fieldName: 'applicationManagementTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 440, referenceTypeName: 'ApplicationManagementTeam' },
|
||||
{ jiraId: 4908, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4909, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4910, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4912, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'Assets': {
|
||||
jiraTypeId: 42,
|
||||
name: 'Assets',
|
||||
typeName: 'Assets',
|
||||
syncPriority: 8,
|
||||
objectCount: 0,
|
||||
attributes: [
|
||||
{ jiraId: 384, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 385, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 386, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 387, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'CI': {
|
||||
jiraTypeId: 392,
|
||||
name: 'CI',
|
||||
typeName: 'CI',
|
||||
syncPriority: 8,
|
||||
objectCount: 0,
|
||||
attributes: [
|
||||
{ jiraId: 4552, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4553, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4554, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4555, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'Metadata': {
|
||||
jiraTypeId: 57,
|
||||
name: 'Metadata',
|
||||
typeName: 'Metadata',
|
||||
syncPriority: 8,
|
||||
objectCount: 0,
|
||||
attributes: [
|
||||
{ jiraId: 499, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 500, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4705, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 501, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 502, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementHosting': {
|
||||
jiraTypeId: 438,
|
||||
name: 'Application Management - Hosting',
|
||||
typeName: 'ApplicationManagementHosting',
|
||||
syncPriority: 8,
|
||||
objectCount: 4,
|
||||
attributes: [
|
||||
{ jiraId: 4933, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4934, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4935, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4936, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4938, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementTam': {
|
||||
jiraTypeId: 439,
|
||||
name: 'Application Management - TAM',
|
||||
typeName: 'ApplicationManagementTam',
|
||||
syncPriority: 8,
|
||||
objectCount: 4,
|
||||
attributes: [
|
||||
{ jiraId: 4940, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4941, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4942, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4943, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4944, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementNumberOfUsers': {
|
||||
jiraTypeId: 433,
|
||||
name: 'Application Management - Number of Users',
|
||||
typeName: 'ApplicationManagementNumberOfUsers',
|
||||
syncPriority: 8,
|
||||
objectCount: 7,
|
||||
attributes: [
|
||||
{ jiraId: 4889, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4890, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4891, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4892, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4893, name: 'Examples', fieldName: 'examples', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4894, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4895, name: 'Order', fieldName: 'order', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'CertificateType': {
|
||||
jiraTypeId: 407,
|
||||
name: 'Certificate Type',
|
||||
typeName: 'CertificateType',
|
||||
syncPriority: 10,
|
||||
objectCount: 3,
|
||||
attributes: [
|
||||
{ jiraId: 4687, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4688, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4689, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4690, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'ApplicationFunctionCategory': {
|
||||
jiraTypeId: 437,
|
||||
name: 'ApplicationFunctionCategory',
|
||||
typeName: 'ApplicationFunctionCategory',
|
||||
syncPriority: 10,
|
||||
objectCount: 13,
|
||||
attributes: [
|
||||
{ jiraId: 4921, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4922, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4923, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4924, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4925, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationFunction': {
|
||||
jiraTypeId: 403,
|
||||
name: 'ApplicationFunction',
|
||||
typeName: 'ApplicationFunction',
|
||||
syncPriority: 10,
|
||||
objectCount: 93,
|
||||
attributes: [
|
||||
{ jiraId: 4650, name: 'AppFuncGUID', fieldName: 'appFuncGUID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4646, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4926, name: 'Application Function Category', fieldName: 'applicationFunctionCategory', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 437, referenceTypeName: 'ApplicationFunctionCategory' },
|
||||
{ jiraId: 4647, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4651, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4920, name: 'Keywords', fieldName: 'keywords', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4648, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4649, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4919, name: 'Application Management - Application Cluster', fieldName: 'applicationManagementApplicationCluster', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' }
|
||||
],
|
||||
},
|
||||
'CertificateClassificationType': {
|
||||
jiraTypeId: 408,
|
||||
name: 'Certificate ClassificationType',
|
||||
typeName: 'CertificateClassificationType',
|
||||
syncPriority: 10,
|
||||
objectCount: 8,
|
||||
attributes: [
|
||||
{ jiraId: 4691, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4692, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4703, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'https://jira.zuyderland.nl/browse/CMDB-430' },
|
||||
{ jiraId: 4693, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4694, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'BusinessImpactAnalyse': {
|
||||
jiraTypeId: 41,
|
||||
name: 'Business Impact Analyse',
|
||||
typeName: 'BusinessImpactAnalyse',
|
||||
syncPriority: 10,
|
||||
objectCount: 6,
|
||||
attributes: [
|
||||
{ jiraId: 369, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 370, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 1107, name: 'Service Window', fieldName: 'serviceWindow', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1108, name: 'Availabilty', fieldName: 'availabilty', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 2411, name: 'BIA Check', fieldName: 'biaCheck', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1109, name: 'Recovery Time Objective (RTO)', fieldName: 'recoveryTimeObjectiveRTO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1110, name: 'Recovery Point Objective (RPO)', fieldName: 'recoveryPointObjectiveRPO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 1111, name: 'Environments', fieldName: 'environments', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4930, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4931, name: 'Indicators', fieldName: 'indicators', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 371, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 372, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'BusinessImportance': {
|
||||
jiraTypeId: 44,
|
||||
name: 'Business Importance',
|
||||
typeName: 'BusinessImportance',
|
||||
syncPriority: 10,
|
||||
objectCount: 8,
|
||||
attributes: [
|
||||
{ jiraId: 395, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 396, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4517, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 397, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 398, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'HostingType': {
|
||||
jiraTypeId: 39,
|
||||
name: 'Hosting Type',
|
||||
typeName: 'HostingType',
|
||||
syncPriority: 10,
|
||||
objectCount: 6,
|
||||
attributes: [
|
||||
{ jiraId: 345, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 346, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4520, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 347, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 348, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'Organisation': {
|
||||
jiraTypeId: 390,
|
||||
name: 'Organisation',
|
||||
typeName: 'Organisation',
|
||||
syncPriority: 10,
|
||||
objectCount: 6,
|
||||
attributes: [
|
||||
{ jiraId: 4507, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4508, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4511, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4509, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4510, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementApplicationType': {
|
||||
jiraTypeId: 436,
|
||||
name: 'Application Management - Application Type',
|
||||
typeName: 'ApplicationManagementApplicationType',
|
||||
syncPriority: 10,
|
||||
objectCount: 4,
|
||||
attributes: [
|
||||
{ jiraId: 4913, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4914, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4915, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4916, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4917, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementDynamicsFactor': {
|
||||
jiraTypeId: 434,
|
||||
name: 'Application Management - Dynamics Factor',
|
||||
typeName: 'ApplicationManagementDynamicsFactor',
|
||||
syncPriority: 10,
|
||||
objectCount: 4,
|
||||
attributes: [
|
||||
{ jiraId: 4896, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4897, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4898, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4899, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4900, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4928, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4901, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'ApplicationManagementComplexityFactor': {
|
||||
jiraTypeId: 432,
|
||||
name: 'Application Management - Complexity Factor',
|
||||
typeName: 'ApplicationManagementComplexityFactor',
|
||||
syncPriority: 10,
|
||||
objectCount: 4,
|
||||
attributes: [
|
||||
{ jiraId: 4883, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4884, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4885, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4886, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4887, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4929, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4888, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
},
|
||||
'IctGovernanceModel': {
|
||||
jiraTypeId: 431,
|
||||
name: 'ICT Governance Model',
|
||||
typeName: 'IctGovernanceModel',
|
||||
syncPriority: 10,
|
||||
objectCount: 6,
|
||||
attributes: [
|
||||
{ jiraId: 4874, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4875, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
|
||||
{ jiraId: 4876, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4877, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
|
||||
{ jiraId: 4878, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4879, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4880, name: 'Remarks', fieldName: 'remarks', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
|
||||
{ jiraId: 4881, name: 'Application', fieldName: 'application', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Lookup Maps
|
||||
// =============================================================================
|
||||
|
||||
/** Map from Jira Type ID to TypeScript type name */
|
||||
export const TYPE_ID_TO_NAME: Record<number, string> = {
|
||||
38: 'ApplicationComponent',
|
||||
59: 'Flows',
|
||||
48: 'Server',
|
||||
418: 'AzureSubscription',
|
||||
424: 'PackageBuild',
|
||||
422: 'Package',
|
||||
406: 'Certificate',
|
||||
401: 'PrivilegedUser',
|
||||
252: 'Domain',
|
||||
37: 'Supplier',
|
||||
421: 'Software',
|
||||
423: 'SoftwarePatch',
|
||||
430: 'Manual',
|
||||
391: 'Measures',
|
||||
251: 'Rebootgroups',
|
||||
440: 'ApplicationManagementTeam',
|
||||
435: 'ApplicationManagementSubteam',
|
||||
42: 'Assets',
|
||||
392: 'CI',
|
||||
57: 'Metadata',
|
||||
438: 'ApplicationManagementHosting',
|
||||
439: 'ApplicationManagementTam',
|
||||
433: 'ApplicationManagementNumberOfUsers',
|
||||
407: 'CertificateType',
|
||||
437: 'ApplicationFunctionCategory',
|
||||
403: 'ApplicationFunction',
|
||||
408: 'CertificateClassificationType',
|
||||
41: 'BusinessImpactAnalyse',
|
||||
44: 'BusinessImportance',
|
||||
39: 'HostingType',
|
||||
390: 'Organisation',
|
||||
436: 'ApplicationManagementApplicationType',
|
||||
434: 'ApplicationManagementDynamicsFactor',
|
||||
432: 'ApplicationManagementComplexityFactor',
|
||||
431: 'IctGovernanceModel',
|
||||
};
|
||||
|
||||
/** Map from TypeScript type name to Jira Type ID */
|
||||
export const TYPE_NAME_TO_ID: Record<string, number> = {
|
||||
'ApplicationComponent': 38,
|
||||
'Flows': 59,
|
||||
'Server': 48,
|
||||
'AzureSubscription': 418,
|
||||
'PackageBuild': 424,
|
||||
'Package': 422,
|
||||
'Certificate': 406,
|
||||
'PrivilegedUser': 401,
|
||||
'Domain': 252,
|
||||
'Supplier': 37,
|
||||
'Software': 421,
|
||||
'SoftwarePatch': 423,
|
||||
'Manual': 430,
|
||||
'Measures': 391,
|
||||
'Rebootgroups': 251,
|
||||
'ApplicationManagementTeam': 440,
|
||||
'ApplicationManagementSubteam': 435,
|
||||
'Assets': 42,
|
||||
'CI': 392,
|
||||
'Metadata': 57,
|
||||
'ApplicationManagementHosting': 438,
|
||||
'ApplicationManagementTam': 439,
|
||||
'ApplicationManagementNumberOfUsers': 433,
|
||||
'CertificateType': 407,
|
||||
'ApplicationFunctionCategory': 437,
|
||||
'ApplicationFunction': 403,
|
||||
'CertificateClassificationType': 408,
|
||||
'BusinessImpactAnalyse': 41,
|
||||
'BusinessImportance': 44,
|
||||
'HostingType': 39,
|
||||
'Organisation': 390,
|
||||
'ApplicationManagementApplicationType': 436,
|
||||
'ApplicationManagementDynamicsFactor': 434,
|
||||
'ApplicationManagementComplexityFactor': 432,
|
||||
'IctGovernanceModel': 431,
|
||||
};
|
||||
|
||||
/** Map from Jira object type name to TypeScript type name */
|
||||
export const JIRA_NAME_TO_TYPE: Record<string, string> = {
|
||||
'Application Component': 'ApplicationComponent',
|
||||
'Flows': 'Flows',
|
||||
'Server': 'Server',
|
||||
'AzureSubscription': 'AzureSubscription',
|
||||
'PackageBuild': 'PackageBuild',
|
||||
'Package': 'Package',
|
||||
'Certificate': 'Certificate',
|
||||
'Privileged User': 'PrivilegedUser',
|
||||
'Domain': 'Domain',
|
||||
'Supplier': 'Supplier',
|
||||
'Software': 'Software',
|
||||
'SoftwarePatch': 'SoftwarePatch',
|
||||
'Manual': 'Manual',
|
||||
'Measures': 'Measures',
|
||||
'Rebootgroups': 'Rebootgroups',
|
||||
'Application Management - Team': 'ApplicationManagementTeam',
|
||||
'Application Management - Subteam': 'ApplicationManagementSubteam',
|
||||
'Assets': 'Assets',
|
||||
'CI': 'CI',
|
||||
'Metadata': 'Metadata',
|
||||
'Application Management - Hosting': 'ApplicationManagementHosting',
|
||||
'Application Management - TAM': 'ApplicationManagementTam',
|
||||
'Application Management - Number of Users': 'ApplicationManagementNumberOfUsers',
|
||||
'Certificate Type': 'CertificateType',
|
||||
'ApplicationFunctionCategory': 'ApplicationFunctionCategory',
|
||||
'ApplicationFunction': 'ApplicationFunction',
|
||||
'Certificate ClassificationType': 'CertificateClassificationType',
|
||||
'Business Impact Analyse': 'BusinessImpactAnalyse',
|
||||
'Business Importance': 'BusinessImportance',
|
||||
'Hosting Type': 'HostingType',
|
||||
'Organisation': 'Organisation',
|
||||
'Application Management - Application Type': 'ApplicationManagementApplicationType',
|
||||
'Application Management - Dynamics Factor': 'ApplicationManagementDynamicsFactor',
|
||||
'Application Management - Complexity Factor': 'ApplicationManagementComplexityFactor',
|
||||
'ICT Governance Model': 'IctGovernanceModel',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/** Get attribute definition by type and field name */
|
||||
export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {
|
||||
const objectType = OBJECT_TYPES[typeName];
|
||||
if (!objectType) return undefined;
|
||||
return objectType.attributes.find(a => a.fieldName === fieldName);
|
||||
}
|
||||
|
||||
/** Get attribute definition by type and Jira attribute ID */
|
||||
export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {
|
||||
const objectType = OBJECT_TYPES[typeName];
|
||||
if (!objectType) return undefined;
|
||||
return objectType.attributes.find(a => a.jiraId === jiraId);
|
||||
}
|
||||
|
||||
/** Get attribute definition by type and Jira attribute name */
|
||||
export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {
|
||||
const objectType = OBJECT_TYPES[typeName];
|
||||
if (!objectType) return undefined;
|
||||
return objectType.attributes.find(a => a.name === attrName);
|
||||
}
|
||||
|
||||
/** Get attribute Jira ID by type and attribute name - throws if not found */
|
||||
export function getAttributeId(typeName: string, attrName: string): number {
|
||||
const attr = getAttributeByName(typeName, attrName);
|
||||
if (!attr) {
|
||||
throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);
|
||||
}
|
||||
return attr.jiraId;
|
||||
}
|
||||
|
||||
/** Get all reference attributes for a type */
|
||||
export function getReferenceAttributes(typeName: string): AttributeDefinition[] {
|
||||
const objectType = OBJECT_TYPES[typeName];
|
||||
if (!objectType) return [];
|
||||
return objectType.attributes.filter(a => a.type === 'reference');
|
||||
}
|
||||
|
||||
/** Get all object types sorted by sync priority */
|
||||
export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {
|
||||
return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);
|
||||
}
|
||||
933
backend/src/generated/jira-types.ts
Normal file
933
backend/src/generated/jira-types.ts
Normal file
@@ -0,0 +1,933 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
// Generated from Jira Assets Schema via REST API
|
||||
// Generated at: 2026-01-09T02:12:50.973Z
|
||||
//
|
||||
// Re-generate with: npm run generate-schema
|
||||
|
||||
// =============================================================================
|
||||
// Base Types
|
||||
// =============================================================================
|
||||
|
||||
/** Reference to another CMDB object */
|
||||
export interface ObjectReference {
|
||||
objectId: string;
|
||||
objectKey: string;
|
||||
label: string;
|
||||
// Optional enriched data from referenced object
|
||||
factor?: number;
|
||||
}
|
||||
|
||||
/** Base interface for all CMDB objects */
|
||||
export interface BaseCMDBObject {
|
||||
id: string;
|
||||
objectKey: string;
|
||||
label: string;
|
||||
_objectType: string;
|
||||
_jiraUpdatedAt: string;
|
||||
_jiraCreatedAt: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Object Type Interfaces
|
||||
// =============================================================================
|
||||
|
||||
/** Application Component (Jira Type ID: 38, 596 objects) */
|
||||
export interface ApplicationComponent extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationComponent';
|
||||
|
||||
// Scalar attributes
|
||||
reference: string | null; // Niet aanpassen. GUID - Enterprise Architect
|
||||
key: string | null;
|
||||
searchReference: string | null; // Additionele zoekwoorden t.b.v. search
|
||||
name: string | null; // Unieke naam object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null; // * Application description
|
||||
status: string | null; // Application Lifecycle Management
|
||||
confluenceSpace: number | null;
|
||||
zenyaID: number | null;
|
||||
zenyaURL: string | null;
|
||||
customDevelopment: boolean | null; // Is er sprake van eigen programmatuur?
|
||||
piiData: boolean | null; // Maakt applicatie gebruik van Persoonlijk identificeerbare informatie?
|
||||
medicalData: boolean | null; // Maakt de Application Component gebruik van medische data?
|
||||
functionalApplicationManagement: string | null;
|
||||
technicalApplicationManagementPrimary: boolean[];
|
||||
technicalApplicationManagementSecondary: boolean[];
|
||||
medischeTechniek: boolean | null; // Is er een link met medische techniek
|
||||
technischeArchitectuurTA: string | null; // Komt uit Enterprise Architect mee
|
||||
measures: string | null;
|
||||
generateConfluenceSpace: boolean | null; // Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden
|
||||
importDate: string | null;
|
||||
applicationManagementOverrideFTE: number | null;
|
||||
|
||||
// Reference attributes
|
||||
organisation: ObjectReference | null; // -> Organisation
|
||||
applicationFunction: ObjectReference[]; // -> ApplicationFunction
|
||||
businessImportance: ObjectReference | null; // -> BusinessImportance
|
||||
businessImpactAnalyse: ObjectReference | null; // -> BusinessImpactAnalyse
|
||||
applicationComponentHostingType: ObjectReference | null; // -> HostingType
|
||||
platform: ObjectReference | null; // -> ApplicationComponent
|
||||
referencedApplicationComponent: ObjectReference[]; // -> ApplicationComponent
|
||||
authenticationMethod: ObjectReference | null; // -> ApplicationComponent
|
||||
monitoring: ObjectReference | null; // -> ApplicationComponent
|
||||
supplierProduct: ObjectReference | null; // -> Supplier
|
||||
supplierTechnical: ObjectReference | null; // -> Supplier
|
||||
supplierImplementation: ObjectReference | null; // -> Supplier
|
||||
supplierConsultancy: ObjectReference | null; // -> Supplier
|
||||
businessOwner: ObjectReference | null; // -> Organization Unit
|
||||
systemOwner: ObjectReference | null; // -> User
|
||||
technicalApplicationManagement: ObjectReference | null; // -> group
|
||||
sourceStatus: ObjectReference | null;
|
||||
ictGovernanceModel: ObjectReference | null; // -> IctGovernanceModel
|
||||
applicationManagementApplicationType: ObjectReference | null; // -> ApplicationManagementApplicationType
|
||||
applicationManagementHosting: ObjectReference | null; // -> ApplicationManagementHosting
|
||||
applicationManagementTAM: ObjectReference | null; // -> ApplicationManagementTam
|
||||
applicationManagementDynamicsFactor: ObjectReference | null; // -> ApplicationManagementDynamicsFactor
|
||||
applicationManagementComplexityFactor: ObjectReference | null; // -> ApplicationManagementComplexityFactor
|
||||
applicationManagementNumberOfUsers: ObjectReference | null; // -> ApplicationManagementNumberOfUsers
|
||||
applicationManagementSubteam: ObjectReference | null; // -> ApplicationManagementSubteam
|
||||
|
||||
}
|
||||
|
||||
/** Flows (Jira Type ID: 59, 903 objects) */
|
||||
export interface Flows extends BaseCMDBObject {
|
||||
_objectType: 'Flows';
|
||||
|
||||
// Scalar attributes
|
||||
reference: string | null;
|
||||
presentInImportEnterpriseArchitect: boolean | null;
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
searchReferenceSource: string | null;
|
||||
searchReferenceTarget: string | null;
|
||||
type: string | null;
|
||||
protocol: string | null;
|
||||
details: string | null;
|
||||
broker: string | null;
|
||||
status: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
importDate: string | null;
|
||||
|
||||
// Reference attributes
|
||||
source: ObjectReference | null; // -> ApplicationComponent
|
||||
target: ObjectReference | null; // -> ApplicationComponent
|
||||
|
||||
}
|
||||
|
||||
/** Server (Jira Type ID: 48, 909 objects) */
|
||||
export interface Server extends BaseCMDBObject {
|
||||
_objectType: 'Server';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
vMLocation: string | null;
|
||||
vMOSType: string | null;
|
||||
memoryMB: number | null;
|
||||
cPUCores: number | null;
|
||||
nICCount: number | null;
|
||||
dataDisks: number | null;
|
||||
privateIPAddress: string | null;
|
||||
cluster: string | null;
|
||||
vMSize: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
dBA: boolean | null;
|
||||
importDate: string | null;
|
||||
state: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationComponent: ObjectReference | null; // -> ApplicationComponent
|
||||
azureSubscription: ObjectReference[]; // -> AzureSubscription
|
||||
rebootGroup: ObjectReference | null; // -> Rebootgroups
|
||||
sourceStatus: ObjectReference | null;
|
||||
|
||||
}
|
||||
|
||||
/** AzureSubscription (Jira Type ID: 418, 151 objects) */
|
||||
export interface AzureSubscription extends BaseCMDBObject {
|
||||
_objectType: 'AzureSubscription';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the subscription
|
||||
status: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
importDate: string | null;
|
||||
|
||||
// Reference attributes
|
||||
server: ObjectReference[]; // -> Server
|
||||
applicationComponent: ObjectReference | null; // -> ApplicationComponent
|
||||
|
||||
}
|
||||
|
||||
/** PackageBuild (Jira Type ID: 424, 496 objects) */
|
||||
export interface PackageBuild extends BaseCMDBObject {
|
||||
_objectType: 'PackageBuild';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // naamgeving: software-versie
|
||||
version: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
buildNumber: string | null;
|
||||
architecture: string | null;
|
||||
language: string | null;
|
||||
appDelivery: string | null;
|
||||
aDlocalMemberships: string | null;
|
||||
aDmemberships: string | null;
|
||||
entraIDmemberships: string | null;
|
||||
platform: string | null;
|
||||
inGoldenImage: boolean | null;
|
||||
importDate: string | null;
|
||||
|
||||
// Reference attributes
|
||||
software: ObjectReference | null; // -> Software
|
||||
status: ObjectReference | null;
|
||||
environment: ObjectReference[];
|
||||
|
||||
}
|
||||
|
||||
/** Package (Jira Type ID: 422, 299 objects) */
|
||||
export interface Package extends BaseCMDBObject {
|
||||
_objectType: 'Package';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
businessImportance: string | null;
|
||||
authentication: string | null;
|
||||
maintenanceContract: boolean | null;
|
||||
importDate: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationComponent: ObjectReference | null; // -> ApplicationComponent
|
||||
supplierProduct: ObjectReference | null; // -> Supplier
|
||||
supplierTechnical: ObjectReference | null; // -> Supplier
|
||||
|
||||
}
|
||||
|
||||
/** Certificate (Jira Type ID: 406, 508 objects) */
|
||||
export interface Certificate extends BaseCMDBObject {
|
||||
_objectType: 'Certificate';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // Naam van het certificaat
|
||||
status: string | null; // Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date)
|
||||
requester: boolean | null; // De aanvrager (medewerker) van het betreffende certificaat
|
||||
autorenew: boolean | null; // Indien een expiry date beschikbaar, dan op False
|
||||
expiryDate: string | null; // Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970
|
||||
reminderMailbox: boolean[];
|
||||
description: string | null; // Algemene omschrijving
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
type: ObjectReference | null; // -> CertificateType
|
||||
classificationType: ObjectReference | null; // -> CertificateClassificationType
|
||||
issuingAuthority: ObjectReference | null; // -> Supplier
|
||||
issuingSupplier: ObjectReference | null; // -> Supplier
|
||||
reminderInDays: ObjectReference | null;
|
||||
certificateOwner: ObjectReference | null; // -> Organization Unit
|
||||
itOperationsTeam: ObjectReference | null; // -> group
|
||||
applicationManagement: ObjectReference | null; // -> group
|
||||
applicationComponent: ObjectReference[]; // -> ApplicationComponent
|
||||
domain: ObjectReference[]; // -> Domain
|
||||
server: ObjectReference[]; // -> Server
|
||||
extraInstallatie: ObjectReference[]; // -> ApplicationComponent
|
||||
|
||||
}
|
||||
|
||||
/** Privileged User (Jira Type ID: 401, 728 objects) */
|
||||
export interface PrivilegedUser extends BaseCMDBObject {
|
||||
_objectType: 'PrivilegedUser';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
uPN: string | null; // User Principal Name
|
||||
description: string | null; // Description
|
||||
topdeskTicket: string | null;
|
||||
eMail: string | null;
|
||||
mobile: string | null;
|
||||
methodsRegistered: string | null;
|
||||
creationDate: string | null;
|
||||
expiryDate: string | null;
|
||||
lastUpdated: string | null;
|
||||
passwordLastSet: string | null;
|
||||
passwordNeverExpires: boolean | null;
|
||||
mfaRegistered: boolean | null;
|
||||
selfServicePasswordResetUsed: boolean | null;
|
||||
selfServicePasswordResetEnabled: boolean | null;
|
||||
lastLogon: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
sID: string | null;
|
||||
importDate: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationComponent: ObjectReference | null; // -> ApplicationComponent
|
||||
|
||||
}
|
||||
|
||||
/** Domain (Jira Type ID: 252, 796 objects) */
|
||||
export interface Domain extends BaseCMDBObject {
|
||||
_objectType: 'Domain';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
status: string | null;
|
||||
description: string | null;
|
||||
registrationDate: string | null;
|
||||
redirectURL: string | null;
|
||||
mailEnabled: boolean | null;
|
||||
confluence: number | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
domainType: ObjectReference | null;
|
||||
domainFunction: ObjectReference | null;
|
||||
dnsManagement: ObjectReference | null; // -> Supplier
|
||||
applicationComponent: ObjectReference[]; // -> ApplicationComponent
|
||||
applicationComponentMonitoring: ObjectReference[]; // -> ApplicationComponent
|
||||
websiteAvailability: ObjectReference | null;
|
||||
businessOwner: ObjectReference | null; // -> Organization Unit
|
||||
|
||||
}
|
||||
|
||||
/** Supplier (Jira Type ID: 37, 471 objects) */
|
||||
export interface Supplier extends BaseCMDBObject {
|
||||
_objectType: 'Supplier';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
address: string | null;
|
||||
street: string | null;
|
||||
houseNumber: string | null;
|
||||
city: string | null;
|
||||
postalCode: string | null;
|
||||
countryCode: string | null;
|
||||
companyTelephone: string | null;
|
||||
companyWebsite: string | null;
|
||||
companyEmail: string | null;
|
||||
servicedeskTelephone: string | null;
|
||||
servicedeskEmail: string | null;
|
||||
consultant1: string | null;
|
||||
consultant1EMail: string | null;
|
||||
consultant2: string | null;
|
||||
consultant2EMail: string | null;
|
||||
consultant3: string | null;
|
||||
consultant3EMail: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Software (Jira Type ID: 421, 307 objects) */
|
||||
export interface Software extends BaseCMDBObject {
|
||||
_objectType: 'Software';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationComponent: ObjectReference | null; // -> ApplicationComponent
|
||||
supplierProduct: ObjectReference | null; // -> Supplier
|
||||
supplierTechnical: ObjectReference | null; // -> Supplier
|
||||
|
||||
}
|
||||
|
||||
/** SoftwarePatch (Jira Type ID: 423, 555 objects) */
|
||||
export interface SoftwarePatch extends BaseCMDBObject {
|
||||
_objectType: 'SoftwarePatch';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // naamgeving: software-versie
|
||||
version: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
// Reference attributes
|
||||
software: ObjectReference | null; // -> Software
|
||||
status: ObjectReference | null;
|
||||
|
||||
}
|
||||
|
||||
/** Manual (Jira Type ID: 430, 23 objects) */
|
||||
export interface Manual extends BaseCMDBObject {
|
||||
_objectType: 'Manual';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
link: number[]; // Confluence manual page
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
type: ObjectReference | null;
|
||||
target: ObjectReference[]; // -> Manual
|
||||
|
||||
}
|
||||
|
||||
/** Measures (Jira Type ID: 391, 11 objects) */
|
||||
export interface Measures extends BaseCMDBObject {
|
||||
_objectType: 'Measures';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
obliged: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
availability: ObjectReference | null;
|
||||
optional: ObjectReference | null;
|
||||
|
||||
}
|
||||
|
||||
/** Rebootgroups (Jira Type ID: 251, 14 objects) */
|
||||
export interface Rebootgroups extends BaseCMDBObject {
|
||||
_objectType: 'Rebootgroups';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
rebootDay: string | null;
|
||||
rebootTime: string | null;
|
||||
installInformation: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Team (Jira Type ID: 440, 11 objects) */
|
||||
export interface ApplicationManagementTeam extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementTeam';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
type: ObjectReference | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Subteam (Jira Type ID: 435, 19 objects) */
|
||||
export interface ApplicationManagementSubteam extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementSubteam';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationManagementTeam: ObjectReference | null; // -> ApplicationManagementTeam
|
||||
|
||||
}
|
||||
|
||||
/** Assets (Jira Type ID: 42, 0 objects) */
|
||||
export interface Assets extends BaseCMDBObject {
|
||||
_objectType: 'Assets';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** CI (Jira Type ID: 392, 0 objects) */
|
||||
export interface CI extends BaseCMDBObject {
|
||||
_objectType: 'CI';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Metadata (Jira Type ID: 57, 0 objects) */
|
||||
export interface Metadata extends BaseCMDBObject {
|
||||
_objectType: 'Metadata';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Hosting (Jira Type ID: 438, 4 objects) */
|
||||
export interface ApplicationManagementHosting extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementHosting';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - TAM (Jira Type ID: 439, 4 objects) */
|
||||
export interface ApplicationManagementTam extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementTam';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Number of Users (Jira Type ID: 433, 7 objects) */
|
||||
export interface ApplicationManagementNumberOfUsers extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementNumberOfUsers';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
examples: string | null;
|
||||
factor: number | null;
|
||||
order: number | null;
|
||||
|
||||
}
|
||||
|
||||
/** Certificate Type (Jira Type ID: 407, 3 objects) */
|
||||
export interface CertificateType extends BaseCMDBObject {
|
||||
_objectType: 'CertificateType';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** ApplicationFunctionCategory (Jira Type ID: 437, 13 objects) */
|
||||
export interface ApplicationFunctionCategory extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationFunctionCategory';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** ApplicationFunction (Jira Type ID: 403, 93 objects) */
|
||||
export interface ApplicationFunction extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationFunction';
|
||||
|
||||
// Scalar attributes
|
||||
appFuncGUID: string | null;
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
keywords: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
// Reference attributes
|
||||
applicationFunctionCategory: ObjectReference | null; // -> ApplicationFunctionCategory
|
||||
applicationManagementApplicationCluster: ObjectReference | null; // -> ApplicationManagementSubteam
|
||||
|
||||
}
|
||||
|
||||
/** Certificate ClassificationType (Jira Type ID: 408, 8 objects) */
|
||||
export interface CertificateClassificationType extends BaseCMDBObject {
|
||||
_objectType: 'CertificateClassificationType';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null; // https://jira.zuyderland.nl/browse/CMDB-430
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Business Impact Analyse (Jira Type ID: 41, 6 objects) */
|
||||
export interface BusinessImpactAnalyse extends BaseCMDBObject {
|
||||
_objectType: 'BusinessImpactAnalyse';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
serviceWindow: string | null;
|
||||
availabilty: string | null;
|
||||
biaCheck: string | null;
|
||||
recoveryTimeObjectiveRTO: string | null;
|
||||
recoveryPointObjectiveRPO: string | null;
|
||||
environments: string | null;
|
||||
description: string | null;
|
||||
indicators: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Business Importance (Jira Type ID: 44, 8 objects) */
|
||||
export interface BusinessImportance extends BaseCMDBObject {
|
||||
_objectType: 'BusinessImportance';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Hosting Type (Jira Type ID: 39, 6 objects) */
|
||||
export interface HostingType extends BaseCMDBObject {
|
||||
_objectType: 'HostingType';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Organisation (Jira Type ID: 390, 6 objects) */
|
||||
export interface Organisation extends BaseCMDBObject {
|
||||
_objectType: 'Organisation';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
description: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Application Type (Jira Type ID: 436, 4 objects) */
|
||||
export interface ApplicationManagementApplicationType extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementApplicationType';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
description: string | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Dynamics Factor (Jira Type ID: 434, 4 objects) */
|
||||
export interface ApplicationManagementDynamicsFactor extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementDynamicsFactor';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
summary: string | null;
|
||||
description: string | null;
|
||||
factor: number | null;
|
||||
|
||||
}
|
||||
|
||||
/** Application Management - Complexity Factor (Jira Type ID: 432, 4 objects) */
|
||||
export interface ApplicationManagementComplexityFactor extends BaseCMDBObject {
|
||||
_objectType: 'ApplicationManagementComplexityFactor';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
summary: string | null;
|
||||
description: string | null;
|
||||
factor: number | null;
|
||||
|
||||
}
|
||||
|
||||
/** ICT Governance Model (Jira Type ID: 431, 6 objects) */
|
||||
export interface IctGovernanceModel extends BaseCMDBObject {
|
||||
_objectType: 'IctGovernanceModel';
|
||||
|
||||
// Scalar attributes
|
||||
key: string | null;
|
||||
name: string | null; // The name of the object
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
summary: string | null;
|
||||
description: string | null;
|
||||
remarks: string | null;
|
||||
application: string | null;
|
||||
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Union Types
|
||||
// =============================================================================
|
||||
|
||||
/** Union of all CMDB object types */
|
||||
export type CMDBObject =
|
||||
| ApplicationComponent
|
||||
| Flows
|
||||
| Server
|
||||
| AzureSubscription
|
||||
| PackageBuild
|
||||
| Package
|
||||
| Certificate
|
||||
| PrivilegedUser
|
||||
| Domain
|
||||
| Supplier
|
||||
| Software
|
||||
| SoftwarePatch
|
||||
| Manual
|
||||
| Measures
|
||||
| Rebootgroups
|
||||
| ApplicationManagementTeam
|
||||
| ApplicationManagementSubteam
|
||||
| Assets
|
||||
| CI
|
||||
| Metadata
|
||||
| ApplicationManagementHosting
|
||||
| ApplicationManagementTam
|
||||
| ApplicationManagementNumberOfUsers
|
||||
| CertificateType
|
||||
| ApplicationFunctionCategory
|
||||
| ApplicationFunction
|
||||
| CertificateClassificationType
|
||||
| BusinessImpactAnalyse
|
||||
| BusinessImportance
|
||||
| HostingType
|
||||
| Organisation
|
||||
| ApplicationManagementApplicationType
|
||||
| ApplicationManagementDynamicsFactor
|
||||
| ApplicationManagementComplexityFactor
|
||||
| IctGovernanceModel;
|
||||
|
||||
/** All valid object type names */
|
||||
export type CMDBObjectTypeName =
|
||||
| 'ApplicationComponent'
|
||||
| 'Flows'
|
||||
| 'Server'
|
||||
| 'AzureSubscription'
|
||||
| 'PackageBuild'
|
||||
| 'Package'
|
||||
| 'Certificate'
|
||||
| 'PrivilegedUser'
|
||||
| 'Domain'
|
||||
| 'Supplier'
|
||||
| 'Software'
|
||||
| 'SoftwarePatch'
|
||||
| 'Manual'
|
||||
| 'Measures'
|
||||
| 'Rebootgroups'
|
||||
| 'ApplicationManagementTeam'
|
||||
| 'ApplicationManagementSubteam'
|
||||
| 'Assets'
|
||||
| 'CI'
|
||||
| 'Metadata'
|
||||
| 'ApplicationManagementHosting'
|
||||
| 'ApplicationManagementTam'
|
||||
| 'ApplicationManagementNumberOfUsers'
|
||||
| 'CertificateType'
|
||||
| 'ApplicationFunctionCategory'
|
||||
| 'ApplicationFunction'
|
||||
| 'CertificateClassificationType'
|
||||
| 'BusinessImpactAnalyse'
|
||||
| 'BusinessImportance'
|
||||
| 'HostingType'
|
||||
| 'Organisation'
|
||||
| 'ApplicationManagementApplicationType'
|
||||
| 'ApplicationManagementDynamicsFactor'
|
||||
| 'ApplicationManagementComplexityFactor'
|
||||
| 'IctGovernanceModel';
|
||||
|
||||
// =============================================================================
|
||||
// Type Guards
|
||||
// =============================================================================
|
||||
|
||||
export function isApplicationComponent(obj: CMDBObject): obj is ApplicationComponent {
|
||||
return obj._objectType === 'ApplicationComponent';
|
||||
}
|
||||
|
||||
export function isFlows(obj: CMDBObject): obj is Flows {
|
||||
return obj._objectType === 'Flows';
|
||||
}
|
||||
|
||||
export function isServer(obj: CMDBObject): obj is Server {
|
||||
return obj._objectType === 'Server';
|
||||
}
|
||||
|
||||
export function isAzureSubscription(obj: CMDBObject): obj is AzureSubscription {
|
||||
return obj._objectType === 'AzureSubscription';
|
||||
}
|
||||
|
||||
export function isPackageBuild(obj: CMDBObject): obj is PackageBuild {
|
||||
return obj._objectType === 'PackageBuild';
|
||||
}
|
||||
|
||||
export function isPackage(obj: CMDBObject): obj is Package {
|
||||
return obj._objectType === 'Package';
|
||||
}
|
||||
|
||||
export function isCertificate(obj: CMDBObject): obj is Certificate {
|
||||
return obj._objectType === 'Certificate';
|
||||
}
|
||||
|
||||
export function isPrivilegedUser(obj: CMDBObject): obj is PrivilegedUser {
|
||||
return obj._objectType === 'PrivilegedUser';
|
||||
}
|
||||
|
||||
export function isDomain(obj: CMDBObject): obj is Domain {
|
||||
return obj._objectType === 'Domain';
|
||||
}
|
||||
|
||||
export function isSupplier(obj: CMDBObject): obj is Supplier {
|
||||
return obj._objectType === 'Supplier';
|
||||
}
|
||||
|
||||
export function isSoftware(obj: CMDBObject): obj is Software {
|
||||
return obj._objectType === 'Software';
|
||||
}
|
||||
|
||||
export function isSoftwarePatch(obj: CMDBObject): obj is SoftwarePatch {
|
||||
return obj._objectType === 'SoftwarePatch';
|
||||
}
|
||||
|
||||
export function isManual(obj: CMDBObject): obj is Manual {
|
||||
return obj._objectType === 'Manual';
|
||||
}
|
||||
|
||||
export function isMeasures(obj: CMDBObject): obj is Measures {
|
||||
return obj._objectType === 'Measures';
|
||||
}
|
||||
|
||||
export function isRebootgroups(obj: CMDBObject): obj is Rebootgroups {
|
||||
return obj._objectType === 'Rebootgroups';
|
||||
}
|
||||
|
||||
export function isApplicationManagementTeam(obj: CMDBObject): obj is ApplicationManagementTeam {
|
||||
return obj._objectType === 'ApplicationManagementTeam';
|
||||
}
|
||||
|
||||
export function isApplicationManagementSubteam(obj: CMDBObject): obj is ApplicationManagementSubteam {
|
||||
return obj._objectType === 'ApplicationManagementSubteam';
|
||||
}
|
||||
|
||||
export function isAssets(obj: CMDBObject): obj is Assets {
|
||||
return obj._objectType === 'Assets';
|
||||
}
|
||||
|
||||
export function isCI(obj: CMDBObject): obj is CI {
|
||||
return obj._objectType === 'CI';
|
||||
}
|
||||
|
||||
export function isMetadata(obj: CMDBObject): obj is Metadata {
|
||||
return obj._objectType === 'Metadata';
|
||||
}
|
||||
|
||||
export function isApplicationManagementHosting(obj: CMDBObject): obj is ApplicationManagementHosting {
|
||||
return obj._objectType === 'ApplicationManagementHosting';
|
||||
}
|
||||
|
||||
export function isApplicationManagementTam(obj: CMDBObject): obj is ApplicationManagementTam {
|
||||
return obj._objectType === 'ApplicationManagementTam';
|
||||
}
|
||||
|
||||
export function isApplicationManagementNumberOfUsers(obj: CMDBObject): obj is ApplicationManagementNumberOfUsers {
|
||||
return obj._objectType === 'ApplicationManagementNumberOfUsers';
|
||||
}
|
||||
|
||||
export function isCertificateType(obj: CMDBObject): obj is CertificateType {
|
||||
return obj._objectType === 'CertificateType';
|
||||
}
|
||||
|
||||
export function isApplicationFunctionCategory(obj: CMDBObject): obj is ApplicationFunctionCategory {
|
||||
return obj._objectType === 'ApplicationFunctionCategory';
|
||||
}
|
||||
|
||||
export function isApplicationFunction(obj: CMDBObject): obj is ApplicationFunction {
|
||||
return obj._objectType === 'ApplicationFunction';
|
||||
}
|
||||
|
||||
export function isCertificateClassificationType(obj: CMDBObject): obj is CertificateClassificationType {
|
||||
return obj._objectType === 'CertificateClassificationType';
|
||||
}
|
||||
|
||||
export function isBusinessImpactAnalyse(obj: CMDBObject): obj is BusinessImpactAnalyse {
|
||||
return obj._objectType === 'BusinessImpactAnalyse';
|
||||
}
|
||||
|
||||
export function isBusinessImportance(obj: CMDBObject): obj is BusinessImportance {
|
||||
return obj._objectType === 'BusinessImportance';
|
||||
}
|
||||
|
||||
export function isHostingType(obj: CMDBObject): obj is HostingType {
|
||||
return obj._objectType === 'HostingType';
|
||||
}
|
||||
|
||||
export function isOrganisation(obj: CMDBObject): obj is Organisation {
|
||||
return obj._objectType === 'Organisation';
|
||||
}
|
||||
|
||||
export function isApplicationManagementApplicationType(obj: CMDBObject): obj is ApplicationManagementApplicationType {
|
||||
return obj._objectType === 'ApplicationManagementApplicationType';
|
||||
}
|
||||
|
||||
export function isApplicationManagementDynamicsFactor(obj: CMDBObject): obj is ApplicationManagementDynamicsFactor {
|
||||
return obj._objectType === 'ApplicationManagementDynamicsFactor';
|
||||
}
|
||||
|
||||
export function isApplicationManagementComplexityFactor(obj: CMDBObject): obj is ApplicationManagementComplexityFactor {
|
||||
return obj._objectType === 'ApplicationManagementComplexityFactor';
|
||||
}
|
||||
|
||||
export function isIctGovernanceModel(obj: CMDBObject): obj is IctGovernanceModel {
|
||||
return obj._objectType === 'IctGovernanceModel';
|
||||
}
|
||||
@@ -6,13 +6,18 @@ import cookieParser from 'cookie-parser';
|
||||
import { config, validateConfig } from './config/env.js';
|
||||
import { logger } from './services/logger.js';
|
||||
import { dataService } from './services/dataService.js';
|
||||
import { syncEngine } from './services/syncEngine.js';
|
||||
import { cmdbService } from './services/cmdbService.js';
|
||||
import applicationsRouter from './routes/applications.js';
|
||||
import classificationsRouter from './routes/classifications.js';
|
||||
import referenceDataRouter from './routes/referenceData.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import configurationRouter from './routes/configuration.js';
|
||||
import authRouter, { authMiddleware } from './routes/auth.js';
|
||||
import { jiraAssetsService } from './services/jiraAssets.js';
|
||||
import searchRouter from './routes/search.js';
|
||||
import cacheRouter from './routes/cache.js';
|
||||
import objectsRouter from './routes/objects.js';
|
||||
import schemaRouter from './routes/schema.js';
|
||||
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
@@ -50,16 +55,16 @@ app.use((req, res, next) => {
|
||||
// Auth middleware - extract session info for all requests
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Set user token on JiraAssets service for each request
|
||||
// Set user token on CMDBService for each request (for user-specific OAuth)
|
||||
app.use((req, res, next) => {
|
||||
// Set user's OAuth token if available
|
||||
if (req.accessToken) {
|
||||
jiraAssetsService.setRequestToken(req.accessToken);
|
||||
cmdbService.setUserToken(req.accessToken);
|
||||
}
|
||||
|
||||
// Clear token after response is sent
|
||||
res.on('finish', () => {
|
||||
jiraAssetsService.clearRequestToken();
|
||||
cmdbService.clearUserToken();
|
||||
});
|
||||
|
||||
next();
|
||||
@@ -68,12 +73,19 @@ app.use((req, res, next) => {
|
||||
// Health check
|
||||
app.get('/health', async (req, res) => {
|
||||
const jiraConnected = await dataService.testConnection();
|
||||
const cacheStatus = dataService.getCacheStatus();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets' : 'mock-data',
|
||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
|
||||
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
||||
aiConfigured: !!config.anthropicApiKey,
|
||||
cache: {
|
||||
isWarm: cacheStatus.isWarm,
|
||||
objectCount: cacheStatus.totalObjects,
|
||||
lastSync: cacheStatus.lastIncrementalSync,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +103,10 @@ app.use('/api/classifications', classificationsRouter);
|
||||
app.use('/api/reference-data', referenceDataRouter);
|
||||
app.use('/api/dashboard', dashboardRouter);
|
||||
app.use('/api/configuration', configurationRouter);
|
||||
app.use('/api/search', searchRouter);
|
||||
app.use('/api/cache', cacheRouter);
|
||||
app.use('/api/objects', objectsRouter);
|
||||
app.use('/api/schema', schemaRouter);
|
||||
|
||||
// Error handling
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
@@ -108,20 +124,33 @@ app.use((req, res) => {
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
logger.info(`Server running on http://localhost:${PORT}`);
|
||||
logger.info(`Environment: ${config.nodeEnv}`);
|
||||
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
|
||||
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured' : 'Using mock data'}`);
|
||||
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`);
|
||||
|
||||
// Initialize sync engine if using Jira Assets
|
||||
if (config.jiraPat && config.jiraSchemaId) {
|
||||
try {
|
||||
await syncEngine.initialize();
|
||||
logger.info('Sync Engine: Initialized and running');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize sync engine', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM signal received: closing HTTP server');
|
||||
process.exit(0);
|
||||
});
|
||||
const shutdown = () => {
|
||||
logger.info('Shutdown signal received: stopping services...');
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT signal received: closing HTTP server');
|
||||
// Stop sync engine
|
||||
syncEngine.stop();
|
||||
|
||||
logger.info('Services stopped, exiting');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { dataService } from '../services/dataService.js';
|
||||
import { databaseService } from '../services/database.js';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
|
||||
import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
|
||||
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.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';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -50,9 +52,12 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// Get application by ID
|
||||
// Query params:
|
||||
// - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection)
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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') {
|
||||
@@ -60,7 +65,10 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
// Edit mode: force refresh from Jira for fresh data + conflict detection
|
||||
const application = mode === 'edit'
|
||||
? await dataService.getApplicationForEdit(id)
|
||||
: await dataService.getApplicationById(id);
|
||||
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
@@ -74,18 +82,32 @@ router.get('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update application
|
||||
// Update application with conflict detection
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body as {
|
||||
const { updates, _jiraUpdatedAt } = req.body as {
|
||||
updates?: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationSubteam?: ReferenceValue;
|
||||
applicationTeam?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
overrideFTE?: number | null;
|
||||
applicationManagementHosting?: string;
|
||||
applicationManagementTAM?: string;
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
};
|
||||
_jiraUpdatedAt?: string;
|
||||
};
|
||||
|
||||
// Support both new format (updates object) and legacy format (direct body)
|
||||
const actualUpdates = updates || req.body;
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
@@ -95,61 +117,111 @@ router.put('/:id', async (req: Request, res: Response) => {
|
||||
|
||||
// Build changes object for history
|
||||
const changes: ClassificationResult['changes'] = {};
|
||||
if (updates.applicationFunctions) {
|
||||
if (actualUpdates.applicationFunctions) {
|
||||
changes.applicationFunctions = {
|
||||
from: application.applicationFunctions,
|
||||
to: updates.applicationFunctions,
|
||||
to: actualUpdates.applicationFunctions,
|
||||
};
|
||||
}
|
||||
if (updates.dynamicsFactor) {
|
||||
if (actualUpdates.dynamicsFactor) {
|
||||
changes.dynamicsFactor = {
|
||||
from: application.dynamicsFactor,
|
||||
to: updates.dynamicsFactor,
|
||||
to: actualUpdates.dynamicsFactor,
|
||||
};
|
||||
}
|
||||
if (updates.complexityFactor) {
|
||||
if (actualUpdates.complexityFactor) {
|
||||
changes.complexityFactor = {
|
||||
from: application.complexityFactor,
|
||||
to: updates.complexityFactor,
|
||||
to: actualUpdates.complexityFactor,
|
||||
};
|
||||
}
|
||||
if (updates.numberOfUsers) {
|
||||
if (actualUpdates.numberOfUsers) {
|
||||
changes.numberOfUsers = {
|
||||
from: application.numberOfUsers,
|
||||
to: updates.numberOfUsers,
|
||||
to: actualUpdates.numberOfUsers,
|
||||
};
|
||||
}
|
||||
if (updates.governanceModel) {
|
||||
if (actualUpdates.governanceModel) {
|
||||
changes.governanceModel = {
|
||||
from: application.governanceModel,
|
||||
to: updates.governanceModel,
|
||||
to: actualUpdates.governanceModel,
|
||||
};
|
||||
}
|
||||
|
||||
const success = await dataService.updateApplication(id, updates);
|
||||
// Call updateApplication with conflict detection if _jiraUpdatedAt is provided
|
||||
const result = await dataService.updateApplication(id, actualUpdates, _jiraUpdatedAt);
|
||||
|
||||
// Check for conflicts
|
||||
if (!result.success && result.conflict) {
|
||||
// Return 409 Conflict with details
|
||||
res.status(409).json({
|
||||
status: 'conflict',
|
||||
message: 'Object is gewijzigd door iemand anders',
|
||||
conflicts: result.conflict.conflicts,
|
||||
jiraUpdatedAt: result.conflict.jiraUpdatedAt,
|
||||
canMerge: result.conflict.canMerge,
|
||||
warning: result.conflict.warning,
|
||||
actions: {
|
||||
forceOverwrite: true,
|
||||
merge: result.conflict.canMerge || false,
|
||||
discard: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
res.status(500).json({ error: result.error || 'Failed to update application' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Save to classification history
|
||||
const classificationResult: ClassificationResult = {
|
||||
applicationId: id,
|
||||
applicationName: application.name,
|
||||
changes,
|
||||
source: updates.source || 'MANUAL',
|
||||
source: actualUpdates.source || 'MANUAL',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
databaseService.saveClassificationResult(classificationResult);
|
||||
|
||||
const updatedApp = await dataService.getApplicationById(id);
|
||||
// Return updated application
|
||||
const updatedApp = result.data || await dataService.getApplicationById(id);
|
||||
res.json(updatedApp);
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to update application' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to update application', error);
|
||||
res.status(500).json({ error: 'Failed to update application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Force update (ignore conflicts)
|
||||
router.put('/:id/force', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const application = await dataService.getApplicationById(id);
|
||||
if (!application) {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Force update without conflict check
|
||||
const result = await dataService.updateApplication(id, updates);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(500).json({ error: result.error || 'Failed to update application' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedApp = result.data || await dataService.getApplicationById(id);
|
||||
res.json(updatedApp);
|
||||
} catch (error) {
|
||||
logger.error('Failed to force update application', error);
|
||||
res.status(500).json({ error: 'Failed to force update application' });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate FTE effort for an application (real-time calculation without saving)
|
||||
router.post('/calculate-effort', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -180,7 +252,8 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
|
||||
complexityFactor: applicationData.complexityFactor || null,
|
||||
numberOfUsers: applicationData.numberOfUsers || null,
|
||||
governanceModel: applicationData.governanceModel || null,
|
||||
applicationCluster: applicationData.applicationCluster || null,
|
||||
applicationSubteam: applicationData.applicationSubteam || null,
|
||||
applicationTeam: applicationData.applicationTeam || null,
|
||||
applicationType: applicationData.applicationType || null,
|
||||
platform: applicationData.platform || null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -214,4 +287,120 @@ router.get('/:id/history', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get related objects for an application (from cache)
|
||||
router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, objectType } = req.params;
|
||||
|
||||
// Map object type string to CMDBObjectTypeName
|
||||
const typeMap: Record<string, CMDBObjectTypeName> = {
|
||||
'Server': 'Server',
|
||||
'server': 'Server',
|
||||
'Flows': 'Flows',
|
||||
'flows': 'Flows',
|
||||
'Flow': 'Flows',
|
||||
'flow': 'Flows',
|
||||
'Connection': 'Flows', // Frontend uses "Connection" for Flows
|
||||
'connection': 'Flows',
|
||||
'Certificate': 'Certificate',
|
||||
'certificate': 'Certificate',
|
||||
'Domain': 'Domain',
|
||||
'domain': 'Domain',
|
||||
'AzureSubscription': 'AzureSubscription',
|
||||
'azuresubscription': 'AzureSubscription',
|
||||
};
|
||||
|
||||
const typeName = typeMap[objectType];
|
||||
if (!typeName) {
|
||||
res.status(400).json({ error: `Unknown object type: ${objectType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use CMDBService to get related objects from cache
|
||||
type RelatedObjectType = Server | Flows | Certificate | Domain | AzureSubscription;
|
||||
let relatedObjects: RelatedObjectType[] = [];
|
||||
|
||||
switch (typeName) {
|
||||
case 'Server':
|
||||
relatedObjects = await cmdbService.getReferencingObjects<Server>(id, 'Server');
|
||||
break;
|
||||
case 'Flows': {
|
||||
// Flows reference ApplicationComponents via Source and Target attributes
|
||||
// We need to find Flows where this ApplicationComponent is the target of the reference
|
||||
relatedObjects = await cmdbService.getReferencingObjects<Flows>(id, 'Flows');
|
||||
break;
|
||||
}
|
||||
case 'Certificate':
|
||||
relatedObjects = await cmdbService.getReferencingObjects<Certificate>(id, 'Certificate');
|
||||
break;
|
||||
case 'Domain':
|
||||
relatedObjects = await cmdbService.getReferencingObjects<Domain>(id, 'Domain');
|
||||
break;
|
||||
case 'AzureSubscription':
|
||||
relatedObjects = await cmdbService.getReferencingObjects<AzureSubscription>(id, 'AzureSubscription');
|
||||
break;
|
||||
default:
|
||||
relatedObjects = [];
|
||||
}
|
||||
|
||||
// Get requested attributes from query string
|
||||
const requestedAttrs = req.query.attributes
|
||||
? String(req.query.attributes).split(',').map(a => a.trim())
|
||||
: [];
|
||||
|
||||
// Format response - must match RelatedObjectsResponse type expected by frontend
|
||||
const objects = relatedObjects.map(obj => {
|
||||
// Extract attributes from the object
|
||||
const attributes: Record<string, string | null> = {};
|
||||
const objData = obj as Record<string, unknown>;
|
||||
|
||||
// If specific attributes are requested, extract those
|
||||
if (requestedAttrs.length > 0) {
|
||||
for (const attrName of requestedAttrs) {
|
||||
// Convert attribute name to camelCase field name
|
||||
const fieldName = attrName.charAt(0).toLowerCase() + attrName.slice(1).replace(/\s+/g, '');
|
||||
const value = objData[fieldName] ?? objData[attrName.toLowerCase()] ?? objData[attrName];
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
attributes[attrName] = null;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// ObjectReference - extract label
|
||||
const ref = value as { label?: string; name?: string; displayValue?: string };
|
||||
attributes[attrName] = ref.label || ref.name || ref.displayValue || null;
|
||||
} else {
|
||||
attributes[attrName] = String(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No specific attributes requested - include common ones
|
||||
if ('status' in objData) {
|
||||
const status = objData.status;
|
||||
if (typeof status === 'object' && status !== null) {
|
||||
attributes['Status'] = (status as { label?: string }).label || String(status);
|
||||
} else if (status) {
|
||||
attributes['Status'] = String(status);
|
||||
}
|
||||
}
|
||||
if ('state' in objData) {
|
||||
attributes['State'] = objData.state ? String(objData.state) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: obj.id,
|
||||
key: obj.objectKey,
|
||||
label: obj.label,
|
||||
name: obj.label,
|
||||
objectType: obj._objectType,
|
||||
attributes,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ objects, total: objects.length });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get related ${req.params.objectType} objects`, error);
|
||||
res.status(500).json({ error: `Failed to get related objects` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -18,9 +18,14 @@ declare global {
|
||||
|
||||
// Get auth configuration
|
||||
router.get('/config', (req: Request, res: Response) => {
|
||||
const authMethod = authService.getAuthMethod();
|
||||
res.json({
|
||||
// Configured authentication method ('pat', 'oauth', or 'none')
|
||||
authMethod,
|
||||
// Legacy fields for backward compatibility
|
||||
oauthEnabled: authService.isOAuthEnabled(),
|
||||
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||
// Jira host for display purposes
|
||||
jiraHost: config.jiraHost,
|
||||
});
|
||||
});
|
||||
|
||||
135
backend/src/routes/cache.ts
Normal file
135
backend/src/routes/cache.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Cache management routes
|
||||
*
|
||||
* Provides endpoints for cache status and manual sync triggers.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cacheStore } from '../services/cacheStore.js';
|
||||
import { syncEngine } from '../services/syncEngine.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get cache status
|
||||
router.get('/status', (req: Request, res: Response) => {
|
||||
try {
|
||||
const cacheStats = cacheStore.getStats();
|
||||
const syncStatus = syncEngine.getStatus();
|
||||
|
||||
res.json({
|
||||
cache: cacheStats,
|
||||
sync: syncStatus,
|
||||
supportedTypes: Object.keys(OBJECT_TYPES),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cache status', error);
|
||||
res.status(500).json({ error: 'Failed to get cache status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger full sync
|
||||
router.post('/sync', async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Manual full sync triggered');
|
||||
|
||||
// Don't wait for completion - return immediately
|
||||
syncEngine.fullSync().catch(err => {
|
||||
logger.error('Full sync failed', err);
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'started',
|
||||
message: 'Full sync started in background',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger full sync', error);
|
||||
res.status(500).json({ error: 'Failed to trigger sync' });
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger sync for a specific object type
|
||||
router.post('/sync/:objectType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { objectType } = req.params;
|
||||
|
||||
// Validate object type
|
||||
if (!OBJECT_TYPES[objectType]) {
|
||||
res.status(400).json({
|
||||
error: `Unknown object type: ${objectType}`,
|
||||
supportedTypes: Object.keys(OBJECT_TYPES),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Manual sync triggered for ${objectType}`);
|
||||
|
||||
const result = await syncEngine.syncType(objectType as CMDBObjectTypeName);
|
||||
|
||||
res.json({
|
||||
status: 'completed',
|
||||
objectType,
|
||||
stats: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to sync object type';
|
||||
logger.error(`Failed to sync object type ${req.params.objectType}`, error);
|
||||
|
||||
// Return 409 (Conflict) if sync is already in progress, otherwise 500
|
||||
const statusCode = errorMessage.includes('already in progress') ? 409 : 500;
|
||||
res.status(statusCode).json({
|
||||
error: errorMessage,
|
||||
objectType: req.params.objectType,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear cache for a specific type
|
||||
router.delete('/clear/:objectType', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { objectType } = req.params;
|
||||
|
||||
if (!OBJECT_TYPES[objectType]) {
|
||||
res.status(400).json({
|
||||
error: `Unknown object type: ${objectType}`,
|
||||
supportedTypes: Object.keys(OBJECT_TYPES),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Clearing cache for ${objectType}`);
|
||||
|
||||
const deleted = cacheStore.clearObjectType(objectType as CMDBObjectTypeName);
|
||||
|
||||
res.json({
|
||||
status: 'cleared',
|
||||
objectType,
|
||||
deletedCount: deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to clear cache for ${req.params.objectType}`, error);
|
||||
res.status(500).json({ error: 'Failed to clear cache' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear entire cache
|
||||
router.delete('/clear', (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Clearing entire cache');
|
||||
|
||||
cacheStore.clearAll();
|
||||
|
||||
res.json({
|
||||
status: 'cleared',
|
||||
message: 'Entire cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear cache', error);
|
||||
res.status(500).json({ error: 'Failed to clear cache' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { join, dirname } from 'path';
|
||||
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';
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Path to the configuration files
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
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 type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Simple in-memory cache for dashboard stats
|
||||
interface CachedStats {
|
||||
data: any;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let statsCache: CachedStats | null = null;
|
||||
const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache (longer since jiraAssets also caches)
|
||||
const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', async (req: Request, res: Response) => {
|
||||
@@ -24,7 +27,8 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
const now = Date.now();
|
||||
if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) {
|
||||
logger.debug('Returning cached dashboard stats');
|
||||
return res.json(statsCache.data);
|
||||
res.json(statsCache.data);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Dashboard: Fetching fresh stats...');
|
||||
@@ -34,9 +38,28 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
const stats = await dataService.getStats(includeDistributions);
|
||||
const dbStats = databaseService.getStats();
|
||||
|
||||
// Get cache status
|
||||
const cacheStatus = dataService.getCacheStatus();
|
||||
const syncStatus = syncEngine.getStatus();
|
||||
|
||||
const responseData = {
|
||||
...stats,
|
||||
classificationStats: dbStats,
|
||||
cache: {
|
||||
lastFullSync: cacheStatus.lastFullSync,
|
||||
lastIncrementalSync: cacheStatus.lastIncrementalSync,
|
||||
objectCount: cacheStatus.totalObjects,
|
||||
objectsByType: cacheStatus.objectsByType,
|
||||
totalRelations: cacheStatus.totalRelations,
|
||||
isWarm: cacheStatus.isWarm,
|
||||
dbSizeBytes: cacheStatus.dbSizeBytes,
|
||||
syncStatus: {
|
||||
isRunning: syncStatus.isRunning,
|
||||
isSyncing: syncStatus.isSyncing,
|
||||
nextIncrementalSync: syncStatus.nextIncrementalSync,
|
||||
incrementalInterval: syncStatus.incrementalInterval,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Update cache
|
||||
@@ -53,11 +76,12 @@ router.get('/stats', async (req: Request, res: Response) => {
|
||||
// Return cached data if available (even if expired)
|
||||
if (statsCache) {
|
||||
logger.info('Dashboard: Returning stale cached data due to error');
|
||||
return res.json({
|
||||
...statsCache.data,
|
||||
res.json({
|
||||
...statsCache.data as object,
|
||||
stale: true,
|
||||
error: 'Using cached data due to API timeout',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to get dashboard stats' });
|
||||
@@ -76,4 +100,101 @@ router.get('/recent', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get applications with governance model validation issues
|
||||
router.get('/governance-analysis', async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Governance Analysis: Fetching all applications for validation...');
|
||||
|
||||
// Use batched fetching to avoid timeouts
|
||||
const pageSize = 50; // Smaller batch size for reliability
|
||||
// Include all statuses so they can be filtered client-side (including Closed)
|
||||
const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined', 'Closed'];
|
||||
|
||||
let allApplications: Array<{ id: string; key: string; name: string; status: ApplicationStatus | null; governanceModel?: ReferenceValue | null; applicationType?: ReferenceValue | null }> = [];
|
||||
let currentPage = 1;
|
||||
let totalCount = 0;
|
||||
let hasMore = true;
|
||||
|
||||
// Fetch applications in batches
|
||||
while (hasMore) {
|
||||
try {
|
||||
const searchResult = await dataService.searchApplications(
|
||||
{ statuses },
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
|
||||
if (currentPage === 1) {
|
||||
totalCount = searchResult.totalCount;
|
||||
logger.info(`Governance Analysis: 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('Governance Analysis: Reached page limit, stopping fetch');
|
||||
break;
|
||||
}
|
||||
} catch (fetchError) {
|
||||
logger.error(`Governance Analysis: Error fetching page ${currentPage}`, fetchError);
|
||||
// Continue with what we have if a single batch fails
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Governance Analysis: Fetched ${allApplications.length} applications, validating...`);
|
||||
|
||||
const applicationsWithIssues: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
status: string | null;
|
||||
governanceModel: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
applicationType: string | null;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}> = [];
|
||||
|
||||
// Process each application
|
||||
for (const app of allApplications) {
|
||||
// Get full application details for validation
|
||||
const fullApp = await dataService.getApplicationById(app.id);
|
||||
if (!fullApp) continue;
|
||||
|
||||
const validation = validateApplicationConfiguration(fullApp as ApplicationDetails);
|
||||
|
||||
// Only include applications with ERRORS (red warnings)
|
||||
// Applications with only warnings (yellow) are excluded
|
||||
if (validation.errors.length > 0) {
|
||||
applicationsWithIssues.push({
|
||||
id: app.id,
|
||||
key: app.key,
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
governanceModel: app.governanceModel?.name || null,
|
||||
businessImpactAnalyse: fullApp.businessImpactAnalyse?.name || null,
|
||||
applicationType: app.applicationType?.name || null,
|
||||
warnings: validation.warnings,
|
||||
errors: validation.errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Governance Analysis: Found ${applicationsWithIssues.length} applications with validation issues`);
|
||||
|
||||
res.json({
|
||||
totalApplications: totalCount,
|
||||
applicationsWithIssues: applicationsWithIssues.length,
|
||||
applications: applicationsWithIssues,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get governance analysis', error);
|
||||
res.status(500).json({ error: 'Failed to get governance analysis' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
176
backend/src/routes/objects.ts
Normal file
176
backend/src/routes/objects.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Generic object routes
|
||||
*
|
||||
* Provides schema-driven access to all CMDB object types.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get list of supported object types
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({
|
||||
typeName,
|
||||
jiraTypeId: def.jiraTypeId,
|
||||
name: def.name,
|
||||
syncPriority: def.syncPriority,
|
||||
attributeCount: def.attributes.length,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
types,
|
||||
totalTypes: types.length,
|
||||
});
|
||||
});
|
||||
|
||||
// Get all objects of a type
|
||||
router.get('/:type', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 1000;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
// Validate type
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
res.status(400).json({
|
||||
error: `Unknown object type: ${type}`,
|
||||
supportedTypes: Object.keys(OBJECT_TYPES),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const objects = await cmdbService.getObjects(type as CMDBObjectTypeName, {
|
||||
limit,
|
||||
offset,
|
||||
searchTerm: search,
|
||||
});
|
||||
|
||||
const count = cmdbService.countObjects(type as CMDBObjectTypeName);
|
||||
|
||||
res.json({
|
||||
objectType: type,
|
||||
objects,
|
||||
count: objects.length,
|
||||
totalCount: count,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get objects of type ${req.params.type}`, error);
|
||||
res.status(500).json({ error: 'Failed to get objects' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific object by ID
|
||||
router.get('/:type/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id } = req.params;
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
// Validate type
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
res.status(400).json({
|
||||
error: `Unknown object type: ${type}`,
|
||||
supportedTypes: Object.keys(OBJECT_TYPES),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const object = await cmdbService.getObject(type as CMDBObjectTypeName, id, {
|
||||
forceRefresh,
|
||||
});
|
||||
|
||||
if (!object) {
|
||||
res.status(404).json({ error: 'Object not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(object);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get object ${req.params.type}/${req.params.id}`, error);
|
||||
res.status(500).json({ error: 'Failed to get object' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get related objects
|
||||
router.get('/:type/:id/related/:relationType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id, relationType } = req.params;
|
||||
const attribute = req.query.attribute as string | undefined;
|
||||
|
||||
// Validate types
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
res.status(400).json({ error: `Unknown source type: ${type}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OBJECT_TYPES[relationType]) {
|
||||
res.status(400).json({ error: `Unknown relation type: ${relationType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const relatedObjects = await cmdbService.getRelatedObjects<CMDBObject>(
|
||||
id,
|
||||
attribute || '',
|
||||
relationType as CMDBObjectTypeName
|
||||
);
|
||||
|
||||
res.json({
|
||||
sourceId: id,
|
||||
sourceType: type,
|
||||
relationType,
|
||||
attribute: attribute || null,
|
||||
objects: relatedObjects,
|
||||
count: relatedObjects.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get related objects`, error);
|
||||
res.status(500).json({ error: 'Failed to get related objects' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get objects referencing this object (inbound references)
|
||||
router.get('/:type/:id/referenced-by/:sourceType', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id, sourceType } = req.params;
|
||||
const attribute = req.query.attribute as string | undefined;
|
||||
|
||||
// Validate types
|
||||
if (!OBJECT_TYPES[type]) {
|
||||
res.status(400).json({ error: `Unknown target type: ${type}` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!OBJECT_TYPES[sourceType]) {
|
||||
res.status(400).json({ error: `Unknown source type: ${sourceType}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const referencingObjects = await cmdbService.getReferencingObjects<CMDBObject>(
|
||||
id,
|
||||
sourceType as CMDBObjectTypeName,
|
||||
attribute
|
||||
);
|
||||
|
||||
res.json({
|
||||
targetId: id,
|
||||
targetType: type,
|
||||
sourceType,
|
||||
attribute: attribute || null,
|
||||
objects: referencingObjects,
|
||||
count: referencingObjects.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get referencing objects`, error);
|
||||
res.status(500).json({ error: 'Failed to get referencing objects' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -15,12 +15,14 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
organisations,
|
||||
hostingTypes,
|
||||
applicationFunctions,
|
||||
applicationClusters,
|
||||
applicationSubteams,
|
||||
applicationTeams,
|
||||
applicationTypes,
|
||||
businessImportance,
|
||||
businessImpactAnalyses,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
subteamToTeamMapping,
|
||||
] = await Promise.all([
|
||||
dataService.getDynamicsFactors(),
|
||||
dataService.getComplexityFactors(),
|
||||
@@ -29,12 +31,14 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
dataService.getOrganisations(),
|
||||
dataService.getHostingTypes(),
|
||||
dataService.getApplicationFunctions(),
|
||||
dataService.getApplicationClusters(),
|
||||
dataService.getApplicationSubteams(),
|
||||
dataService.getApplicationTeams(),
|
||||
dataService.getApplicationTypes(),
|
||||
dataService.getBusinessImportance(),
|
||||
dataService.getBusinessImpactAnalyses(),
|
||||
dataService.getApplicationManagementHosting(),
|
||||
dataService.getApplicationManagementTAM(),
|
||||
dataService.getSubteamToTeamMapping(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
@@ -45,12 +49,14 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
organisations,
|
||||
hostingTypes,
|
||||
applicationFunctions,
|
||||
applicationClusters,
|
||||
applicationSubteams,
|
||||
applicationTeams,
|
||||
applicationTypes,
|
||||
businessImportance,
|
||||
businessImpactAnalyses,
|
||||
applicationManagementHosting,
|
||||
applicationManagementTAM,
|
||||
subteamToTeamMapping,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get reference data', error);
|
||||
@@ -135,14 +141,25 @@ router.get('/application-functions', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get application clusters (from Jira Assets)
|
||||
router.get('/application-clusters', async (req: Request, res: Response) => {
|
||||
// Get application subteams (from Jira Assets)
|
||||
router.get('/application-subteams', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const clusters = await dataService.getApplicationClusters();
|
||||
res.json(clusters);
|
||||
const subteams = await dataService.getApplicationSubteams();
|
||||
res.json(subteams);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application clusters', error);
|
||||
res.status(500).json({ error: 'Failed to get application clusters' });
|
||||
logger.error('Failed to get application subteams', error);
|
||||
res.status(500).json({ error: 'Failed to get application subteams' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get application teams (from Jira Assets)
|
||||
router.get('/application-teams', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const teams = await dataService.getApplicationTeams();
|
||||
res.json(teams);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get application teams', error);
|
||||
res.status(500).json({ error: 'Failed to get application teams' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
151
backend/src/routes/schema.ts
Normal file
151
backend/src/routes/schema.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Extended types for API response
|
||||
interface ObjectTypeWithLinks extends ObjectTypeDefinition {
|
||||
incomingLinks: Array<{
|
||||
fromType: string;
|
||||
fromTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}>;
|
||||
outgoingLinks: Array<{
|
||||
toType: string;
|
||||
toTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SchemaResponse {
|
||||
metadata: {
|
||||
generatedAt: string;
|
||||
objectTypeCount: number;
|
||||
totalAttributes: number;
|
||||
};
|
||||
objectTypes: Record<string, ObjectTypeWithLinks>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/schema
|
||||
* Returns the complete Jira Assets schema with object types, attributes, and links
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
// Build links between object types
|
||||
const objectTypesWithLinks: Record<string, ObjectTypeWithLinks> = {};
|
||||
|
||||
// First pass: convert all object types
|
||||
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
|
||||
objectTypesWithLinks[typeName] = {
|
||||
...typeDef,
|
||||
incomingLinks: [],
|
||||
outgoingLinks: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Second pass: build link relationships
|
||||
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
|
||||
for (const attr of typeDef.attributes) {
|
||||
if (attr.type === 'reference' && attr.referenceTypeName) {
|
||||
// Add outgoing link from this type
|
||||
objectTypesWithLinks[typeName].outgoingLinks.push({
|
||||
toType: attr.referenceTypeName,
|
||||
toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName,
|
||||
attributeName: attr.name,
|
||||
isMultiple: attr.isMultiple,
|
||||
});
|
||||
|
||||
// Add incoming link to the referenced type
|
||||
if (objectTypesWithLinks[attr.referenceTypeName]) {
|
||||
objectTypesWithLinks[attr.referenceTypeName].incomingLinks.push({
|
||||
fromType: typeName,
|
||||
fromTypeName: typeDef.name,
|
||||
attributeName: attr.name,
|
||||
isMultiple: attr.isMultiple,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: SchemaResponse = {
|
||||
metadata: {
|
||||
generatedAt: SCHEMA_GENERATED_AT,
|
||||
objectTypeCount: SCHEMA_OBJECT_TYPE_COUNT,
|
||||
totalAttributes: SCHEMA_TOTAL_ATTRIBUTES,
|
||||
},
|
||||
objectTypes: objectTypesWithLinks,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get schema:', error);
|
||||
res.status(500).json({ error: 'Failed to get schema' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/schema/object-type/:typeName
|
||||
* Returns details for a specific object type
|
||||
*/
|
||||
router.get('/object-type/:typeName', (req, res) => {
|
||||
const { typeName } = req.params;
|
||||
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) {
|
||||
return res.status(404).json({ error: `Object type '${typeName}' not found` });
|
||||
}
|
||||
|
||||
// Build links for this specific type
|
||||
const incomingLinks: Array<{
|
||||
fromType: string;
|
||||
fromTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}> = [];
|
||||
|
||||
const outgoingLinks: Array<{
|
||||
toType: string;
|
||||
toTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}> = [];
|
||||
|
||||
// Outgoing links from this type
|
||||
for (const attr of typeDef.attributes) {
|
||||
if (attr.type === 'reference' && attr.referenceTypeName) {
|
||||
outgoingLinks.push({
|
||||
toType: attr.referenceTypeName,
|
||||
toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName,
|
||||
attributeName: attr.name,
|
||||
isMultiple: attr.isMultiple,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming links from other types
|
||||
for (const [otherTypeName, otherTypeDef] of Object.entries(OBJECT_TYPES)) {
|
||||
for (const attr of otherTypeDef.attributes) {
|
||||
if (attr.type === 'reference' && attr.referenceTypeName === typeName) {
|
||||
incomingLinks.push({
|
||||
fromType: otherTypeName,
|
||||
fromTypeName: otherTypeDef.name,
|
||||
attributeName: attr.name,
|
||||
isMultiple: attr.isMultiple,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
...typeDef,
|
||||
incomingLinks,
|
||||
outgoingLinks,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
74
backend/src/routes/search.ts
Normal file
74
backend/src/routes/search.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { cmdbService } from '../services/cmdbService.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
import { config } from '../config/env.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// CMDB free-text search endpoint (from cache)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const query = req.query.query as string;
|
||||
const limit = parseInt(req.query.limit as string, 10) || 100;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
res.status(400).json({ error: 'Query parameter is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`CMDB search request: query="${query}", limit=${limit}`);
|
||||
|
||||
// Search all types in cache
|
||||
const results = await cmdbService.searchAllTypes(query.trim(), { limit });
|
||||
|
||||
// Group results by object type
|
||||
const objectTypeMap = new Map<string, { id: number; name: string; iconUrl: string }>();
|
||||
const formattedResults = results.map(obj => {
|
||||
const typeName = obj._objectType || 'Unknown';
|
||||
|
||||
// Track unique object types
|
||||
if (!objectTypeMap.has(typeName)) {
|
||||
objectTypeMap.set(typeName, {
|
||||
id: objectTypeMap.size + 1,
|
||||
name: typeName,
|
||||
iconUrl: '', // Can be enhanced to include actual icons
|
||||
});
|
||||
}
|
||||
|
||||
const objectType = objectTypeMap.get(typeName)!;
|
||||
|
||||
return {
|
||||
id: parseInt(obj.id, 10) || 0,
|
||||
key: obj.objectKey,
|
||||
label: obj.label,
|
||||
objectTypeId: objectType.id,
|
||||
avatarUrl: '',
|
||||
attributes: [], // Can be enhanced to include attributes
|
||||
};
|
||||
});
|
||||
|
||||
// Build response matching CMDBSearchResponse interface
|
||||
const response = {
|
||||
metadata: {
|
||||
count: formattedResults.length,
|
||||
offset: 0,
|
||||
limit: limit,
|
||||
total: formattedResults.length,
|
||||
criteria: {
|
||||
query: query,
|
||||
type: 'global',
|
||||
schema: parseInt(config.jiraSchemaId, 10) || 0,
|
||||
},
|
||||
},
|
||||
objectTypes: Array.from(objectTypeMap.values()),
|
||||
results: formattedResults,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('CMDB search failed', error);
|
||||
res.status(500).json({ error: 'Failed to search CMDB' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -268,14 +268,21 @@ class AuthService {
|
||||
return existed;
|
||||
}
|
||||
|
||||
// Check if OAuth is enabled
|
||||
// Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||
isOAuthEnabled(): boolean {
|
||||
return config.jiraOAuthEnabled && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||
return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||
}
|
||||
|
||||
// Check if using service account (PAT) fallback
|
||||
// Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
|
||||
isUsingServiceAccount(): boolean {
|
||||
return !this.isOAuthEnabled() && !!config.jiraPat;
|
||||
return config.jiraAuthMethod === 'pat' && !!config.jiraPat;
|
||||
}
|
||||
|
||||
// Get the configured authentication method
|
||||
getAuthMethod(): 'pat' | 'oauth' | 'none' {
|
||||
if (this.isOAuthEnabled()) return 'oauth';
|
||||
if (this.isUsingServiceAccount()) return 'pat';
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
660
backend/src/services/cacheStore.ts
Normal file
660
backend/src/services/cacheStore.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* CacheStore - SQLite cache operations for CMDB objects
|
||||
*
|
||||
* Provides fast local storage for CMDB data synced from Jira Assets.
|
||||
* Uses the generated schema for type-safe operations.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
// Get current directory for ESM
|
||||
const currentFileUrl = new URL(import.meta.url);
|
||||
const __dirname = dirname(currentFileUrl.pathname);
|
||||
|
||||
const CACHE_DB_PATH = join(__dirname, '../../data/cmdb-cache.db');
|
||||
|
||||
export interface CacheStats {
|
||||
totalObjects: number;
|
||||
objectsByType: Record<string, number>;
|
||||
totalRelations: number;
|
||||
lastFullSync: string | null;
|
||||
lastIncrementalSync: string | null;
|
||||
isWarm: boolean;
|
||||
dbSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: string;
|
||||
orderDir?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
class CacheStore {
|
||||
private db: Database.Database;
|
||||
private initialized: 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();
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
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
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Object CRUD Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get a single object by ID
|
||||
*/
|
||||
getObject<T extends CMDBObject>(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;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.data) as T;
|
||||
} catch (error) {
|
||||
logger.error(`CacheStore: Failed to parse object ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single object by object key (e.g., "ICMT-123")
|
||||
*/
|
||||
getObjectByKey<T extends CMDBObject>(typeName: CMDBObjectTypeName, objectKey: string): T | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT data FROM cached_objects
|
||||
WHERE object_key = ? AND object_type = ?
|
||||
`);
|
||||
const row = stmt.get(objectKey, typeName) as { data: string } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(row.data) as T;
|
||||
} catch (error) {
|
||||
logger.error(`CacheStore: Failed to parse object ${objectKey}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all objects of a specific type
|
||||
*/
|
||||
getObjects<T extends CMDBObject>(
|
||||
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';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count objects of a specific type
|
||||
*/
|
||||
countObjects(typeName: CMDBObjectTypeName): number {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM cached_objects
|
||||
WHERE object_type = ?
|
||||
`);
|
||||
const row = stmt.get(typeName) as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search objects by label (case-insensitive)
|
||||
*/
|
||||
searchByLabel<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
searchTerm: string,
|
||||
options?: QueryOptions
|
||||
): T[] {
|
||||
const limit = options?.limit || 100;
|
||||
const offset = options?.offset || 0;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT data FROM cached_objects
|
||||
WHERE object_type = ? AND label LIKE ?
|
||||
ORDER BY label ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const rows = stmt.all(typeName, `%${searchTerm}%`, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across all object types
|
||||
*/
|
||||
searchAllTypes(searchTerm: string, options?: QueryOptions): CMDBObject[] {
|
||||
const limit = options?.limit || 100;
|
||||
const offset = options?.offset || 0;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT data FROM cached_objects
|
||||
WHERE label LIKE ? OR object_key LIKE ?
|
||||
ORDER BY object_type, label ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`);
|
||||
|
||||
const pattern = `%${searchTerm}%`;
|
||||
const rows = stmt.all(pattern, pattern, limit, offset) as { data: string }[];
|
||||
|
||||
return rows.map(row => {
|
||||
try {
|
||||
return JSON.parse(row.data) as CMDBObject;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter((obj): obj is CMDBObject => obj !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single object
|
||||
*/
|
||||
upsertObject<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): void {
|
||||
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
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
object.id,
|
||||
object.objectKey,
|
||||
typeName,
|
||||
object.label,
|
||||
JSON.stringify(object),
|
||||
object._jiraUpdatedAt || null,
|
||||
object._jiraCreatedAt || null,
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upsert objects (much faster for bulk operations)
|
||||
*/
|
||||
batchUpsertObjects<T extends CMDBObject>(typeName: CMDBObjectTypeName, objects: T[]): void {
|
||||
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(
|
||||
obj.id,
|
||||
obj.objectKey,
|
||||
typeName,
|
||||
obj.label,
|
||||
JSON.stringify(obj),
|
||||
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(`
|
||||
DELETE FROM cached_objects
|
||||
WHERE id = ? AND object_type = ?
|
||||
`);
|
||||
const result = stmt.run(id, typeName);
|
||||
|
||||
// Also delete related relations
|
||||
this.deleteRelationsForObject(id);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all objects of a specific type
|
||||
*/
|
||||
clearObjectType(typeName: CMDBObjectTypeName): number {
|
||||
// First get all IDs to delete relations
|
||||
const idsStmt = this.db.prepare(`
|
||||
SELECT id FROM cached_objects WHERE object_type = ?
|
||||
`);
|
||||
const ids = idsStmt.all(typeName) as { id: string }[];
|
||||
|
||||
// Delete relations
|
||||
for (const { id } of ids) {
|
||||
this.deleteRelationsForObject(id);
|
||||
}
|
||||
|
||||
// Delete objects
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM cached_objects WHERE object_type = ?
|
||||
`);
|
||||
const result = stmt.run(typeName);
|
||||
|
||||
logger.info(`CacheStore: Cleared ${result.changes} ${typeName} objects`);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear entire cache
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.db.exec('DELETE FROM cached_objects');
|
||||
this.db.exec('DELETE FROM object_relations');
|
||||
logger.info('CacheStore: Cleared all cached data');
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Relation Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Store a relation between two objects
|
||||
*/
|
||||
upsertRelation(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
attributeName: string,
|
||||
sourceType: string,
|
||||
targetType: string
|
||||
): void {
|
||||
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
|
||||
`);
|
||||
|
||||
stmt.run(sourceId, targetId, attributeName, sourceType, targetType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upsert relations
|
||||
*/
|
||||
batchUpsertRelations(relations: Array<{
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
attributeName: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
}>): void {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
batchInsert(relations);
|
||||
logger.debug(`CacheStore: Batch upserted ${relations.length} relations`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related objects (outbound references from an object)
|
||||
*/
|
||||
getRelatedObjects<T extends CMDBObject>(
|
||||
sourceId: string,
|
||||
targetTypeName: CMDBObjectTypeName,
|
||||
attributeName?: string
|
||||
): T[] {
|
||||
let query = `
|
||||
SELECT co.data FROM cached_objects co
|
||||
JOIN object_relations rel ON co.id = rel.target_id
|
||||
WHERE rel.source_id = ? AND co.object_type = ?
|
||||
`;
|
||||
const params: (string | undefined)[] = [sourceId, targetTypeName];
|
||||
|
||||
if (attributeName) {
|
||||
query += ' AND rel.attribute_name = ?';
|
||||
params.push(attributeName);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get objects that reference the given object (inbound references)
|
||||
*/
|
||||
getReferencingObjects<T extends CMDBObject>(
|
||||
targetId: string,
|
||||
sourceTypeName: CMDBObjectTypeName,
|
||||
attributeName?: string
|
||||
): T[] {
|
||||
let query = `
|
||||
SELECT co.data FROM cached_objects co
|
||||
JOIN object_relations rel ON co.id = rel.source_id
|
||||
WHERE rel.target_id = ? AND co.object_type = ?
|
||||
`;
|
||||
const params: (string | undefined)[] = [targetId, sourceTypeName];
|
||||
|
||||
if (attributeName) {
|
||||
query += ' AND rel.attribute_name = ?';
|
||||
params.push(attributeName);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(query);
|
||||
const rows = stmt.all(...params) 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all relations for an object
|
||||
*/
|
||||
deleteRelationsForObject(objectId: string): void {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM object_relations
|
||||
WHERE source_id = ? OR target_id = ?
|
||||
`);
|
||||
stmt.run(objectId, objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and store relations from an object based on its type schema
|
||||
*/
|
||||
extractAndStoreRelations<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): void {
|
||||
const refAttributes = getReferenceAttributes(typeName);
|
||||
const relations: Array<{
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
attributeName: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
}> = [];
|
||||
|
||||
for (const attrDef of refAttributes) {
|
||||
const value = (object as unknown as Record<string, unknown>)[attrDef.fieldName];
|
||||
|
||||
if (!value) continue;
|
||||
|
||||
const targetType = attrDef.referenceTypeName || 'Unknown';
|
||||
|
||||
if (attrDef.isMultiple && Array.isArray(value)) {
|
||||
for (const ref of value as ObjectReference[]) {
|
||||
if (ref?.objectId) {
|
||||
relations.push({
|
||||
sourceId: object.id,
|
||||
targetId: ref.objectId,
|
||||
attributeName: attrDef.name,
|
||||
sourceType: typeName,
|
||||
targetType,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (!attrDef.isMultiple) {
|
||||
const ref = value as ObjectReference;
|
||||
if (ref?.objectId) {
|
||||
relations.push({
|
||||
sourceId: object.id,
|
||||
targetId: ref.objectId,
|
||||
attributeName: attrDef.name,
|
||||
sourceType: typeName,
|
||||
targetType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (relations.length > 0) {
|
||||
this.batchUpsertRelations(relations);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Sync Metadata Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get sync metadata value
|
||||
*/
|
||||
getSyncMetadata(key: string): string | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT value FROM sync_metadata WHERE key = ?
|
||||
`);
|
||||
const row = stmt.get(key) as { value: string } | undefined;
|
||||
return row?.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sync metadata value
|
||||
*/
|
||||
setSyncMetadata(key: string, value: string): void {
|
||||
const stmt = this.db.prepare(`
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sync metadata
|
||||
*/
|
||||
deleteSyncMetadata(key: string): void {
|
||||
const stmt = this.db.prepare(`
|
||||
DELETE FROM sync_metadata WHERE key = ?
|
||||
`);
|
||||
stmt.run(key);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Statistics
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats(): CacheStats {
|
||||
// Count by type
|
||||
const typeCountStmt = this.db.prepare(`
|
||||
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<string, number> = {};
|
||||
let totalObjects = 0;
|
||||
for (const { object_type, count } of typeCounts) {
|
||||
objectsByType[object_type] = count;
|
||||
totalObjects += count;
|
||||
}
|
||||
|
||||
// Count relations
|
||||
const relCountStmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count FROM object_relations
|
||||
`);
|
||||
const relCount = (relCountStmt.get() as { count: number }).count;
|
||||
|
||||
// Get sync metadata
|
||||
const lastFullSync = this.getSyncMetadata('lastFullSync');
|
||||
const lastIncrementalSync = this.getSyncMetadata('lastIncrementalSync');
|
||||
|
||||
// Check if cache is warm (has Application Components)
|
||||
const isWarm = (objectsByType['ApplicationComponent'] || 0) > 0;
|
||||
|
||||
// Get database file size
|
||||
let dbSizeBytes = 0;
|
||||
try {
|
||||
const stats = fs.statSync(CACHE_DB_PATH);
|
||||
dbSizeBytes = stats.size;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return {
|
||||
totalObjects,
|
||||
objectsByType,
|
||||
totalRelations: relCount,
|
||||
lastFullSync,
|
||||
lastIncrementalSync,
|
||||
isWarm,
|
||||
dbSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache is warm (has data)
|
||||
*/
|
||||
isWarm(): boolean {
|
||||
const count = this.countObjects('ApplicationComponent');
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const cacheStore = new CacheStore();
|
||||
|
||||
@@ -372,7 +372,7 @@ async function formatApplicationFunctionsForPrompt(
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
// Format reference objects for prompt (Application Type, Dynamics Factor, etc.)
|
||||
// Format reference objects for prompt (Application Type, etc.)
|
||||
function formatReferenceObjectsForPrompt(
|
||||
objects: ReferenceValue[],
|
||||
useSummary: boolean = false
|
||||
@@ -391,10 +391,30 @@ function formatReferenceObjectsForPrompt(
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Format factors (Dynamics/Complexity) with description for AI prompt
|
||||
function formatFactorsForPrompt(objects: ReferenceValue[]): string {
|
||||
if (objects.length === 0) {
|
||||
return 'Geen factoren beschikbaar.';
|
||||
}
|
||||
|
||||
return objects
|
||||
.map((obj) => {
|
||||
const parts: string[] = [` - ${obj.key}: ${obj.name}`];
|
||||
if (obj.factor !== undefined) {
|
||||
parts[0] += ` (factor: ${obj.factor})`;
|
||||
}
|
||||
if (obj.description) {
|
||||
parts.push(` ${obj.description}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Format reference objects with emphasis on exact name (for fields where AI must use exact name)
|
||||
function formatReferenceObjectsWithExactNames(
|
||||
objects: ReferenceValue[],
|
||||
useSummary: boolean = false
|
||||
useDescription: boolean = false
|
||||
): string {
|
||||
if (objects.length === 0) {
|
||||
return 'Geen objecten beschikbaar.';
|
||||
@@ -402,9 +422,7 @@ function formatReferenceObjectsWithExactNames(
|
||||
|
||||
return objects
|
||||
.map((obj) => {
|
||||
const displayText = useSummary && obj.summary
|
||||
? obj.summary
|
||||
: obj.description || '';
|
||||
const displayText = useDescription && obj.description ? obj.description : '';
|
||||
// Emphasize the exact name that should be used
|
||||
return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`;
|
||||
})
|
||||
@@ -892,8 +910,8 @@ class AIService {
|
||||
applicationFunctionCategories
|
||||
);
|
||||
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
||||
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true);
|
||||
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true);
|
||||
const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
|
||||
const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
|
||||
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
||||
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
||||
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);
|
||||
@@ -1133,8 +1151,8 @@ class AIService {
|
||||
applicationFunctionCategories
|
||||
);
|
||||
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
||||
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true);
|
||||
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true);
|
||||
const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
|
||||
const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
|
||||
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
||||
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
||||
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);
|
||||
|
||||
445
backend/src/services/cmdbService.ts
Normal file
445
backend/src/services/cmdbService.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* CMDBService - Universal schema-driven CMDB service
|
||||
*
|
||||
* Provides a unified interface for all CMDB operations:
|
||||
* - Reads from cache for fast access
|
||||
* - Write-through to Jira with conflict detection
|
||||
* - Schema-driven parsing and updates
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { cacheStore, type CacheStats } from './cacheStore.js';
|
||||
import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js';
|
||||
import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js';
|
||||
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface GetObjectOptions {
|
||||
/** Force refresh from Jira (bypasses cache) */
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
success: boolean;
|
||||
data?: CMDBObject;
|
||||
conflict?: ConflictCheckResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service Implementation
|
||||
// =============================================================================
|
||||
|
||||
class CMDBService {
|
||||
// ==========================================================================
|
||||
// Read Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get a single object by ID
|
||||
* By default reads from cache; use forceRefresh to fetch from Jira
|
||||
*/
|
||||
async getObject<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
id: string,
|
||||
options?: GetObjectOptions
|
||||
): Promise<T | null> {
|
||||
// Force refresh: always fetch from Jira
|
||||
if (options?.forceRefresh) {
|
||||
return this.fetchAndCacheObject<T>(typeName, id);
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = cacheStore.getObject<T>(typeName, id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss: fetch from Jira
|
||||
return this.fetchAndCacheObject<T>(typeName, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single object by object key (e.g., "ICMT-123")
|
||||
*/
|
||||
async getObjectByKey<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
objectKey: string,
|
||||
options?: GetObjectOptions
|
||||
): Promise<T | null> {
|
||||
// Force refresh: search Jira by key
|
||||
if (options?.forceRefresh) {
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) return null;
|
||||
|
||||
const iql = `objectType = "${typeDef.name}" AND Key = "${objectKey}"`;
|
||||
const result = await jiraAssetsClient.searchObjects(iql, 1, 1);
|
||||
|
||||
if (result.objects.length === 0) return null;
|
||||
|
||||
const parsed = jiraAssetsClient.parseObject<T>(result.objects[0]);
|
||||
if (parsed) {
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
cacheStore.extractAndStoreRelations(typeName, parsed);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = cacheStore.getObjectByKey<T>(typeName, objectKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss: search Jira
|
||||
return this.getObjectByKey(typeName, objectKey, { forceRefresh: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single object from Jira and update cache
|
||||
* If the object was deleted from Jira (404), it will be removed from the local cache
|
||||
*/
|
||||
private async fetchAndCacheObject<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
id: string
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const jiraObj = await jiraAssetsClient.getObject(id);
|
||||
if (!jiraObj) return null;
|
||||
|
||||
const parsed = jiraAssetsClient.parseObject<T>(jiraObj);
|
||||
if (parsed) {
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
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);
|
||||
if (deleted) {
|
||||
logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all objects of a type from cache
|
||||
*/
|
||||
async getObjects<T extends CMDBObject>(
|
||||
typeName: CMDBObjectTypeName,
|
||||
options?: SearchOptions
|
||||
): Promise<T[]> {
|
||||
if (options?.searchTerm) {
|
||||
return cacheStore.searchByLabel<T>(typeName, options.searchTerm, {
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
});
|
||||
}
|
||||
|
||||
return cacheStore.getObjects<T>(typeName, {
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count objects of a type in cache
|
||||
*/
|
||||
countObjects(typeName: CMDBObjectTypeName): number {
|
||||
return cacheStore.countObjects(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across all object types
|
||||
*/
|
||||
async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise<CMDBObject[]> {
|
||||
return cacheStore.searchAllTypes(searchTerm, { limit: options?.limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related objects (outbound references)
|
||||
*/
|
||||
async getRelatedObjects<T extends CMDBObject>(
|
||||
sourceId: string,
|
||||
attributeName: string,
|
||||
targetTypeName: CMDBObjectTypeName
|
||||
): Promise<T[]> {
|
||||
return cacheStore.getRelatedObjects<T>(sourceId, targetTypeName, attributeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get objects that reference the given object (inbound references)
|
||||
*/
|
||||
async getReferencingObjects<T extends CMDBObject>(
|
||||
targetId: string,
|
||||
sourceTypeName: CMDBObjectTypeName,
|
||||
attributeName?: string
|
||||
): Promise<T[]> {
|
||||
return cacheStore.getReferencingObjects<T>(targetId, sourceTypeName, attributeName);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Write Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Update an object with conflict detection
|
||||
*
|
||||
* @param typeName - The object type
|
||||
* @param id - The object ID
|
||||
* @param updates - Field updates (only changed fields)
|
||||
* @param originalUpdatedAt - The _jiraUpdatedAt from when the object was loaded for editing
|
||||
*/
|
||||
async updateObject(
|
||||
typeName: CMDBObjectTypeName,
|
||||
id: string,
|
||||
updates: Record<string, unknown>,
|
||||
originalUpdatedAt: string
|
||||
): Promise<UpdateResult> {
|
||||
try {
|
||||
// 1. Check for conflicts
|
||||
const conflictResult = await conflictResolver.checkConflict(
|
||||
typeName,
|
||||
id,
|
||||
originalUpdatedAt,
|
||||
updates
|
||||
);
|
||||
|
||||
if (conflictResult.hasConflict && conflictResult.conflicts && conflictResult.conflicts.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: conflictResult,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Build Jira update payload
|
||||
const payload = this.buildUpdatePayload(typeName, updates);
|
||||
|
||||
if (payload.attributes.length === 0) {
|
||||
logger.warn(`CMDBService: No attributes to update for ${typeName} ${id}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// 3. Send update to Jira
|
||||
const success = await jiraAssetsClient.updateObject(id, payload);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update object in Jira',
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Fetch fresh data and update cache
|
||||
const freshData = await this.fetchAndCacheObject(typeName, id);
|
||||
|
||||
logger.info(`CMDBService: Updated ${typeName} ${id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: freshData || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`CMDBService: Update failed for ${typeName} ${id}`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force update without conflict check (use with caution)
|
||||
*/
|
||||
async forceUpdateObject(
|
||||
typeName: CMDBObjectTypeName,
|
||||
id: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<UpdateResult> {
|
||||
try {
|
||||
const payload = this.buildUpdatePayload(typeName, updates);
|
||||
|
||||
if (payload.attributes.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const success = await jiraAssetsClient.updateObject(id, payload);
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to update object in Jira',
|
||||
};
|
||||
}
|
||||
|
||||
const freshData = await this.fetchAndCacheObject(typeName, id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: freshData || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`CMDBService: Force update failed for ${typeName} ${id}`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Jira update payload from field updates
|
||||
*/
|
||||
private buildUpdatePayload(
|
||||
typeName: CMDBObjectTypeName,
|
||||
updates: Record<string, unknown>
|
||||
): JiraUpdatePayload {
|
||||
const attributes: JiraUpdatePayload['attributes'] = [];
|
||||
|
||||
logger.debug(`CMDBService.buildUpdatePayload: Building payload for ${typeName}`, {
|
||||
updateKeys: Object.keys(updates),
|
||||
updates: JSON.stringify(updates, null, 2)
|
||||
});
|
||||
|
||||
for (const [fieldName, value] of Object.entries(updates)) {
|
||||
const attrDef = getAttributeDefinition(typeName, fieldName);
|
||||
|
||||
if (!attrDef) {
|
||||
logger.warn(`CMDBService: Unknown attribute ${fieldName} for ${typeName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!attrDef.isEditable) {
|
||||
logger.warn(`CMDBService: Attribute ${fieldName} is not editable`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrValues = this.buildAttributeValues(value, attrDef);
|
||||
|
||||
logger.debug(`CMDBService.buildUpdatePayload: Attribute ${fieldName} (jiraId: ${attrDef.jiraId}, type: ${attrDef.type}, isMultiple: ${attrDef.isMultiple})`, {
|
||||
inputValue: JSON.stringify(value),
|
||||
outputValues: JSON.stringify(attrValues)
|
||||
});
|
||||
|
||||
attributes.push({
|
||||
objectTypeAttributeId: attrDef.jiraId,
|
||||
objectAttributeValues: attrValues,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`CMDBService.buildUpdatePayload: Final payload`, {
|
||||
attributeCount: attributes.length,
|
||||
payload: JSON.stringify({ attributes }, null, 2)
|
||||
});
|
||||
|
||||
return { attributes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attribute values for Jira API
|
||||
*/
|
||||
private buildAttributeValues(
|
||||
value: unknown,
|
||||
attrDef: { type: string; isMultiple: boolean }
|
||||
): Array<{ value?: string }> {
|
||||
// Null/undefined = clear the field
|
||||
if (value === null || value === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reference type
|
||||
if (attrDef.type === 'reference') {
|
||||
if (attrDef.isMultiple && Array.isArray(value)) {
|
||||
return (value as ObjectReference[]).map(ref => ({
|
||||
value: ref.objectKey,
|
||||
}));
|
||||
} else if (!attrDef.isMultiple) {
|
||||
const ref = value as ObjectReference;
|
||||
return [{ value: ref.objectKey }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (attrDef.type === 'boolean') {
|
||||
return [{ value: value ? 'true' : 'false' }];
|
||||
}
|
||||
|
||||
// Number types
|
||||
if (attrDef.type === 'integer' || attrDef.type === 'float') {
|
||||
return [{ value: String(value) }];
|
||||
}
|
||||
|
||||
// String types
|
||||
return [{ value: String(value) }];
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cache Management
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats(): CacheStats {
|
||||
return cacheStore.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache has data
|
||||
*/
|
||||
isCacheWarm(): boolean {
|
||||
return cacheStore.isWarm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific type
|
||||
*/
|
||||
clearCacheForType(typeName: CMDBObjectTypeName): void {
|
||||
cacheStore.clearObjectType(typeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear entire cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
cacheStore.clearAll();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// User Token Management (for OAuth)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Set user token for current request
|
||||
*/
|
||||
setUserToken(token: string | null): void {
|
||||
jiraAssetsClient.setRequestToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user token
|
||||
*/
|
||||
clearUserToken(): void {
|
||||
jiraAssetsClient.clearRequestToken();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const cmdbService = new CMDBService();
|
||||
|
||||
254
backend/src/services/conflictResolver.ts
Normal file
254
backend/src/services/conflictResolver.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* ConflictResolver - Detects and reports conflicts when updating CMDB objects
|
||||
*
|
||||
* Implements optimistic locking by comparing timestamps and checking
|
||||
* for field-level conflicts.
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js';
|
||||
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
|
||||
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface FieldConflict {
|
||||
field: string;
|
||||
fieldName: string;
|
||||
proposedValue: unknown;
|
||||
jiraValue: unknown;
|
||||
}
|
||||
|
||||
export interface ConflictCheckResult {
|
||||
hasConflict: boolean;
|
||||
conflicts?: FieldConflict[];
|
||||
jiraUpdatedAt?: string;
|
||||
warning?: string;
|
||||
canMerge?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Conflict Resolver Implementation
|
||||
// =============================================================================
|
||||
|
||||
class ConflictResolver {
|
||||
/**
|
||||
* Check for conflicts before updating an object
|
||||
*
|
||||
* @param typeName - The object type
|
||||
* @param objectId - The object ID
|
||||
* @param originalUpdatedAt - The _jiraUpdatedAt from when the user started editing
|
||||
* @param proposedChanges - The changes the user wants to make
|
||||
*/
|
||||
async checkConflict(
|
||||
typeName: CMDBObjectTypeName,
|
||||
objectId: string,
|
||||
originalUpdatedAt: string,
|
||||
proposedChanges: Record<string, unknown>
|
||||
): Promise<ConflictCheckResult> {
|
||||
try {
|
||||
// 1. Fetch current state from Jira
|
||||
let jiraObj;
|
||||
try {
|
||||
jiraObj = await jiraAssetsClient.getObject(objectId);
|
||||
} catch (error) {
|
||||
if (error instanceof JiraObjectNotFoundError) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
warning: 'Object has been deleted from Jira',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!jiraObj) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
warning: 'Object not found in Jira',
|
||||
};
|
||||
}
|
||||
|
||||
const currentUpdatedAt = jiraObj.updated || '';
|
||||
|
||||
// 2. Compare timestamps
|
||||
if (currentUpdatedAt === originalUpdatedAt) {
|
||||
// No changes since user started editing - safe to update
|
||||
return { hasConflict: false };
|
||||
}
|
||||
|
||||
// 3. Timestamp differs - check field-level conflicts
|
||||
logger.info(`ConflictResolver: Timestamp mismatch for ${objectId}. Original: ${originalUpdatedAt}, Current: ${currentUpdatedAt}`);
|
||||
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) {
|
||||
return {
|
||||
hasConflict: true,
|
||||
warning: `Unknown object type: ${typeName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const conflictingFields: FieldConflict[] = [];
|
||||
|
||||
for (const [fieldName, proposedValue] of Object.entries(proposedChanges)) {
|
||||
const attrDef = getAttributeDefinition(typeName, fieldName);
|
||||
if (!attrDef) continue;
|
||||
|
||||
// Find the attribute in Jira response
|
||||
const jiraAttr = jiraObj.attributes.find(
|
||||
a => a.objectTypeAttributeId === attrDef.jiraId ||
|
||||
a.objectTypeAttribute?.name === attrDef.name
|
||||
);
|
||||
|
||||
// Extract current value from Jira
|
||||
const jiraValue = this.extractAttributeValue(jiraAttr, attrDef);
|
||||
|
||||
// Compare values
|
||||
if (!this.valuesEqual(proposedValue, jiraValue, attrDef.type)) {
|
||||
conflictingFields.push({
|
||||
field: attrDef.name,
|
||||
fieldName,
|
||||
proposedValue,
|
||||
jiraValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (conflictingFields.length === 0) {
|
||||
// Object was updated but not on the same fields - safe to merge
|
||||
return {
|
||||
hasConflict: false,
|
||||
warning: 'Object was updated but no field conflicts detected',
|
||||
jiraUpdatedAt: currentUpdatedAt,
|
||||
canMerge: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Real conflicts exist
|
||||
return {
|
||||
hasConflict: true,
|
||||
conflicts: conflictingFields,
|
||||
jiraUpdatedAt: currentUpdatedAt,
|
||||
canMerge: false,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`ConflictResolver: Error checking conflict for ${objectId}`, error);
|
||||
return {
|
||||
hasConflict: true,
|
||||
warning: 'Error checking for conflicts',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a typed value from a Jira attribute
|
||||
*/
|
||||
private extractAttributeValue(
|
||||
jiraAttr: { objectAttributeValues: Array<{
|
||||
value?: string;
|
||||
displayValue?: string;
|
||||
referencedObject?: { id: number; objectKey: string; label: string };
|
||||
}> } | undefined,
|
||||
attrDef: { type: string; isMultiple: boolean }
|
||||
): unknown {
|
||||
if (!jiraAttr?.objectAttributeValues?.length) {
|
||||
return attrDef.isMultiple ? [] : null;
|
||||
}
|
||||
|
||||
const values = jiraAttr.objectAttributeValues;
|
||||
|
||||
switch (attrDef.type) {
|
||||
case 'reference': {
|
||||
const refs = values
|
||||
.filter(v => v.referencedObject)
|
||||
.map(v => ({
|
||||
objectId: v.referencedObject!.id.toString(),
|
||||
objectKey: v.referencedObject!.objectKey,
|
||||
label: v.referencedObject!.label,
|
||||
}));
|
||||
return attrDef.isMultiple ? refs : refs[0] || null;
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
const val = values[0]?.value;
|
||||
return val === 'true' || val === 'Ja';
|
||||
}
|
||||
|
||||
case 'integer': {
|
||||
const val = values[0]?.value;
|
||||
return val ? parseInt(val, 10) : null;
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
const val = values[0]?.value;
|
||||
return val ? parseFloat(val) : null;
|
||||
}
|
||||
|
||||
default:
|
||||
return values[0]?.displayValue ?? values[0]?.value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two values for equality
|
||||
*/
|
||||
private valuesEqual(
|
||||
proposed: unknown,
|
||||
jira: unknown,
|
||||
type: string
|
||||
): boolean {
|
||||
// Both null/undefined
|
||||
if (proposed == null && jira == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// One is null
|
||||
if (proposed == null || jira == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reference type
|
||||
if (type === 'reference') {
|
||||
return this.referencesEqual(proposed, jira);
|
||||
}
|
||||
|
||||
// Arrays
|
||||
if (Array.isArray(proposed) && Array.isArray(jira)) {
|
||||
if (proposed.length !== jira.length) return false;
|
||||
// For reference arrays, compare by objectId
|
||||
if (type === 'reference') {
|
||||
const proposedIds = new Set((proposed as Array<{ objectId?: string }>).map(r => r.objectId));
|
||||
const jiraIds = new Set((jira as Array<{ objectId?: string }>).map(r => r.objectId));
|
||||
if (proposedIds.size !== jiraIds.size) return false;
|
||||
for (const id of proposedIds) {
|
||||
if (!jiraIds.has(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return JSON.stringify(proposed) === JSON.stringify(jira);
|
||||
}
|
||||
|
||||
// Primitives
|
||||
return proposed === jira;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare reference values
|
||||
*/
|
||||
private referencesEqual(proposed: unknown, jira: unknown): boolean {
|
||||
// Compare by objectId or objectKey
|
||||
const propRef = proposed as { objectId?: string; objectKey?: string } | null;
|
||||
const jiraRef = jira as { objectId?: string; objectKey?: string } | null;
|
||||
|
||||
if (!propRef && !jiraRef) return true;
|
||||
if (!propRef || !jiraRef) return false;
|
||||
|
||||
return propRef.objectId === jiraRef.objectId ||
|
||||
propRef.objectKey === jiraRef.objectKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const conflictResolver = new ConflictResolver();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,15 @@ import {
|
||||
HostingRule,
|
||||
} from '../config/effortCalculation.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from './logger.js';
|
||||
import type { ApplicationDetails, EffortCalculationBreakdown } 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 (v25)
|
||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
425
backend/src/services/jiraAssetsClient.ts
Normal file
425
backend/src/services/jiraAssetsClient.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* JiraAssetsClient - Low-level Jira Assets API client for CMDB caching
|
||||
*
|
||||
* This client handles direct API calls to Jira Insight/Assets and provides
|
||||
* methods for fetching, parsing, and updating CMDB objects.
|
||||
*/
|
||||
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { OBJECT_TYPES } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
|
||||
import type { JiraAssetsObject, JiraAssetsAttribute, JiraAssetsSearchResponse } from '../types/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/** Error thrown when an object is not found in Jira (404) */
|
||||
export class JiraObjectNotFoundError extends Error {
|
||||
constructor(public objectId: string) {
|
||||
super(`Object ${objectId} not found in Jira`);
|
||||
this.name = 'JiraObjectNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraUpdatePayload {
|
||||
objectTypeId?: number; // Optional for updates (PUT) - only needed for creates (POST)
|
||||
attributes: Array<{
|
||||
objectTypeAttributeId: number;
|
||||
objectAttributeValues: Array<{ value?: string }>; // value can be undefined when clearing
|
||||
}>;
|
||||
}
|
||||
|
||||
// Map from Jira object type ID to our type name
|
||||
const TYPE_ID_TO_NAME: Record<number, CMDBObjectTypeName> = {};
|
||||
const JIRA_NAME_TO_TYPE: Record<string, CMDBObjectTypeName> = {};
|
||||
|
||||
// Build lookup maps from schema
|
||||
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
|
||||
TYPE_ID_TO_NAME[typeDef.jiraTypeId] = typeName as CMDBObjectTypeName;
|
||||
JIRA_NAME_TO_TYPE[typeDef.name] = typeName as CMDBObjectTypeName;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JiraAssetsClient Implementation
|
||||
// =============================================================================
|
||||
|
||||
class JiraAssetsClient {
|
||||
private baseUrl: string;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
private isDataCenter: boolean | null = null;
|
||||
private requestToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
// Add PAT authentication if configured
|
||||
if (config.jiraAuthMethod === 'pat' && config.jiraPat) {
|
||||
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Request Token Management (for user-context requests)
|
||||
// ==========================================================================
|
||||
|
||||
setRequestToken(token: string): void {
|
||||
this.requestToken = token;
|
||||
}
|
||||
|
||||
clearRequestToken(): void {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// API Detection
|
||||
// ==========================================================================
|
||||
|
||||
private async detectApiType(): Promise<void> {
|
||||
if (this.isDataCenter !== null) return;
|
||||
|
||||
// Detect based on host URL pattern:
|
||||
// - Jira Cloud uses *.atlassian.net domains
|
||||
// - Everything else (custom domains) is Data Center / on-premise
|
||||
if (config.jiraHost.includes('atlassian.net')) {
|
||||
this.isDataCenter = false;
|
||||
logger.info('JiraAssetsClient: Detected Jira Cloud (Assets API) based on host URL');
|
||||
} else {
|
||||
this.isDataCenter = true;
|
||||
logger.info('JiraAssetsClient: Detected Jira Data Center (Insight API) based on host URL');
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers = { ...this.defaultHeaders };
|
||||
|
||||
// Use request-scoped token if available (for user context)
|
||||
if (this.requestToken) {
|
||||
headers['Authorization'] = `Bearer ${this.requestToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Core API Methods
|
||||
// ==========================================================================
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Jira API error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API Methods
|
||||
// ==========================================================================
|
||||
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.detectApiType();
|
||||
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
logger.error('JiraAssetsClient: Connection test failed', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getObject(objectId: string): Promise<JiraAssetsObject | null> {
|
||||
try {
|
||||
return await this.request<JiraAssetsObject>(`/object/${objectId}`);
|
||||
} catch (error) {
|
||||
// Check if this is a 404 (object not found / deleted)
|
||||
if (error instanceof Error && error.message.includes('404')) {
|
||||
logger.info(`JiraAssetsClient: Object ${objectId} not found in Jira (likely deleted)`);
|
||||
throw new JiraObjectNotFoundError(objectId);
|
||||
}
|
||||
logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async searchObjects(
|
||||
iql: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 50
|
||||
): Promise<{ objects: JiraAssetsObject[]; totalCount: number; hasMore: boolean }> {
|
||||
await this.detectApiType();
|
||||
|
||||
let response: JiraAssetsSearchResponse;
|
||||
|
||||
if (this.isDataCenter) {
|
||||
// Try modern AQL endpoint first
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
qlQuery: iql,
|
||||
page: page.toString(),
|
||||
resultPerPage: pageSize.toString(),
|
||||
includeAttributes: 'true',
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
|
||||
} catch (error) {
|
||||
// Fallback to deprecated IQL endpoint
|
||||
logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`);
|
||||
const params = new URLSearchParams({
|
||||
iql,
|
||||
page: page.toString(),
|
||||
resultPerPage: pageSize.toString(),
|
||||
includeAttributes: 'true',
|
||||
includeAttributesDeep: '1',
|
||||
objectSchemaId: config.jiraSchemaId,
|
||||
});
|
||||
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
|
||||
}
|
||||
} else {
|
||||
// Jira Cloud uses POST for AQL
|
||||
response = await this.request<JiraAssetsSearchResponse>('/aql/objects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
qlQuery: iql,
|
||||
page,
|
||||
resultPerPage: pageSize,
|
||||
includeAttributes: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const totalCount = response.totalFilterCount || response.totalCount || 0;
|
||||
const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount;
|
||||
|
||||
return {
|
||||
objects: response.objectEntries || [],
|
||||
totalCount,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllObjectsOfType(
|
||||
typeName: CMDBObjectTypeName,
|
||||
batchSize: number = 40
|
||||
): Promise<JiraAssetsObject[]> {
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) {
|
||||
logger.warn(`JiraAssetsClient: Unknown type ${typeName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const allObjects: JiraAssetsObject[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const iql = `objectType = "${typeDef.name}"`;
|
||||
const result = await this.searchObjects(iql, page, batchSize);
|
||||
allObjects.push(...result.objects);
|
||||
hasMore = result.hasMore;
|
||||
page++;
|
||||
}
|
||||
|
||||
logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects`);
|
||||
return allObjects;
|
||||
}
|
||||
|
||||
async getUpdatedObjectsSince(
|
||||
since: Date,
|
||||
_batchSize: number = 40
|
||||
): Promise<JiraAssetsObject[]> {
|
||||
await this.detectApiType();
|
||||
|
||||
// Jira Data Center's IQL doesn't support filtering by 'updated' attribute
|
||||
if (this.isDataCenter) {
|
||||
logger.debug('JiraAssetsClient: Incremental sync via IQL not supported on Data Center, skipping');
|
||||
return [];
|
||||
}
|
||||
|
||||
// For Jira Cloud, we could use updated >= "date" in IQL
|
||||
const iql = `updated >= "${since.toISOString()}"`;
|
||||
const result = await this.searchObjects(iql, 1, 1000);
|
||||
return result.objects;
|
||||
}
|
||||
|
||||
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> {
|
||||
try {
|
||||
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
|
||||
attributeCount: payload.attributes.length,
|
||||
payload: JSON.stringify(payload, null, 2)
|
||||
});
|
||||
|
||||
await this.request(`/object/${objectId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`JiraAssetsClient: Failed to update object ${objectId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Object Parsing
|
||||
// ==========================================================================
|
||||
|
||||
parseObject<T extends CMDBObject>(jiraObj: JiraAssetsObject): T | null {
|
||||
const typeId = jiraObj.objectType?.id;
|
||||
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})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
id: jiraObj.id.toString(),
|
||||
objectKey: jiraObj.objectKey,
|
||||
label: jiraObj.label,
|
||||
_objectType: typeName,
|
||||
_jiraUpdatedAt: jiraObj.updated || new Date().toISOString(),
|
||||
_jiraCreatedAt: jiraObj.created || new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Parse each attribute based on schema
|
||||
for (const attrDef of typeDef.attributes) {
|
||||
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name);
|
||||
result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef);
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
private findAttribute(
|
||||
attributes: JiraAssetsAttribute[],
|
||||
jiraId: number,
|
||||
name: string
|
||||
): JiraAssetsAttribute | undefined {
|
||||
// Try by ID first
|
||||
let attr = attributes.find(a => a.objectTypeAttributeId === jiraId);
|
||||
if (attr) return attr;
|
||||
|
||||
// Try by name
|
||||
attr = attributes.find(a =>
|
||||
a.objectTypeAttribute?.name === name ||
|
||||
a.objectTypeAttribute?.name?.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
|
||||
return attr;
|
||||
}
|
||||
|
||||
private parseAttributeValue(
|
||||
jiraAttr: JiraAssetsAttribute | undefined,
|
||||
attrDef: { type: string; isMultiple: boolean }
|
||||
): unknown {
|
||||
if (!jiraAttr?.objectAttributeValues?.length) {
|
||||
return attrDef.isMultiple ? [] : null;
|
||||
}
|
||||
|
||||
const values = jiraAttr.objectAttributeValues;
|
||||
|
||||
switch (attrDef.type) {
|
||||
case 'reference': {
|
||||
const refs = values
|
||||
.filter(v => v.referencedObject)
|
||||
.map(v => ({
|
||||
objectId: v.referencedObject!.id.toString(),
|
||||
objectKey: v.referencedObject!.objectKey,
|
||||
label: v.referencedObject!.label,
|
||||
} as ObjectReference));
|
||||
return attrDef.isMultiple ? refs : refs[0] || null;
|
||||
}
|
||||
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'url':
|
||||
case 'email':
|
||||
case 'select':
|
||||
case 'user': {
|
||||
const val = values[0]?.displayValue ?? values[0]?.value ?? null;
|
||||
// Strip HTML if present
|
||||
if (val && typeof val === 'string' && val.includes('<')) {
|
||||
return this.stripHtml(val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
case 'integer': {
|
||||
const val = values[0]?.value;
|
||||
return val ? parseInt(val, 10) : null;
|
||||
}
|
||||
|
||||
case 'float': {
|
||||
const val = values[0]?.value;
|
||||
return val ? parseFloat(val) : null;
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
const val = values[0]?.value;
|
||||
return val === 'true' || val === 'Ja';
|
||||
}
|
||||
|
||||
case 'date':
|
||||
case 'datetime': {
|
||||
return values[0]?.value ?? values[0]?.displayValue ?? null;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const statusVal = values[0]?.status;
|
||||
if (statusVal) {
|
||||
return statusVal.name || null;
|
||||
}
|
||||
return values[0]?.displayValue ?? values[0]?.value ?? null;
|
||||
}
|
||||
|
||||
default:
|
||||
return values[0]?.displayValue ?? values[0]?.value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const jiraAssetsClient = new JiraAssetsClient();
|
||||
|
||||
@@ -36,7 +36,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -63,7 +64,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -90,7 +92,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: null,
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -117,7 +120,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -144,7 +148,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -171,7 +176,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||
numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' },
|
||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -198,7 +204,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: null,
|
||||
governanceModel: null,
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -225,7 +232,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||
numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' },
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -252,7 +260,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||
numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' },
|
||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -279,7 +288,8 @@ const mockApplications: ApplicationDetails[] = [
|
||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||
numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' },
|
||||
governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' },
|
||||
applicationCluster: null,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: null,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
@@ -347,10 +357,10 @@ const mockBusinessImpactAnalyses: ReferenceValue[] = [
|
||||
{ objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
|
||||
];
|
||||
|
||||
const mockApplicationClusters: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'CLUSTER-1', name: 'Zorgapplicaties' },
|
||||
{ objectId: '2', key: 'CLUSTER-2', name: 'Bedrijfsvoering' },
|
||||
{ objectId: '3', key: 'CLUSTER-3', name: 'Infrastructuur' },
|
||||
const mockApplicationSubteams: ReferenceValue[] = [
|
||||
{ objectId: '1', key: 'SUBTEAM-1', name: 'Zorgapplicaties' },
|
||||
{ objectId: '2', key: 'SUBTEAM-2', name: 'Bedrijfsvoering' },
|
||||
{ objectId: '3', key: 'SUBTEAM-3', name: 'Infrastructuur' },
|
||||
];
|
||||
|
||||
const mockApplicationTypes: ReferenceValue[] = [
|
||||
@@ -420,11 +430,11 @@ export class MockDataService {
|
||||
filtered = filtered.filter((app) => !!app.complexityFactor);
|
||||
}
|
||||
|
||||
// Apply applicationCluster filter
|
||||
if (filters.applicationCluster === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.applicationCluster);
|
||||
} else if (filters.applicationCluster === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.applicationCluster);
|
||||
// Apply applicationSubteam filter
|
||||
if (filters.applicationSubteam === 'empty') {
|
||||
filtered = filtered.filter((app) => !app.applicationSubteam);
|
||||
} else if (filters.applicationSubteam === 'filled') {
|
||||
filtered = filtered.filter((app) => !!app.applicationSubteam);
|
||||
}
|
||||
|
||||
// Apply applicationType filter
|
||||
@@ -468,7 +478,8 @@ export class MockDataService {
|
||||
governanceModel: app.governanceModel,
|
||||
dynamicsFactor: app.dynamicsFactor,
|
||||
complexityFactor: app.complexityFactor,
|
||||
applicationCluster: app.applicationCluster,
|
||||
applicationSubteam: app.applicationSubteam,
|
||||
applicationTeam: app.applicationTeam,
|
||||
applicationType: app.applicationType,
|
||||
platform: app.platform,
|
||||
requiredEffortApplicationManagement: effort,
|
||||
@@ -501,7 +512,8 @@ export class MockDataService {
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationSubteam?: ReferenceValue;
|
||||
applicationTeam?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
@@ -527,8 +539,11 @@ export class MockDataService {
|
||||
if (updates.governanceModel !== undefined) {
|
||||
app.governanceModel = updates.governanceModel;
|
||||
}
|
||||
if (updates.applicationCluster !== undefined) {
|
||||
app.applicationCluster = updates.applicationCluster;
|
||||
if (updates.applicationSubteam !== undefined) {
|
||||
app.applicationSubteam = updates.applicationSubteam;
|
||||
}
|
||||
if (updates.applicationTeam !== undefined) {
|
||||
app.applicationTeam = updates.applicationTeam;
|
||||
}
|
||||
if (updates.applicationType !== undefined) {
|
||||
app.applicationType = updates.applicationType;
|
||||
@@ -539,12 +554,6 @@ export class MockDataService {
|
||||
if (updates.businessImpactAnalyse !== undefined) {
|
||||
app.businessImpactAnalyse = updates.businessImpactAnalyse;
|
||||
}
|
||||
if (updates.applicationCluster !== undefined) {
|
||||
app.applicationCluster = updates.applicationCluster;
|
||||
}
|
||||
if (updates.applicationType !== undefined) {
|
||||
app.applicationType = updates.applicationType;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -601,7 +610,7 @@ export class MockDataService {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
async getApplicationSubteams(): Promise<ReferenceValue[]> {
|
||||
// Return empty for mock - in real implementation, this comes from Jira
|
||||
return [];
|
||||
}
|
||||
@@ -671,7 +680,8 @@ export class MockDataService {
|
||||
governanceModel: app.governanceModel,
|
||||
dynamicsFactor: app.dynamicsFactor,
|
||||
complexityFactor: app.complexityFactor,
|
||||
applicationCluster: app.applicationCluster,
|
||||
applicationSubteam: app.applicationSubteam,
|
||||
applicationTeam: app.applicationTeam,
|
||||
applicationType: app.applicationType,
|
||||
platform: app.platform,
|
||||
requiredEffortApplicationManagement: app.requiredEffortApplicationManagement,
|
||||
@@ -726,8 +736,8 @@ export class MockDataService {
|
||||
});
|
||||
}
|
||||
|
||||
// Group all applications (regular + platforms + workloads) by cluster
|
||||
const clusterMap = new Map<string, {
|
||||
// Group all applications (regular + platforms + workloads) by subteam
|
||||
const subteamMap = new Map<string, {
|
||||
regular: ApplicationListItem[];
|
||||
platforms: import('../types/index.js').PlatformWithWorkloads[];
|
||||
}>();
|
||||
@@ -739,39 +749,39 @@ export class MockDataService {
|
||||
platforms: [],
|
||||
};
|
||||
|
||||
// Group regular applications by cluster
|
||||
// Group regular applications by subteam
|
||||
for (const app of regularApplications) {
|
||||
if (app.applicationCluster) {
|
||||
const clusterId = app.applicationCluster.objectId;
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
||||
if (app.applicationSubteam) {
|
||||
const subteamId = app.applicationSubteam.objectId;
|
||||
if (!subteamMap.has(subteamId)) {
|
||||
subteamMap.set(subteamId, { regular: [], platforms: [] });
|
||||
}
|
||||
clusterMap.get(clusterId)!.regular.push(app);
|
||||
subteamMap.get(subteamId)!.regular.push(app);
|
||||
} else {
|
||||
unassigned.regular.push(app);
|
||||
}
|
||||
}
|
||||
|
||||
// Group platforms by cluster
|
||||
// Group platforms by subteam
|
||||
for (const platformWithWorkloads of platformsWithWorkloads) {
|
||||
const platform = platformWithWorkloads.platform;
|
||||
if (platform.applicationCluster) {
|
||||
const clusterId = platform.applicationCluster.objectId;
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
||||
if (platform.applicationSubteam) {
|
||||
const subteamId = platform.applicationSubteam.objectId;
|
||||
if (!subteamMap.has(subteamId)) {
|
||||
subteamMap.set(subteamId, { regular: [], platforms: [] });
|
||||
}
|
||||
clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads);
|
||||
subteamMap.get(subteamId)!.platforms.push(platformWithWorkloads);
|
||||
} else {
|
||||
unassigned.platforms.push(platformWithWorkloads);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all clusters
|
||||
const allClusters = mockApplicationClusters;
|
||||
const clusters = allClusters.map(cluster => {
|
||||
const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] };
|
||||
const regularApps = clusterData.regular;
|
||||
const platforms = clusterData.platforms;
|
||||
// Build subteams from mock data
|
||||
const allSubteams = mockApplicationSubteams;
|
||||
const subteams: import('../types/index.js').TeamDashboardSubteam[] = allSubteams.map(subteamRef => {
|
||||
const subteamData = subteamMap.get(subteamRef.objectId) || { regular: [], platforms: [] };
|
||||
const regularApps = subteamData.regular;
|
||||
const platforms = subteamData.platforms;
|
||||
|
||||
// Calculate total effort: regular apps + platforms (including their workloads)
|
||||
const regularEffort = regularApps.reduce((sum, app) =>
|
||||
@@ -803,7 +813,7 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
return {
|
||||
cluster,
|
||||
subteam: subteamRef,
|
||||
applications: regularApps,
|
||||
platforms,
|
||||
totalEffort,
|
||||
@@ -812,7 +822,28 @@ export class MockDataService {
|
||||
applicationCount,
|
||||
byGovernanceModel,
|
||||
};
|
||||
});
|
||||
}).filter(s => s.applicationCount > 0); // Only include subteams with apps
|
||||
|
||||
// Create a virtual team containing all subteams (since Team doesn't exist in mock data)
|
||||
const virtualTeam: import('../types/index.js').TeamDashboardTeam = {
|
||||
team: {
|
||||
objectId: 'mock-team-1',
|
||||
key: 'TEAM-1',
|
||||
name: 'Mock Team',
|
||||
teamType: 'Business',
|
||||
},
|
||||
subteams,
|
||||
totalEffort: subteams.reduce((sum, s) => sum + s.totalEffort, 0),
|
||||
minEffort: subteams.reduce((sum, s) => sum + s.minEffort, 0),
|
||||
maxEffort: subteams.reduce((sum, s) => sum + s.maxEffort, 0),
|
||||
applicationCount: subteams.reduce((sum, s) => sum + s.applicationCount, 0),
|
||||
byGovernanceModel: subteams.reduce((acc, s) => {
|
||||
for (const [key, count] of Object.entries(s.byGovernanceModel)) {
|
||||
acc[key] = (acc[key] || 0) + count;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
};
|
||||
|
||||
// Calculate unassigned totals
|
||||
const unassignedRegularEffort = unassigned.regular.reduce((sum, app) =>
|
||||
@@ -842,8 +873,9 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
return {
|
||||
clusters,
|
||||
teams: subteams.length > 0 ? [virtualTeam] : [],
|
||||
unassigned: {
|
||||
subteam: null,
|
||||
applications: unassigned.regular,
|
||||
platforms: unassigned.platforms,
|
||||
totalEffort: unassignedTotalEffort,
|
||||
|
||||
463
backend/src/services/syncEngine.ts
Normal file
463
backend/src/services/syncEngine.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* SyncEngine - Background synchronization service
|
||||
*
|
||||
* Handles:
|
||||
* - Full sync at startup and periodically
|
||||
* - Incremental sync every 30 seconds
|
||||
* - Schema-driven sync for all object types
|
||||
*/
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { cacheStore } from './cacheStore.js';
|
||||
import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js';
|
||||
import { OBJECT_TYPES, getObjectTypesBySyncPriority } from '../generated/jira-schema.js';
|
||||
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface SyncStats {
|
||||
objectType: string;
|
||||
objectsProcessed: number;
|
||||
relationsExtracted: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
stats: SyncStats[];
|
||||
totalObjects: number;
|
||||
totalRelations: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncEngineStatus {
|
||||
isRunning: boolean;
|
||||
isSyncing: boolean;
|
||||
lastFullSync: string | null;
|
||||
lastIncrementalSync: string | null;
|
||||
nextIncrementalSync: string | null;
|
||||
incrementalInterval: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
const DEFAULT_INCREMENTAL_INTERVAL = 30_000; // 30 seconds
|
||||
const DEFAULT_BATCH_SIZE = 50;
|
||||
|
||||
// =============================================================================
|
||||
// Sync Engine Implementation
|
||||
// =============================================================================
|
||||
|
||||
class SyncEngine {
|
||||
private isRunning: boolean = false;
|
||||
private isSyncing: boolean = false; // For full/incremental syncs
|
||||
private syncingTypes: Set<CMDBObjectTypeName> = new Set(); // Track which types are being synced
|
||||
private incrementalTimer: NodeJS.Timeout | null = null;
|
||||
private incrementalInterval: number;
|
||||
private batchSize: number;
|
||||
private lastIncrementalSync: Date | null = null;
|
||||
|
||||
constructor() {
|
||||
this.incrementalInterval = parseInt(
|
||||
process.env.SYNC_INCREMENTAL_INTERVAL_MS || String(DEFAULT_INCREMENTAL_INTERVAL),
|
||||
10
|
||||
);
|
||||
this.batchSize = parseInt(
|
||||
process.env.JIRA_API_BATCH_SIZE || String(DEFAULT_BATCH_SIZE),
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Lifecycle
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the sync engine
|
||||
* Performs initial sync if cache is cold, then starts incremental sync
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
logger.warn('SyncEngine: Already running');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('SyncEngine: Initializing...');
|
||||
this.isRunning = true;
|
||||
|
||||
// Check if we need a full sync
|
||||
const stats = cacheStore.getStats();
|
||||
const lastFullSync = stats.lastFullSync;
|
||||
const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000);
|
||||
|
||||
if (needsFullSync) {
|
||||
logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...');
|
||||
// Run full sync in background (non-blocking)
|
||||
this.fullSync().catch(err => {
|
||||
logger.error('SyncEngine: Background full sync failed', err);
|
||||
});
|
||||
} else {
|
||||
logger.info('SyncEngine: Cache is warm, skipping initial full sync');
|
||||
}
|
||||
|
||||
// Start incremental sync scheduler
|
||||
this.startIncrementalSyncScheduler();
|
||||
|
||||
logger.info('SyncEngine: Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the sync engine
|
||||
*/
|
||||
stop(): void {
|
||||
logger.info('SyncEngine: Stopping...');
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.incrementalTimer) {
|
||||
clearInterval(this.incrementalTimer);
|
||||
this.incrementalTimer = null;
|
||||
}
|
||||
|
||||
logger.info('SyncEngine: Stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timestamp is stale
|
||||
*/
|
||||
private isStale(timestamp: string, maxAgeMs: number): boolean {
|
||||
const age = Date.now() - new Date(timestamp).getTime();
|
||||
return age > maxAgeMs;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Full Sync
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Perform a full sync of all object types
|
||||
*/
|
||||
async fullSync(): Promise<SyncResult> {
|
||||
if (this.isSyncing) {
|
||||
logger.warn('SyncEngine: Sync already in progress');
|
||||
return {
|
||||
success: false,
|
||||
stats: [],
|
||||
totalObjects: 0,
|
||||
totalRelations: 0,
|
||||
duration: 0,
|
||||
error: 'Sync already in progress',
|
||||
};
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
const startTime = Date.now();
|
||||
const stats: SyncStats[] = [];
|
||||
let totalObjects = 0;
|
||||
let totalRelations = 0;
|
||||
|
||||
logger.info('SyncEngine: Starting full sync...');
|
||||
|
||||
try {
|
||||
// Get object types sorted by sync priority
|
||||
const objectTypes = getObjectTypesBySyncPriority();
|
||||
|
||||
for (const typeDef of objectTypes) {
|
||||
const typeStat = await this.syncObjectType(typeDef.typeName as CMDBObjectTypeName);
|
||||
stats.push(typeStat);
|
||||
totalObjects += typeStat.objectsProcessed;
|
||||
totalRelations += typeStat.relationsExtracted;
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
const now = new Date().toISOString();
|
||||
cacheStore.setSyncMetadata('lastFullSync', now);
|
||||
cacheStore.setSyncMetadata('lastIncrementalSync', now);
|
||||
this.lastIncrementalSync = new Date();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`SyncEngine: Full sync complete. ${totalObjects} objects, ${totalRelations} relations in ${duration}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats,
|
||||
totalObjects,
|
||||
totalRelations,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('SyncEngine: Full sync failed', error);
|
||||
return {
|
||||
success: false,
|
||||
stats,
|
||||
totalObjects,
|
||||
totalRelations,
|
||||
duration: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single object type
|
||||
*/
|
||||
private async syncObjectType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
|
||||
const startTime = Date.now();
|
||||
let objectsProcessed = 0;
|
||||
let relationsExtracted = 0;
|
||||
|
||||
try {
|
||||
const typeDef = OBJECT_TYPES[typeName];
|
||||
if (!typeDef) {
|
||||
logger.warn(`SyncEngine: Unknown type ${typeName}`);
|
||||
return { objectType: typeName, objectsProcessed: 0, relationsExtracted: 0, duration: 0 };
|
||||
}
|
||||
|
||||
logger.debug(`SyncEngine: Syncing ${typeName}...`);
|
||||
|
||||
// Fetch all objects from Jira
|
||||
const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize);
|
||||
|
||||
// Parse and cache objects
|
||||
const parsedObjects: CMDBObject[] = [];
|
||||
|
||||
for (const jiraObj of jiraObjects) {
|
||||
const parsed = jiraAssetsClient.parseObject(jiraObj);
|
||||
if (parsed) {
|
||||
parsedObjects.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch upsert to cache
|
||||
if (parsedObjects.length > 0) {
|
||||
cacheStore.batchUpsertObjects(typeName, parsedObjects);
|
||||
objectsProcessed = parsedObjects.length;
|
||||
|
||||
// Extract relations
|
||||
for (const obj of parsedObjects) {
|
||||
cacheStore.extractAndStoreRelations(typeName, obj);
|
||||
relationsExtracted++;
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`);
|
||||
|
||||
return {
|
||||
objectType: typeName,
|
||||
objectsProcessed,
|
||||
relationsExtracted,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`SyncEngine: Failed to sync ${typeName}`, error);
|
||||
return {
|
||||
objectType: typeName,
|
||||
objectsProcessed,
|
||||
relationsExtracted,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Incremental Sync
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Start the incremental sync scheduler
|
||||
*/
|
||||
private startIncrementalSyncScheduler(): void {
|
||||
if (this.incrementalTimer) {
|
||||
clearInterval(this.incrementalTimer);
|
||||
}
|
||||
|
||||
logger.info(`SyncEngine: Starting incremental sync scheduler (every ${this.incrementalInterval}ms)`);
|
||||
|
||||
this.incrementalTimer = setInterval(() => {
|
||||
if (!this.isSyncing && this.isRunning) {
|
||||
this.incrementalSync().catch(err => {
|
||||
logger.error('SyncEngine: Incremental sync failed', err);
|
||||
});
|
||||
}
|
||||
}, this.incrementalInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an incremental sync (only updated objects)
|
||||
*
|
||||
* Note: On Jira Data Center, IQL-based incremental sync is not supported.
|
||||
* We instead check if a periodic full sync is needed.
|
||||
*/
|
||||
async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> {
|
||||
if (this.isSyncing) {
|
||||
return { success: false, updatedCount: 0 };
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
// Get the last sync time
|
||||
const lastSyncStr = cacheStore.getSyncMetadata('lastIncrementalSync');
|
||||
const since = lastSyncStr
|
||||
? new Date(lastSyncStr)
|
||||
: new Date(Date.now() - 60000); // Default: last minute
|
||||
|
||||
logger.debug(`SyncEngine: Incremental sync since ${since.toISOString()}`);
|
||||
|
||||
// Fetch updated objects from Jira
|
||||
const updatedObjects = await jiraAssetsClient.getUpdatedObjectsSince(since, this.batchSize);
|
||||
|
||||
// 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');
|
||||
if (lastFullSyncStr) {
|
||||
const lastFullSync = new Date(lastFullSyncStr);
|
||||
const fullSyncAge = Date.now() - lastFullSync.getTime();
|
||||
const FULL_SYNC_INTERVAL = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
if (fullSyncAge > FULL_SYNC_INTERVAL) {
|
||||
logger.info('SyncEngine: Triggering periodic full sync (incremental not available)');
|
||||
// Release the lock before calling fullSync
|
||||
this.isSyncing = false;
|
||||
await this.fullSync();
|
||||
return { success: true, updatedCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamp even if no objects were synced
|
||||
const now = new Date();
|
||||
cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString());
|
||||
this.lastIncrementalSync = now;
|
||||
|
||||
return { success: true, updatedCount: 0 };
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const jiraObj of updatedObjects) {
|
||||
const parsed = jiraAssetsClient.parseObject(jiraObj);
|
||||
if (parsed) {
|
||||
const typeName = parsed._objectType as CMDBObjectTypeName;
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
cacheStore.extractAndStoreRelations(typeName, parsed);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
const now = new Date();
|
||||
cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString());
|
||||
this.lastIncrementalSync = now;
|
||||
|
||||
if (updatedCount > 0) {
|
||||
logger.info(`SyncEngine: Incremental sync updated ${updatedCount} objects`);
|
||||
}
|
||||
|
||||
return { success: true, updatedCount };
|
||||
} catch (error) {
|
||||
logger.error('SyncEngine: Incremental sync failed', error);
|
||||
return { success: false, updatedCount: 0 };
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Manual Sync Triggers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Trigger a sync for a specific object type
|
||||
* Allows concurrent syncs for different types, but blocks if:
|
||||
* - A full sync is in progress
|
||||
* - An incremental sync is in progress
|
||||
* - This specific type is already being synced
|
||||
*/
|
||||
async syncType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
|
||||
// Block if a full or incremental sync is running
|
||||
if (this.isSyncing) {
|
||||
throw new Error('Full or incremental sync already in progress');
|
||||
}
|
||||
|
||||
// Block if this specific type is already being synced
|
||||
if (this.syncingTypes.has(typeName)) {
|
||||
throw new Error(`Sync already in progress for ${typeName}`);
|
||||
}
|
||||
|
||||
this.syncingTypes.add(typeName);
|
||||
|
||||
try {
|
||||
return await this.syncObjectType(typeName);
|
||||
} finally {
|
||||
this.syncingTypes.delete(typeName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force sync a single object
|
||||
* If the object was deleted from Jira, it will be removed from the local cache
|
||||
*/
|
||||
async syncObject(typeName: CMDBObjectTypeName, objectId: string): Promise<boolean> {
|
||||
try {
|
||||
const jiraObj = await jiraAssetsClient.getObject(objectId);
|
||||
if (!jiraObj) return false;
|
||||
|
||||
const parsed = jiraAssetsClient.parseObject(jiraObj);
|
||||
if (!parsed) return false;
|
||||
|
||||
cacheStore.upsertObject(typeName, parsed);
|
||||
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);
|
||||
if (deleted) {
|
||||
logger.info(`SyncEngine: Removed deleted object ${typeName}/${objectId} from cache`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
logger.error(`SyncEngine: Failed to sync object ${objectId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Status
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get current sync engine status
|
||||
*/
|
||||
getStatus(): SyncEngineStatus {
|
||||
const stats = cacheStore.getStats();
|
||||
|
||||
let nextIncrementalSync: string | null = null;
|
||||
if (this.isRunning && this.lastIncrementalSync) {
|
||||
const nextTime = new Date(this.lastIncrementalSync.getTime() + this.incrementalInterval);
|
||||
nextIncrementalSync = nextTime.toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
isSyncing: this.isSyncing,
|
||||
lastFullSync: stats.lastFullSync,
|
||||
lastIncrementalSync: stats.lastIncrementalSync,
|
||||
nextIncrementalSync,
|
||||
incrementalInterval: this.incrementalInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const syncEngine = new SyncEngine();
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ReferenceValue {
|
||||
remarks?: string; // Remarks attribute for Governance Model
|
||||
application?: string; // Application attribute for Governance Model
|
||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
|
||||
}
|
||||
|
||||
// Application list item (summary view)
|
||||
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
|
||||
governanceModel: ReferenceValue | null;
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationSubteam: ReferenceValue | null;
|
||||
applicationTeam: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
|
||||
complexityFactor: ReferenceValue | null;
|
||||
numberOfUsers: ReferenceValue | null;
|
||||
governanceModel: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationSubteam: ReferenceValue | null;
|
||||
applicationTeam: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
@@ -92,7 +95,7 @@ export interface SearchFilters {
|
||||
governanceModel?: 'all' | 'filled' | 'empty';
|
||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||
complexityFactor?: 'all' | 'filled' | 'empty';
|
||||
applicationCluster?: 'all' | 'filled' | 'empty';
|
||||
applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name
|
||||
applicationType?: 'all' | 'filled' | 'empty';
|
||||
organisation?: string;
|
||||
hostingType?: string;
|
||||
@@ -168,7 +171,8 @@ export interface PendingChanges {
|
||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
}
|
||||
|
||||
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationSubteams: ReferenceValue[];
|
||||
applicationTeams: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
@@ -297,6 +302,31 @@ export interface PlatformWithWorkloads {
|
||||
totalEffort: number; // platformEffort + workloadsEffort
|
||||
}
|
||||
|
||||
// Subteam level in team dashboard hierarchy
|
||||
export interface TeamDashboardSubteam {
|
||||
subteam: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
}
|
||||
|
||||
// Team level in team dashboard hierarchy (contains subteams)
|
||||
export interface TeamDashboardTeam {
|
||||
team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf"
|
||||
subteams: TeamDashboardSubteam[];
|
||||
// Aggregated KPIs (sum of all subteams)
|
||||
totalEffort: number;
|
||||
minEffort: number;
|
||||
maxEffort: number;
|
||||
applicationCount: number;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
}
|
||||
|
||||
// Legacy type for backward compatibility (deprecated)
|
||||
export interface TeamDashboardCluster {
|
||||
cluster: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
@@ -309,16 +339,8 @@ export interface TeamDashboardCluster {
|
||||
}
|
||||
|
||||
export interface TeamDashboardData {
|
||||
clusters: TeamDashboardCluster[];
|
||||
unassigned: {
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
};
|
||||
teams: TeamDashboardTeam[];
|
||||
unassigned: TeamDashboardSubteam; // Apps without team assignment
|
||||
}
|
||||
|
||||
// Jira Assets API types
|
||||
@@ -347,6 +369,9 @@ export interface JiraAssetsAttribute {
|
||||
objectKey: string;
|
||||
label: string;
|
||||
};
|
||||
status?: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -364,7 +389,8 @@ export interface ApplicationUpdateRequest {
|
||||
complexityFactor?: string;
|
||||
numberOfUsers?: string;
|
||||
governanceModel?: string;
|
||||
applicationCluster?: string;
|
||||
applicationSubteam?: string;
|
||||
applicationTeam?: string;
|
||||
applicationType?: string;
|
||||
hostingType?: string;
|
||||
businessImpactAnalyse?: string;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo-zuyderland.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ZiRA Classificatie Tool - Zuyderland</title>
|
||||
<title>CMDB Analyse Tool - Zuyderland</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1
frontend/public/logo-zuyderland.svg
Normal file
1
frontend/public/logo-zuyderland.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><style>.cls-1{fill:#7bcaef;}.cls-2{fill:#3b6d89;}</style></defs><g id="Laag_2" data-name="Laag 2"><g id="Logo_beeldmerk_A" data-name="Logo beeldmerk A"><g id="zl-beeldmerk-a"><polygon id="Binnenkant" class="cls-1" points="400 200 400 0 200 0 200 200 0 400 200 400 200 600 400 600 400 400 600 200 400 200"/><g id="Buitenkant"><path class="cls-2" d="M200,200H80A80,80,0,0,1,0,120V80A80,80,0,0,1,80,0H200Z"/><path class="cls-2" d="M600,200H400V0H520a80,80,0,0,1,80,80Z"/><path class="cls-2" d="M200,600H80A80,80,0,0,1,0,520V400H200Z"/><path class="cls-2" d="M520,600H400V400H520a80,80,0,0,1,80,80v40A80,80,0,0,1,520,600Z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -1,14 +1,112 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import SearchDashboard from './components/SearchDashboard';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ApplicationList from './components/ApplicationList';
|
||||
import ApplicationDetail from './components/ApplicationDetail';
|
||||
import ApplicationInfo from './components/ApplicationInfo';
|
||||
import GovernanceModelHelper from './components/GovernanceModelHelper';
|
||||
import TeamDashboard from './components/TeamDashboard';
|
||||
import ConfigurationV25 from './components/ConfigurationV25';
|
||||
import ReportsDashboard from './components/ReportsDashboard';
|
||||
import GovernanceAnalysis from './components/GovernanceAnalysis';
|
||||
import DataModelDashboard from './components/DataModelDashboard';
|
||||
import FTECalculator from './components/FTECalculator';
|
||||
import Login from './components/Login';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
// Redirect component for old app-components/overview/:id paths
|
||||
function RedirectToApplicationEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <Navigate to={`/application/${id}/edit`} replace />;
|
||||
}
|
||||
|
||||
// Dropdown menu item type
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface NavDropdown {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
items: NavItem[];
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
// Dropdown component for navigation
|
||||
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Close dropdown on route change
|
||||
useEffect(() => {
|
||||
setIsOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{dropdown.label}
|
||||
<svg
|
||||
className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
{dropdown.items.map((item) => {
|
||||
const itemActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
'block px-4 py-2 text-sm transition-colors',
|
||||
itemActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu() {
|
||||
const { user, authMethod, logout } = useAuthStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -84,12 +182,32 @@ function UserMenu() {
|
||||
function AppContent() {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', exact: true },
|
||||
{ path: '/applications', label: 'Applicaties', exact: false },
|
||||
{ path: '/teams', label: 'Team-indeling', exact: true },
|
||||
{ path: '/configuration', label: 'FTE Config v25', exact: true },
|
||||
];
|
||||
// Navigation structure
|
||||
const appComponentsDropdown: NavDropdown = {
|
||||
label: 'Application Component',
|
||||
basePath: '/application',
|
||||
items: [
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
const reportsDropdown: NavDropdown = {
|
||||
label: 'Rapporten',
|
||||
basePath: '/reports',
|
||||
items: [
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
|
||||
const isReportsActive = location.pathname.startsWith('/reports');
|
||||
const isDashboardActive = location.pathname === '/';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -98,39 +216,35 @@ function AppContent() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">ZiRA</span>
|
||||
</div>
|
||||
<Link to="/" className="flex items-center space-x-3">
|
||||
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Classificatie Tool
|
||||
Analyse Tool
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex space-x-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<nav className="hidden md:flex items-center space-x-1">
|
||||
{/* Dashboard (Search) */}
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
to="/"
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
isDashboardActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
Dashboard
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Application Component Dropdown */}
|
||||
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
|
||||
|
||||
{/* Reports Dropdown */}
|
||||
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -142,9 +256,30 @@ function AppContent() {
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/applications" element={<ApplicationList />} />
|
||||
<Route path="/applications/:id" element={<ApplicationDetail />} />
|
||||
{/* Main Dashboard (Search) */}
|
||||
<Route path="/" element={<SearchDashboard />} />
|
||||
|
||||
{/* Application routes (new structure) */}
|
||||
<Route path="/application/overview" element={<ApplicationList />} />
|
||||
<Route path="/application/fte-calculator" element={<FTECalculator />} />
|
||||
<Route path="/application/:id" element={<ApplicationInfo />} />
|
||||
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
|
||||
|
||||
{/* Application Component routes */}
|
||||
<Route path="/app-components" element={<Dashboard />} />
|
||||
<Route path="/app-components/fte-config" element={<ConfigurationV25 />} />
|
||||
|
||||
{/* Reports routes */}
|
||||
<Route path="/reports" element={<ReportsDashboard />} />
|
||||
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
|
||||
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
|
||||
<Route path="/reports/data-model" element={<DataModelDashboard />} />
|
||||
|
||||
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
|
||||
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
|
||||
<Route path="/app-components/overview/:id" element={<RedirectToApplicationEdit />} />
|
||||
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
|
||||
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
|
||||
<Route path="/teams" element={<TeamDashboard />} />
|
||||
<Route path="/configuration" element={<ConfigurationV25 />} />
|
||||
</Routes>
|
||||
@@ -178,12 +313,12 @@ function App() {
|
||||
}
|
||||
|
||||
// Show login if OAuth is enabled and not authenticated
|
||||
if (config?.oauthEnabled && !isAuthenticated) {
|
||||
if (config?.authMethod === 'oauth' && !isAuthenticated) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// Show login if nothing is configured
|
||||
if (!config?.oauthEnabled && !config?.serviceAccountEnabled) {
|
||||
if (config?.authMethod === 'none') {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
|
||||
620
frontend/src/components/ApplicationInfo.tsx
Normal file
620
frontend/src/components/ApplicationInfo.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
getApplicationById,
|
||||
getConfig,
|
||||
getRelatedObjects,
|
||||
RelatedObject,
|
||||
} from '../services/api';
|
||||
import { StatusBadge, BusinessImportanceBadge } from './ApplicationList';
|
||||
import { EffortDisplay } from './EffortDisplay';
|
||||
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
|
||||
import type { ApplicationDetails } from '../types';
|
||||
|
||||
// Related objects configuration
|
||||
interface RelatedObjectConfig {
|
||||
objectType: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
attributes: string[];
|
||||
columns: { key: string; label: string; isName?: boolean }[];
|
||||
colorScheme: 'blue' | 'green' | 'orange' | 'purple' | 'cyan';
|
||||
}
|
||||
|
||||
const RELATED_OBJECTS_CONFIG: RelatedObjectConfig[] = [
|
||||
{
|
||||
objectType: 'Server',
|
||||
title: 'Servers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
attributes: ['Name', 'Status', 'State'],
|
||||
columns: [
|
||||
{ key: 'Name', label: 'Naam', isName: true },
|
||||
{ key: 'Status', label: 'Status' },
|
||||
{ key: 'State', label: 'State' },
|
||||
],
|
||||
colorScheme: 'blue',
|
||||
},
|
||||
{
|
||||
objectType: 'AzureSubscription',
|
||||
title: 'Azure Subscriptions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
),
|
||||
attributes: ['Name', 'Status'],
|
||||
columns: [
|
||||
{ key: 'Name', label: 'Naam', isName: true },
|
||||
{ key: 'Status', label: 'Status' },
|
||||
],
|
||||
colorScheme: 'cyan',
|
||||
},
|
||||
{
|
||||
objectType: 'Certificate',
|
||||
title: 'Certificaten',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'],
|
||||
columns: [
|
||||
{ key: 'Name', label: 'Naam', isName: true },
|
||||
{ key: 'Status', label: 'Status' },
|
||||
{ key: 'Expiry Date', label: 'Vervaldatum' },
|
||||
{ key: 'Autorenew', label: 'Auto-renew' },
|
||||
{ key: 'Certificate Owner', label: 'Eigenaar' },
|
||||
],
|
||||
colorScheme: 'orange',
|
||||
},
|
||||
{
|
||||
objectType: 'Connection',
|
||||
title: 'Connecties',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'],
|
||||
columns: [
|
||||
{ key: 'Name', label: 'Naam', isName: true },
|
||||
{ key: 'Source', label: 'Bron' },
|
||||
{ key: 'Target', label: 'Doel' },
|
||||
{ key: 'Type', label: 'Type' },
|
||||
{ key: 'Protocol', label: 'Protocol' },
|
||||
],
|
||||
colorScheme: 'purple',
|
||||
},
|
||||
];
|
||||
|
||||
const COLOR_SCHEMES = {
|
||||
blue: {
|
||||
header: 'bg-blue-50',
|
||||
icon: 'text-blue-600',
|
||||
badge: 'bg-blue-100 text-blue-700',
|
||||
border: 'border-blue-200',
|
||||
},
|
||||
green: {
|
||||
header: 'bg-green-50',
|
||||
icon: 'text-green-600',
|
||||
badge: 'bg-green-100 text-green-700',
|
||||
border: 'border-green-200',
|
||||
},
|
||||
orange: {
|
||||
header: 'bg-orange-50',
|
||||
icon: 'text-orange-600',
|
||||
badge: 'bg-orange-100 text-orange-700',
|
||||
border: 'border-orange-200',
|
||||
},
|
||||
purple: {
|
||||
header: 'bg-purple-50',
|
||||
icon: 'text-purple-600',
|
||||
badge: 'bg-purple-100 text-purple-700',
|
||||
border: 'border-purple-200',
|
||||
},
|
||||
cyan: {
|
||||
header: 'bg-cyan-50',
|
||||
icon: 'text-cyan-600',
|
||||
badge: 'bg-cyan-100 text-cyan-700',
|
||||
border: 'border-cyan-200',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ApplicationInfo() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const [application, setApplication] = useState<ApplicationDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jiraHost, setJiraHost] = useState<string>('');
|
||||
|
||||
// Use centralized effort calculation hook
|
||||
const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({
|
||||
application,
|
||||
});
|
||||
|
||||
// Related objects state
|
||||
const [relatedObjects, setRelatedObjects] = useState<Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>>(new Map());
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['Server', 'Certificate'])); // Default expanded
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [app, config] = await Promise.all([
|
||||
getApplicationById(id),
|
||||
getConfig(),
|
||||
]);
|
||||
|
||||
setApplication(app);
|
||||
setJiraHost(config.jiraHost);
|
||||
// Note: Effort calculation is handled automatically by useEffortCalculation hook
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load application');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
document.title = `${application.name} | Zuyderland CMDB`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'Zuyderland CMDB';
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
// Fetch related objects when application is loaded
|
||||
useEffect(() => {
|
||||
if (!id || !application) return;
|
||||
|
||||
// Initialize loading state for all object types
|
||||
const initialState = new Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>();
|
||||
RELATED_OBJECTS_CONFIG.forEach(config => {
|
||||
initialState.set(config.objectType, { objects: [], loading: true, error: null });
|
||||
});
|
||||
setRelatedObjects(initialState);
|
||||
|
||||
// Fetch each object type in parallel
|
||||
RELATED_OBJECTS_CONFIG.forEach(async (config) => {
|
||||
try {
|
||||
const result = await getRelatedObjects(id, config.objectType, config.attributes);
|
||||
setRelatedObjects(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null });
|
||||
return newMap;
|
||||
});
|
||||
} catch (err) {
|
||||
setRelatedObjects(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(config.objectType, {
|
||||
objects: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load'
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [id, application]);
|
||||
|
||||
const toggleSection = (objectType: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(objectType)) {
|
||||
newSet.delete(objectType);
|
||||
} else {
|
||||
newSet.add(objectType);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !application) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error || 'Application not found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back navigation */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Link
|
||||
to="/application/overview"
|
||||
className="flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Terug naar overzicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header with application name and quick actions */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{application.name}</h1>
|
||||
<StatusBadge status={application.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{application.key}</span>
|
||||
{application.applicationType && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded">
|
||||
{application.applicationType.name}
|
||||
</span>
|
||||
)}
|
||||
{application.hostingType && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded">
|
||||
{application.hostingType.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jiraHost && application.key && (
|
||||
<a
|
||||
href={`${jiraHost}/secure/insight/assets/${application.key}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Open in Jira
|
||||
</a>
|
||||
)}
|
||||
<Link
|
||||
to={`/application/${id}/edit`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Bewerken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{application.description && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-gray-600">{application.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main info grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left column - Basic info */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">Basis informatie</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<InfoRow label="Search Reference" value={application.searchReference} />
|
||||
<InfoRow label="Leverancier/Product" value={application.supplierProduct} />
|
||||
<InfoRow label="Organisatie" value={application.organisation} />
|
||||
{application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Technische Architectuur</label>
|
||||
<a
|
||||
href={`${application.technischeArchitectuur}${application.technischeArchitectuur.includes('?') ? '&' : '?'}csf=1&web=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Document openen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Business info */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">Business informatie</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Business Importance</label>
|
||||
<BusinessImportanceBadge importance={application.businessImportance} />
|
||||
</div>
|
||||
<InfoRow label="Business Impact Analyse" value={application.businessImpactAnalyse?.name} />
|
||||
<InfoRow label="Business Owner" value={application.businessOwner} />
|
||||
<InfoRow label="System Owner" value={application.systemOwner} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Governance */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">Governance & Management</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<InfoRow label="Regiemodel" value={application.governanceModel?.name} />
|
||||
<InfoRow label="Subteam" value={application.applicationSubteam?.name} />
|
||||
<InfoRow label="Team" value={application.applicationTeam?.name} />
|
||||
<InfoRow label="Application Management - Hosting" value={application.applicationManagementHosting?.name} />
|
||||
<InfoRow label="Application Management - TAM" value={application.applicationManagementTAM?.name} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">Contactpersonen</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<InfoRow label="Functioneel Beheer" value={application.functionalApplicationManagement} />
|
||||
<InfoRow label="Technisch Applicatiebeheer" value={application.technicalApplicationManagement} />
|
||||
<InfoRow
|
||||
label="Contactpersonen TAB"
|
||||
value={(() => {
|
||||
const primary = application.technicalApplicationManagementPrimary?.trim();
|
||||
const secondary = application.technicalApplicationManagementSecondary?.trim();
|
||||
const parts = [];
|
||||
if (primary) parts.push(primary);
|
||||
if (secondary) parts.push(secondary);
|
||||
return parts.length > 0 ? parts.join(', ') : undefined;
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification section */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">Classificatie</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<InfoRow label="Dynamics Factor" value={application.dynamicsFactor?.name} />
|
||||
<InfoRow label="Complexity Factor" value={application.complexityFactor?.name} />
|
||||
<InfoRow label="Number of Users" value={application.numberOfUsers?.name} />
|
||||
</div>
|
||||
|
||||
{/* FTE - Benodigde inspanning applicatiemanagement */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-100">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Benodigde inspanning applicatiemanagement
|
||||
</label>
|
||||
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
|
||||
<EffortDisplay
|
||||
effectiveFte={getEffectiveFte(calculatedFte, application.overrideFTE, application.requiredEffortApplicationManagement)}
|
||||
calculatedFte={calculatedFte ?? application.requiredEffortApplicationManagement ?? null}
|
||||
overrideFte={application.overrideFTE ?? null}
|
||||
breakdown={effortBreakdown}
|
||||
isPreview={false}
|
||||
showDetails={true}
|
||||
showOverrideInput={false}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Functions */}
|
||||
{application.applicationFunctions && application.applicationFunctions.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Applicatiefuncties ({application.applicationFunctions.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{application.applicationFunctions.map((func, index) => (
|
||||
<span
|
||||
key={func.objectId || index}
|
||||
className={clsx(
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm',
|
||||
index === 0
|
||||
? 'bg-blue-100 text-blue-800 font-medium'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-xs mr-2 opacity-70">{func.key}</span>
|
||||
{func.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Objects Sections */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Gerelateerde objecten</h2>
|
||||
|
||||
{RELATED_OBJECTS_CONFIG.map((config) => {
|
||||
const data = relatedObjects.get(config.objectType);
|
||||
const isExpanded = expandedSections.has(config.objectType);
|
||||
const colors = COLOR_SCHEMES[config.colorScheme];
|
||||
const objects = data?.objects || [];
|
||||
const count = objects.length;
|
||||
const isLoading = data?.loading ?? true;
|
||||
|
||||
return (
|
||||
<div key={config.objectType} className={clsx('bg-white rounded-lg border', colors.border)}>
|
||||
{/* Header - clickable to expand/collapse */}
|
||||
<button
|
||||
onClick={() => toggleSection(config.objectType)}
|
||||
className={clsx(
|
||||
'w-full px-6 py-4 flex items-center justify-between',
|
||||
colors.header,
|
||||
'hover:opacity-90 transition-opacity'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={colors.icon}>{config.icon}</span>
|
||||
<h3 className="text-lg font-medium text-gray-900">{config.title}</h3>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-600" />
|
||||
) : (
|
||||
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium', colors.badge)}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 transition-transform',
|
||||
isExpanded && 'transform rotate-180'
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-300 border-t-blue-600 mx-auto" />
|
||||
<p className="text-gray-500 text-sm mt-2">Laden...</p>
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="p-6 text-center text-red-600">
|
||||
<p className="text-sm">{data.error}</p>
|
||||
</div>
|
||||
) : count === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
<p className="text-sm">Geen {config.title.toLowerCase()} gevonden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{config.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{objects.map((obj) => (
|
||||
<tr key={obj.id} className="hover:bg-gray-50">
|
||||
{config.columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-900">
|
||||
{col.isName && jiraHost ? (
|
||||
<a
|
||||
href={`${jiraHost}/secure/insight/assets/${obj.key}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{obj.attributes[col.key] || obj.name || '-'}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
obj.attributes[col.key] || '-'
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Call to action */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-blue-900">Classificatie aanpassen?</h3>
|
||||
<p className="text-blue-700 text-sm mt-1">
|
||||
Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/application/${id}/edit`}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Bewerken
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for displaying info rows
|
||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">{label}</label>
|
||||
<p className="text-gray-900">{value || '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default function ApplicationList() {
|
||||
setStatuses,
|
||||
setApplicationFunction,
|
||||
setGovernanceModel,
|
||||
setApplicationCluster,
|
||||
setApplicationSubteam,
|
||||
setApplicationType,
|
||||
setOrganisation,
|
||||
setHostingType,
|
||||
@@ -45,6 +45,7 @@ export default function ApplicationList() {
|
||||
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
|
||||
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
||||
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
|
||||
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
// Sync URL params with store on mount
|
||||
@@ -98,6 +99,7 @@ export default function ApplicationList() {
|
||||
setOrganisations(data.organisations);
|
||||
setHostingTypes(data.hostingTypes);
|
||||
setBusinessImportanceOptions(data.businessImportance || []);
|
||||
setApplicationSubteams(data.applicationSubteams || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data', err);
|
||||
}
|
||||
@@ -126,7 +128,7 @@ export default function ApplicationList() {
|
||||
// Only navigate programmatically for regular clicks
|
||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
||||
event.preventDefault();
|
||||
navigate(`/applications/${app.id}`);
|
||||
navigate(`/application/${app.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,26 +259,6 @@ export default function ApplicationList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Application Cluster</label>
|
||||
<div className="space-y-1">
|
||||
{(['all', 'filled', 'empty'] as const).map((value) => (
|
||||
<label key={value} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="applicationCluster"
|
||||
checked={filters.applicationCluster === value}
|
||||
onChange={() => setApplicationCluster(value)}
|
||||
className="border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Application Type</label>
|
||||
<div className="space-y-1">
|
||||
@@ -347,6 +329,23 @@ export default function ApplicationList() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-2">Subteam</label>
|
||||
<select
|
||||
value={filters.applicationSubteam || 'all'}
|
||||
onChange={(e) => setApplicationSubteam(e.target.value as 'all' | 'empty' | string)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="empty">Leeg</option>
|
||||
{applicationSubteams.map((subteam) => (
|
||||
<option key={subteam.objectId} value={subteam.name}>
|
||||
{subteam.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,7 +404,7 @@ export default function ApplicationList() {
|
||||
>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3 text-sm text-gray-500"
|
||||
>
|
||||
@@ -414,7 +413,7 @@ export default function ApplicationList() {
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
@@ -426,7 +425,7 @@ export default function ApplicationList() {
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
@@ -435,7 +434,7 @@ export default function ApplicationList() {
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
@@ -460,7 +459,7 @@ export default function ApplicationList() {
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3"
|
||||
>
|
||||
@@ -477,7 +476,7 @@ export default function ApplicationList() {
|
||||
</td>
|
||||
<td className="py-0">
|
||||
<Link
|
||||
to={`/applications/${app.id}`}
|
||||
to={`/application/${app.id}`}
|
||||
onClick={(e) => handleRowClick(app, index, e)}
|
||||
className="block px-4 py-3 text-sm text-gray-900"
|
||||
>
|
||||
@@ -502,7 +501,7 @@ export default function ApplicationList() {
|
||||
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||
{currentPage > 1 ? (
|
||||
<Link
|
||||
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`}
|
||||
to={currentPage === 2 ? '/application/overview' : `/application/overview?page=${currentPage - 1}`}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
@@ -518,7 +517,7 @@ export default function ApplicationList() {
|
||||
</span>
|
||||
{currentPage < result.totalPages ? (
|
||||
<Link
|
||||
to={`/applications?page=${currentPage + 1}`}
|
||||
to={`/application/overview?page=${currentPage + 1}`}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
|
||||
180
frontend/src/components/CacheStatusIndicator.tsx
Normal file
180
frontend/src/components/CacheStatusIndicator.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api';
|
||||
|
||||
interface CacheStatusIndicatorProps {
|
||||
/** Show compact version (just icon + time) */
|
||||
compact?: boolean;
|
||||
/** Auto-refresh interval in ms (default: 30000) */
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
|
||||
compact = false,
|
||||
refreshInterval = 30000,
|
||||
}) => {
|
||||
const [status, setStatus] = useState<CacheStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const data = await getCacheStatus();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Kon status niet ophalen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (syncing) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
await triggerSync();
|
||||
// Refetch status after a short delay
|
||||
setTimeout(fetchStatus, 1000);
|
||||
} catch (err) {
|
||||
setError('Sync mislukt');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-zinc-500`}>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{!compact && <span>Laden...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !status) {
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-red-400`}>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{!compact && <span>{error || 'Geen data'}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastSync = status.cache.lastIncrementalSync;
|
||||
const ageMinutes = lastSync
|
||||
? Math.floor((Date.now() - new Date(lastSync).getTime()) / 60000)
|
||||
: null;
|
||||
|
||||
const isWarm = status.cache.isWarm;
|
||||
const isSyncing = status.sync.isSyncing || syncing;
|
||||
|
||||
// Status color
|
||||
const statusColor = !isWarm
|
||||
? 'text-amber-400'
|
||||
: ageMinutes !== null && ageMinutes > 5
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400';
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs ${statusColor} hover:bg-zinc-800 transition-colors disabled:opacity-50`}
|
||||
title={`Cache: ${status.cache.totalObjects} objecten, laatst gesynchroniseerd ${ageMinutes !== null ? `${ageMinutes} min geleden` : 'onbekend'}`}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<svg className="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{ageMinutes !== null ? `${ageMinutes}m` : '?'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-800/50 rounded-lg border border-zinc-700 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-zinc-300 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
Cache Status
|
||||
</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${isWarm ? 'bg-emerald-500/20 text-emerald-400' : 'bg-amber-500/20 text-amber-400'}`}>
|
||||
{isWarm ? 'Actief' : 'Cold Start'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Objecten in cache:</span>
|
||||
<span className="text-zinc-200 font-mono">{status.cache.totalObjects.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Relaties:</span>
|
||||
<span className="text-zinc-200 font-mono">{status.cache.totalRelations.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-zinc-500">Laatst gesynchroniseerd:</span>
|
||||
<span className={`font-mono ${statusColor}`}>
|
||||
{ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'}
|
||||
</span>
|
||||
</div>
|
||||
{status.sync.isSyncing && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Synchronisatie bezig...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 text-sm font-medium"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Synchroniseren...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Nu synchroniseren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheStatusIndicator;
|
||||
|
||||
162
frontend/src/components/ConflictDialog.tsx
Normal file
162
frontend/src/components/ConflictDialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import type { ConflictError } from '../services/api';
|
||||
|
||||
interface ConflictDialogProps {
|
||||
conflict: ConflictError;
|
||||
onForceOverwrite: () => void;
|
||||
onDiscard: () => void;
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ConflictDialog: React.FC<ConflictDialogProps> = ({
|
||||
conflict,
|
||||
onForceOverwrite,
|
||||
onDiscard,
|
||||
onClose,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-zinc-900 rounded-xl shadow-2xl border border-zinc-700 max-w-2xl w-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-zinc-700 bg-amber-950/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-amber-400">
|
||||
Wijzigingsconflict Gedetecteerd
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{conflict.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5">
|
||||
{conflict.warning && (
|
||||
<p className="text-sm text-zinc-400 mb-4">{conflict.warning}</p>
|
||||
)}
|
||||
|
||||
{conflict.conflicts && conflict.conflicts.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm text-zinc-300 mb-4">
|
||||
De volgende velden zijn gewijzigd terwijl u aan het bewerken was:
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg overflow-hidden border border-zinc-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-zinc-800">
|
||||
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Veld</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Uw waarde</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Waarde in Jira</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-700">
|
||||
{conflict.conflicts.map((c, index) => (
|
||||
<tr key={index} className="bg-zinc-800/50">
|
||||
<td className="px-4 py-3 font-medium text-zinc-200">
|
||||
{c.field}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded bg-blue-500/20 text-blue-400 text-xs font-mono">
|
||||
{formatValue(c.proposedValue)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded bg-amber-500/20 text-amber-400 text-xs font-mono">
|
||||
{formatValue(c.jiraValue)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info box */}
|
||||
<div className="mt-5 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700">
|
||||
<p className="text-sm text-zinc-400">
|
||||
<span className="font-medium text-zinc-300">Wat wilt u doen?</span>
|
||||
<br />
|
||||
• <strong>Doorvoeren:</strong> Uw wijzigingen overschrijven de huidige waarden in Jira
|
||||
<br />
|
||||
• <strong>Verwerpen:</strong> Uw wijzigingen worden weggegooid en de huidige data wordt geladen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-zinc-700 bg-zinc-800/50 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
Verwerpen en verversen
|
||||
</button>
|
||||
<button
|
||||
onClick={onForceOverwrite}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-500 transition-colors disabled:opacity-50 font-medium flex items-center gap-2"
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Mijn wijzigingen doorvoeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '(leeg)';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '(leeg)';
|
||||
return value.map(v => {
|
||||
if (typeof v === 'object' && v && 'label' in v) {
|
||||
return (v as { label: string }).label;
|
||||
}
|
||||
return String(v);
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value && 'label' in value) {
|
||||
return (value as { label: string }).label;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Ja' : 'Nee';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default ConflictDialog;
|
||||
|
||||
@@ -14,19 +14,32 @@ interface CustomSelectProps {
|
||||
// Helper function to get display text for an option
|
||||
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
|
||||
if (showRemarks) {
|
||||
// Concatenate description and remarks with ". "
|
||||
// Concatenate description, remarks, and indicators with ". "
|
||||
const parts: string[] = [];
|
||||
if (option.description) parts.push(option.description);
|
||||
if (option.remarks) parts.push(option.remarks);
|
||||
if (option.indicators) parts.push(option.indicators);
|
||||
return parts.length > 0 ? parts.join('. ') : null;
|
||||
}
|
||||
if (showSummary && option.summary) {
|
||||
// Include indicators if available
|
||||
if (option.indicators) {
|
||||
return `${option.summary}. ${option.indicators}`;
|
||||
}
|
||||
return option.summary;
|
||||
}
|
||||
if (showSummary && !option.summary && option.description) {
|
||||
// Include indicators if available
|
||||
if (option.indicators) {
|
||||
return `${option.description}. ${option.indicators}`;
|
||||
}
|
||||
return option.description;
|
||||
}
|
||||
if (!showSummary && option.description) {
|
||||
// Include indicators if available
|
||||
if (option.indicators) {
|
||||
return `${option.description}. ${option.indicators}`;
|
||||
}
|
||||
return option.description;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getDashboardStats, getRecentClassifications } from '../services/api';
|
||||
import type { DashboardStats, ClassificationResult } from '../types';
|
||||
import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
|
||||
import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
|
||||
|
||||
// Extended type to include stale indicator from API
|
||||
interface DashboardStatsWithMeta extends DashboardStats {
|
||||
@@ -12,9 +12,36 @@ interface DashboardStatsWithMeta extends DashboardStats {
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
|
||||
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(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) {
|
||||
@@ -25,12 +52,14 @@ export default function Dashboard() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [statsData, recentData] = await Promise.all([
|
||||
const [statsData, recentData, refData] = await Promise.all([
|
||||
getDashboardStats(forceRefresh),
|
||||
getRecentClassifications(10),
|
||||
getReferenceData(),
|
||||
]);
|
||||
setStats(statsData as DashboardStatsWithMeta);
|
||||
setRecentClassifications(recentData);
|
||||
setGovernanceModels(refData.governanceModels);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
||||
} finally {
|
||||
@@ -104,7 +133,7 @@ export default function Dashboard() {
|
||||
</svg>
|
||||
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
|
||||
</button>
|
||||
<Link to="/applications" className="btn btn-primary">
|
||||
<Link to="/app-components/overview" className="btn btn-primary">
|
||||
Start classificeren
|
||||
</Link>
|
||||
</div>
|
||||
@@ -141,16 +170,18 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* Progress bars */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Classificatie voortgang
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* ICT Governance Model Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>ApplicationFunction ingevuld</span>
|
||||
<span>ICT Governance Model ingevuld</span>
|
||||
<span>
|
||||
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0}
|
||||
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0} ({progressPercentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||
@@ -160,6 +191,23 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ApplicationFunction Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>ApplicationFunction ingevuld</span>
|
||||
<span>
|
||||
{stats?.withApplicationFunction || 0} / {stats?.totalApplications || 0} ({stats?.applicationFunctionPercentage || 0}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-green-600 h-4 rounded-full transition-all duration-500"
|
||||
style={{ width: `${stats?.applicationFunctionPercentage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two column layout */}
|
||||
@@ -186,7 +234,7 @@ export default function Dashboard() {
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
width: `${(count / (stats?.totalAllApplications || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -200,37 +248,110 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Governance model distribution */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
<div className="card p-6" style={{ overflow: 'visible', position: 'relative', zIndex: hoveredGovModel ? 100 : 1 }}>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center gap-2">
|
||||
Verdeling per regiemodel
|
||||
<span className="text-gray-400 text-xs font-normal" title="Hover voor details">ⓘ</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2" style={{ overflow: 'visible' }}>
|
||||
{stats?.byGovernanceModel &&
|
||||
Object.entries(stats.byGovernanceModel)
|
||||
.sort((a, b) => {
|
||||
// Sort alphabetically, but put "Niet ingesteld" at the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{model}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
[
|
||||
...governanceModels
|
||||
.map(g => g.name)
|
||||
.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 (
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full"
|
||||
key={govModel}
|
||||
className="rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
position: 'relative'
|
||||
}}
|
||||
/>
|
||||
onMouseEnter={() => handleGovModelMouseEnter(govModel)}
|
||||
onMouseLeave={handleGovModelMouseLeave}
|
||||
>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
|
||||
{shortLabel}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
||||
<div className="text-xl font-bold leading-tight">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover popup */}
|
||||
{isHovered && govModel !== 'Niet ingesteld' && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-3 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left z-50"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
backgroundColor: '#ffffff'
|
||||
}}
|
||||
>
|
||||
{/* Arrow pointer */}
|
||||
<div
|
||||
className="absolute -top-2 left-5 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent"
|
||||
style={{ borderBottomColor: '#ffffff', filter: 'drop-shadow(0 -1px 1px rgba(0,0,0,0.1))' }}
|
||||
/>
|
||||
|
||||
{/* Header: Summary (Description) */}
|
||||
<div className="text-sm font-bold text-gray-900 mb-2">
|
||||
{govModelData?.summary || govModel}
|
||||
{govModelData?.description && (
|
||||
<span className="font-normal text-gray-500"> ({govModelData.description})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{govModelData?.remarks && (
|
||||
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.remarks}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application section */}
|
||||
{govModelData?.application && (
|
||||
<div className="border-t border-gray-100 pt-3 mt-3">
|
||||
<div className="text-xs font-bold text-gray-700 mb-1.5 uppercase tracking-wide">
|
||||
Toepassing
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.application}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Fallback message if no data */}
|
||||
{!govModelData && (
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
Geen aanvullende informatie beschikbaar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!stats?.byGovernanceModel ||
|
||||
Object.keys(stats.byGovernanceModel).length === 0) && (
|
||||
<p className="text-sm text-gray-500">Geen data beschikbaar</p>
|
||||
|
||||
648
frontend/src/components/DataModelDashboard.tsx
Normal file
648
frontend/src/components/DataModelDashboard.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { getSchema, triggerTypeSync, type SchemaResponse, type SchemaObjectTypeDefinition, type SchemaAttributeDefinition } from '../services/api';
|
||||
|
||||
// Attribute type badge colors
|
||||
const typeColors: Record<string, { bg: string; text: string }> = {
|
||||
text: { bg: 'bg-gray-100', text: 'text-gray-700' },
|
||||
integer: { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
float: { bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||
boolean: { bg: 'bg-purple-100', text: 'text-purple-700' },
|
||||
date: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
datetime: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
select: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
reference: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
url: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
|
||||
email: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
|
||||
textarea: { bg: 'bg-gray-100', text: 'text-gray-700' },
|
||||
user: { bg: 'bg-pink-100', text: 'text-pink-700' },
|
||||
status: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
unknown: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const colors = typeColors[type] || typeColors.unknown;
|
||||
return (
|
||||
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${colors.bg} ${colors.text}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinition; onReferenceClick?: (typeName: string) => void }) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-sm font-medium text-gray-900">
|
||||
{attr.name}
|
||||
{attr.isRequired && <span className="ml-1 text-red-500">*</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-gray-500 font-mono text-xs">
|
||||
{attr.fieldName}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<TypeBadge type={attr.type} />
|
||||
{attr.isMultiple && (
|
||||
<span className="ml-1 text-xs text-gray-400">[]</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{attr.referenceTypeName ? (
|
||||
<button
|
||||
onClick={() => onReferenceClick?.(attr.referenceTypeName!)}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
{attr.referenceTypeName}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate" title={attr.description}>
|
||||
{attr.description || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<div className="flex gap-1 justify-center">
|
||||
{attr.isSystem && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded" title="System attribute">
|
||||
SYS
|
||||
</span>
|
||||
)}
|
||||
{!attr.isEditable && !attr.isSystem && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded" title="Read-only">
|
||||
RO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectTypeCard({
|
||||
objectType,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onReferenceClick,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
refreshedCount,
|
||||
refreshError,
|
||||
}: {
|
||||
objectType: SchemaObjectTypeDefinition;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onReferenceClick: (typeName: string) => void;
|
||||
onRefresh: (typeName: string) => void;
|
||||
isRefreshing: boolean;
|
||||
refreshedCount?: number;
|
||||
refreshError?: string;
|
||||
}) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200 cursor-pointer hover:from-blue-100 hover:to-indigo-100 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{objectType.name}</h3>
|
||||
<p className="text-xs text-gray-500 font-mono">{objectType.typeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
{displayCount.toLocaleString()} objects
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{objectType.attributes.length} attributes
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{objectType.incomingLinks.length > 0 && (
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full" title="Incoming references">
|
||||
← {objectType.incomingLinks.length}
|
||||
</span>
|
||||
)}
|
||||
{objectType.outgoingLinks.length > 0 && (
|
||||
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full" title="Outgoing references">
|
||||
→ {objectType.outgoingLinks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh(objectType.typeName);
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isRefreshing
|
||||
? 'bg-blue-100 text-blue-400 cursor-not-allowed'
|
||||
: 'hover:bg-blue-100 text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
title={isRefreshing ? 'Bezig met verversen...' : 'Ververs alle objecten van dit type'}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{refreshError && (
|
||||
<div className="px-4 py-2 bg-red-50 border-b border-red-200">
|
||||
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{refreshError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{/* Links Section */}
|
||||
{(objectType.incomingLinks.length > 0 || objectType.outgoingLinks.length > 0) && (
|
||||
<div className="px-4 py-3 bg-gray-50">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Relationships
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Incoming Links */}
|
||||
{objectType.incomingLinks.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-green-700 mb-1 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 17l-5-5m0 0l5-5m-5 5h12" />
|
||||
</svg>
|
||||
Referenced by ({objectType.incomingLinks.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{objectType.incomingLinks.map((link, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onReferenceClick(link.fromType)}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{link.fromTypeName}
|
||||
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
|
||||
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outgoing Links */}
|
||||
{objectType.outgoingLinks.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-orange-700 mb-1 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
References ({objectType.outgoingLinks.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{objectType.outgoingLinks.map((link, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onReferenceClick(link.toType)}
|
||||
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{link.toTypeName}
|
||||
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
|
||||
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Field
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reference
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Flags
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{/* Reference attributes first */}
|
||||
{referenceAttrs.map((attr) => (
|
||||
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
|
||||
))}
|
||||
{/* Then non-reference attributes */}
|
||||
{nonReferenceAttrs.map((attr) => (
|
||||
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataModelDashboard() {
|
||||
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
|
||||
const [sortBy, setSortBy] = useState<'name' | 'objects' | 'attributes' | 'priority'>('priority');
|
||||
const [refreshingTypes, setRefreshingTypes] = useState<Set<string>>(new Set());
|
||||
const [refreshedCounts, setRefreshedCounts] = useState<Record<string, number>>({});
|
||||
const [refreshErrors, setRefreshErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadSchema();
|
||||
}, []);
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getSchema();
|
||||
setSchema(data);
|
||||
// Reset refreshed counts when schema is reloaded
|
||||
setRefreshedCounts({});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load schema');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshType = useCallback(async (typeName: string) => {
|
||||
// Add to refreshing set and clear any previous error
|
||||
setRefreshingTypes((prev) => new Set(prev).add(typeName));
|
||||
setRefreshErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[typeName];
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await triggerTypeSync(typeName);
|
||||
|
||||
// Update the count for this type
|
||||
if (result.stats?.objectsProcessed !== undefined) {
|
||||
setRefreshedCounts((prev) => ({
|
||||
...prev,
|
||||
[typeName]: result.stats.objectsProcessed,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to refresh ${typeName}:`, err);
|
||||
// Extract error message
|
||||
let errorMessage = 'Failed to sync object type';
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message;
|
||||
} else if (typeof err === 'object' && err !== null && 'error' in err) {
|
||||
errorMessage = String(err.error);
|
||||
}
|
||||
setRefreshErrors((prev) => ({
|
||||
...prev,
|
||||
[typeName]: errorMessage,
|
||||
}));
|
||||
} finally {
|
||||
// Remove from refreshing set
|
||||
setRefreshingTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(typeName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedTypes = useMemo(() => {
|
||||
if (!schema) return [];
|
||||
|
||||
let types = Object.values(schema.objectTypes);
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
types = types.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.typeName.toLowerCase().includes(query) ||
|
||||
t.attributes.some(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(query) ||
|
||||
a.fieldName.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
types.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'objects':
|
||||
types.sort((a, b) => b.objectCount - a.objectCount);
|
||||
break;
|
||||
case 'attributes':
|
||||
types.sort((a, b) => b.attributes.length - a.attributes.length);
|
||||
break;
|
||||
case 'priority':
|
||||
types.sort((a, b) => a.syncPriority - b.syncPriority || a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [schema, searchQuery, sortBy]);
|
||||
|
||||
const toggleExpanded = (typeName: string) => {
|
||||
setExpandedTypes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(typeName)) {
|
||||
next.delete(typeName);
|
||||
} else {
|
||||
next.add(typeName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReferenceClick = (typeName: string) => {
|
||||
// Expand the referenced type and scroll to it
|
||||
setExpandedTypes((prev) => new Set(prev).add(typeName));
|
||||
|
||||
// Find and scroll to the element
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`object-type-${typeName}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedTypes(new Set(filteredAndSortedTypes.map((t) => t.typeName)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedTypes(new Set());
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-gray-500">Laden van datamodel...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-red-800 mb-2">Fout bij laden</h3>
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadSchema}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schema) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Datamodel</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Overzicht van alle object types, attributen en relaties in het Jira Assets schema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{schema.metadata.objectTypeCount}</div>
|
||||
<div className="text-sm text-gray-500">Object Types</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{schema.metadata.totalAttributes}</div>
|
||||
<div className="text-sm text-gray-500">Attributen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{Object.values(schema.objectTypes).reduce((sum, t) => sum + t.outgoingLinks.length, 0)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Relaties</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{new Date(schema.metadata.generatedAt).toLocaleDateString('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Gegenereerd</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Zoek object types of attributen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Sorteren op:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="priority">Sync Prioriteit</option>
|
||||
<option value="name">Naam</option>
|
||||
<option value="objects">Aantal objecten</option>
|
||||
<option value="attributes">Aantal attributen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Alles uitklappen
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Alles inklappen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
{filteredAndSortedTypes.length} object types
|
||||
{searchQuery && ` gevonden voor "${searchQuery}"`}
|
||||
</div>
|
||||
|
||||
{/* Object Types List */}
|
||||
<div className="space-y-4">
|
||||
{filteredAndSortedTypes.map((objectType) => (
|
||||
<div key={objectType.typeName} id={`object-type-${objectType.typeName}`}>
|
||||
<ObjectTypeCard
|
||||
objectType={objectType}
|
||||
isExpanded={expandedTypes.has(objectType.typeName)}
|
||||
onToggle={() => toggleExpanded(objectType.typeName)}
|
||||
onReferenceClick={handleReferenceClick}
|
||||
onRefresh={handleRefreshType}
|
||||
isRefreshing={refreshingTypes.has(objectType.typeName)}
|
||||
refreshedCount={refreshedCounts[objectType.typeName]}
|
||||
refreshError={refreshErrors[objectType.typeName]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredAndSortedTypes.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Geen object types gevonden voor "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/EffortDisplay.tsx
Normal file
258
frontend/src/components/EffortDisplay.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* EffortDisplay - Shared component for displaying FTE effort calculations
|
||||
*
|
||||
* Used in:
|
||||
* - ApplicationInfo.tsx (detail page)
|
||||
* - GovernanceModelHelper.tsx (governance helper page)
|
||||
*/
|
||||
|
||||
import type { EffortCalculationBreakdown } from '../types';
|
||||
|
||||
export interface EffortDisplayProps {
|
||||
/** The effective FTE value (after override if applicable) */
|
||||
effectiveFte: number | null;
|
||||
/** The calculated FTE value (before override) */
|
||||
calculatedFte?: number | null;
|
||||
/** Override FTE value if set */
|
||||
overrideFte?: number | null;
|
||||
/** Full breakdown from effort calculation */
|
||||
breakdown?: EffortCalculationBreakdown | null;
|
||||
/** Whether this is a preview (unsaved changes) */
|
||||
isPreview?: boolean;
|
||||
/** Show full details (expanded view) */
|
||||
showDetails?: boolean;
|
||||
/** Show override input field */
|
||||
showOverrideInput?: boolean;
|
||||
/** Callback when override value changes */
|
||||
onOverrideChange?: (value: number | null) => void;
|
||||
}
|
||||
|
||||
// Display-only constants (for showing the formula, NOT for calculation)
|
||||
// The actual calculation happens in backend/src/services/effortCalculation.ts
|
||||
const HOURS_PER_WEEK_DISPLAY = 36;
|
||||
const WORK_WEEKS_PER_YEAR_DISPLAY = 46;
|
||||
const DECLARABLE_PERCENTAGE_DISPLAY = 0.75;
|
||||
|
||||
export function EffortDisplay({
|
||||
effectiveFte,
|
||||
calculatedFte,
|
||||
overrideFte,
|
||||
breakdown,
|
||||
isPreview = false,
|
||||
showDetails = true,
|
||||
showOverrideInput = false,
|
||||
onOverrideChange,
|
||||
}: EffortDisplayProps) {
|
||||
const hasOverride = overrideFte !== null && overrideFte !== undefined;
|
||||
const hasBreakdown = breakdown !== null && breakdown !== undefined;
|
||||
|
||||
// Extract breakdown values
|
||||
const baseEffort = breakdown?.baseEffort ?? null;
|
||||
const baseEffortMin = breakdown?.baseEffortMin ?? null;
|
||||
const baseEffortMax = breakdown?.baseEffortMax ?? null;
|
||||
|
||||
const numberOfUsersFactor = breakdown?.numberOfUsersFactor ?? { value: 1.0, name: null };
|
||||
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
|
||||
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
|
||||
|
||||
const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null;
|
||||
const applicationTypeName = breakdown?.applicationType ?? null;
|
||||
const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null;
|
||||
const applicationManagementHosting = breakdown?.applicationManagementHosting ?? null;
|
||||
|
||||
const warnings = breakdown?.warnings ?? [];
|
||||
const errors = breakdown?.errors ?? [];
|
||||
const usedDefaults = breakdown?.usedDefaults ?? [];
|
||||
const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false;
|
||||
const isFixedFte = breakdown?.isFixedFte ?? false;
|
||||
|
||||
// Use hours from backend breakdown (calculated in effortCalculation.ts)
|
||||
// Only fall back to local calculation if breakdown is not available
|
||||
const declarableHoursPerYear = breakdown?.hoursPerYear ?? (effectiveFte !== null
|
||||
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte * DECLARABLE_PERCENTAGE_DISPLAY
|
||||
: 0);
|
||||
const hoursPerMonth = breakdown?.hoursPerMonth ?? declarableHoursPerYear / 12;
|
||||
const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? declarableHoursPerYear / WORK_WEEKS_PER_YEAR_DISPLAY;
|
||||
const minutesPerWeek = hoursPerWeekCalculated * 60;
|
||||
// For display of netto hours (before declarable percentage)
|
||||
const netHoursPerYear = effectiveFte !== null
|
||||
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte
|
||||
: 0;
|
||||
|
||||
// No effort calculated
|
||||
if (effectiveFte === null || effectiveFte === undefined) {
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2">
|
||||
{errors.map((error, i) => (
|
||||
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
|
||||
<span>❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">Niet berekend - configuratie onvolledig</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-sm text-gray-400">Niet berekend</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-2">
|
||||
{errors.map((error, i) => (
|
||||
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
|
||||
<span>❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-2">
|
||||
{warnings.map((warning, i) => (
|
||||
<div key={i} className="text-sm text-yellow-700 flex items-start gap-1">
|
||||
<span>{warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'}</span>
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main FTE display */}
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{effectiveFte.toFixed(2)} FTE
|
||||
{hasOverride && (
|
||||
<span className="ml-2 text-sm font-normal text-orange-600">(Override)</span>
|
||||
)}
|
||||
{isPreview && !hasOverride && (
|
||||
<span className="ml-2 text-sm font-normal text-blue-600">(voorvertoning)</span>
|
||||
)}
|
||||
{isFixedFte && (
|
||||
<span className="ml-2 text-sm font-normal text-purple-600">(vast)</span>
|
||||
)}
|
||||
{requiresManualAssessment && (
|
||||
<span className="ml-2 text-sm font-normal text-orange-600">(handmatige beoordeling)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show calculated value if override is active */}
|
||||
{hasOverride && calculatedFte !== null && calculatedFte !== undefined && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Berekende waarde: <span className="font-medium">{calculatedFte.toFixed(2)} FTE</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override input */}
|
||||
{showOverrideInput && onOverrideChange && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-sm text-gray-600">Override FTE:</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={overrideFte !== null ? overrideFte : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === '') {
|
||||
onOverrideChange(null);
|
||||
} else {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
onOverrideChange(numValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-24 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
placeholder="Leeg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDetails && baseEffort !== null && (
|
||||
<div className="pt-2 border-t border-gray-200 space-y-1 text-sm text-gray-600">
|
||||
{/* Base FTE with range */}
|
||||
<div className="font-medium text-gray-700 mb-2">
|
||||
Basis FTE: {baseEffort.toFixed(2)} FTE
|
||||
{baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && (
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
(range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lookup path */}
|
||||
<div className="pl-2 space-y-1 text-xs text-gray-500 border-l-2 border-gray-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>ICT Governance Model:</span>
|
||||
<span className="font-medium text-gray-700">{governanceModelName || 'Niet ingesteld'}</span>
|
||||
{usedDefaults.includes('regiemodel') && <span className="text-orange-500">(default)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Application Type:</span>
|
||||
<span className="font-medium text-gray-700">{applicationTypeName || 'Niet ingesteld'}</span>
|
||||
{usedDefaults.includes('applicationType') && <span className="text-orange-500">(default)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Business Impact Analyse:</span>
|
||||
<span className="font-medium text-gray-700">{businessImpactAnalyse || 'Niet ingesteld'}</span>
|
||||
{usedDefaults.includes('businessImpact') && <span className="text-orange-500">(default)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Hosting:</span>
|
||||
<span className="font-medium text-gray-700">{applicationManagementHosting || 'Niet ingesteld'}</span>
|
||||
{usedDefaults.includes('hosting') && <span className="text-orange-500">(default)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Factors */}
|
||||
<div className="font-medium text-gray-700 mt-2 mb-1">Factoren:</div>
|
||||
<div>
|
||||
Number of Users: × {numberOfUsersFactor.value.toFixed(2)}
|
||||
{numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`}
|
||||
</div>
|
||||
<div>
|
||||
Dynamics Factor: × {dynamicsFactor.value.toFixed(2)}
|
||||
{dynamicsFactor.name && ` (${dynamicsFactor.name})`}
|
||||
</div>
|
||||
<div>
|
||||
Complexity Factor: × {complexityFactor.value.toFixed(2)}
|
||||
{complexityFactor.name && ` (${complexityFactor.name})`}
|
||||
</div>
|
||||
|
||||
{/* Hours breakdown */}
|
||||
<div className="font-medium text-gray-700 mt-3 mb-1 pt-2 border-t border-gray-200">
|
||||
Uren per jaar (écht inzetbaar):
|
||||
</div>
|
||||
<div className="pl-2 space-y-1 text-xs text-gray-600 bg-blue-50 rounded p-2 border-l-2 border-blue-300">
|
||||
<div className="font-medium text-gray-700">
|
||||
{declarableHoursPerYear.toFixed(1)} uur per jaar
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
≈ {hoursPerMonth.toFixed(1)} uur per maand
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
≈ {hoursPerWeekCalculated.toFixed(2)} uur per week
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
≈ {minutesPerWeek.toFixed(0)} minuten per week
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||
<div>Berekening: {HOURS_PER_WEEK_DISPLAY} uur/week × {WORK_WEEKS_PER_YEAR_DISPLAY} weken × {effectiveFte.toFixed(2)} FTE × {DECLARABLE_PERCENTAGE_DISPLAY * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar</div>
|
||||
<div className="mt-1">(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {DECLARABLE_PERCENTAGE_DISPLAY * 100}% declarabel)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EffortDisplay;
|
||||
|
||||
337
frontend/src/components/FTECalculator.tsx
Normal file
337
frontend/src/components/FTECalculator.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { getReferenceData } from '../services/api';
|
||||
import CustomSelect from './CustomSelect';
|
||||
import { EffortDisplay } from './EffortDisplay';
|
||||
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
|
||||
import type { ReferenceValue, ApplicationDetails } from '../types';
|
||||
|
||||
export default function FTECalculator() {
|
||||
// Reference data state
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
|
||||
const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState<ReferenceValue[]>([]);
|
||||
const [applicationManagementHosting, setApplicationManagementHosting] = useState<ReferenceValue[]>([]);
|
||||
const [numberOfUsers, setNumberOfUsers] = useState<ReferenceValue[]>([]);
|
||||
const [dynamicsFactors, setDynamicsFactors] = useState<ReferenceValue[]>([]);
|
||||
const [complexityFactors, setComplexityFactors] = useState<ReferenceValue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Selected values state
|
||||
const [selectedGovernanceModel, setSelectedGovernanceModel] = useState<ReferenceValue | null>(null);
|
||||
const [selectedApplicationType, setSelectedApplicationType] = useState<ReferenceValue | null>(null);
|
||||
const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState<ReferenceValue | null>(null);
|
||||
const [selectedHosting, setSelectedHosting] = useState<ReferenceValue | null>(null);
|
||||
const [selectedNumberOfUsers, setSelectedNumberOfUsers] = useState<ReferenceValue | null>(null);
|
||||
const [selectedDynamicsFactor, setSelectedDynamicsFactor] = useState<ReferenceValue | null>(null);
|
||||
const [selectedComplexityFactor, setSelectedComplexityFactor] = useState<ReferenceValue | null>(null);
|
||||
|
||||
// Load reference data
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getReferenceData();
|
||||
setGovernanceModels(data.governanceModels);
|
||||
setApplicationTypes(data.applicationTypes);
|
||||
setBusinessImpactAnalyses(data.businessImpactAnalyses);
|
||||
setApplicationManagementHosting(data.applicationManagementHosting);
|
||||
setNumberOfUsers(data.numberOfUsers);
|
||||
setDynamicsFactors(data.dynamicsFactors);
|
||||
setComplexityFactors(data.complexityFactors);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load reference data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Build a minimal ApplicationDetails object for calculation
|
||||
const applicationData = useMemo<ApplicationDetails | null>(() => {
|
||||
// Only create if at least governance model is selected (required for calculation)
|
||||
if (!selectedGovernanceModel) return null;
|
||||
|
||||
return {
|
||||
id: 'calculator',
|
||||
key: 'CALC',
|
||||
name: 'FTE Calculator',
|
||||
searchReference: null,
|
||||
description: null,
|
||||
supplierProduct: null,
|
||||
organisation: null,
|
||||
hostingType: null,
|
||||
status: null,
|
||||
businessImportance: null,
|
||||
businessImpactAnalyse: selectedBusinessImpactAnalyse,
|
||||
systemOwner: null,
|
||||
businessOwner: null,
|
||||
functionalApplicationManagement: null,
|
||||
technicalApplicationManagement: null,
|
||||
medischeTechniek: false,
|
||||
applicationFunctions: [],
|
||||
dynamicsFactor: selectedDynamicsFactor,
|
||||
complexityFactor: selectedComplexityFactor,
|
||||
numberOfUsers: selectedNumberOfUsers,
|
||||
governanceModel: selectedGovernanceModel,
|
||||
applicationSubteam: null,
|
||||
applicationTeam: null,
|
||||
applicationType: selectedApplicationType,
|
||||
platform: null,
|
||||
requiredEffortApplicationManagement: null,
|
||||
applicationManagementHosting: selectedHosting,
|
||||
applicationManagementTAM: null,
|
||||
};
|
||||
}, [
|
||||
selectedGovernanceModel,
|
||||
selectedApplicationType,
|
||||
selectedBusinessImpactAnalyse,
|
||||
selectedHosting,
|
||||
selectedNumberOfUsers,
|
||||
selectedDynamicsFactor,
|
||||
selectedComplexityFactor,
|
||||
]);
|
||||
|
||||
// Use effort calculation hook
|
||||
const {
|
||||
calculatedFte,
|
||||
breakdown: effortBreakdown,
|
||||
isCalculating,
|
||||
} = useEffortCalculation({
|
||||
application: applicationData,
|
||||
debounceMs: 300,
|
||||
});
|
||||
|
||||
// Reset all fields
|
||||
const handleReset = () => {
|
||||
setSelectedGovernanceModel(null);
|
||||
setSelectedApplicationType(null);
|
||||
setSelectedBusinessImpactAnalyse(null);
|
||||
setSelectedHosting(null);
|
||||
setSelectedNumberOfUsers(null);
|
||||
setSelectedDynamicsFactor(null);
|
||||
setSelectedComplexityFactor(null);
|
||||
};
|
||||
|
||||
// Sort numberOfUsers by extracting the first number from each option
|
||||
const sortedNumberOfUsers = useMemo(() => {
|
||||
const getSortValue = (name: string): number => {
|
||||
const cleaned = name.replace(/\./g, '');
|
||||
const match = cleaned.match(/\d+/);
|
||||
const num = match ? parseInt(match[0], 10) : 0;
|
||||
|
||||
if (name.startsWith('<')) {
|
||||
return num - 0.5;
|
||||
}
|
||||
if (name.startsWith('>')) {
|
||||
return num + 0.5;
|
||||
}
|
||||
return num;
|
||||
};
|
||||
|
||||
return [...numberOfUsers].sort((a, b) => {
|
||||
const numA = getSortValue(a.name);
|
||||
const numB = getSortValue(b.name);
|
||||
return numA - numB;
|
||||
});
|
||||
}, [numberOfUsers]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">FTE Calculator</h1>
|
||||
<p className="text-gray-600">
|
||||
Bereken de benodigde FTE voor applicatiemanagement op basis van de onderstaande classificatievelden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Classificatievelden</h2>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Reset alle velden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* First row: Application Type, Hosting */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Application Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Application Type
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedApplicationType?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = applicationTypes.find((t) => t.objectId === value);
|
||||
setSelectedApplicationType(selected || null);
|
||||
}}
|
||||
options={applicationTypes}
|
||||
placeholder="Selecteer Application Type..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hosting */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hosting
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedHosting?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = applicationManagementHosting.find((h) => h.objectId === value);
|
||||
setSelectedHosting(selected || null);
|
||||
}}
|
||||
options={applicationManagementHosting}
|
||||
placeholder="Selecteer Hosting..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second row: Business Impact Analyse - Full width */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Business Impact Analyse
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedBusinessImpactAnalyse?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = businessImpactAnalyses.find((b) => b.objectId === value);
|
||||
setSelectedBusinessImpactAnalyse(selected || null);
|
||||
}}
|
||||
options={businessImpactAnalyses}
|
||||
placeholder="Selecteer Business Impact Analyse..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Third row: Number of Users, Dynamics Factor, Complexity Factor - 3 columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Number of Users */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Number of Users
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedNumberOfUsers?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = sortedNumberOfUsers.find((u) => u.objectId === value);
|
||||
setSelectedNumberOfUsers(selected || null);
|
||||
}}
|
||||
options={sortedNumberOfUsers}
|
||||
placeholder="Selecteer Number of Users..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dynamics Factor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dynamics Factor
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedDynamicsFactor?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = dynamicsFactors.find((d) => d.objectId === value);
|
||||
setSelectedDynamicsFactor(selected || null);
|
||||
}}
|
||||
options={dynamicsFactors}
|
||||
placeholder="Selecteer Dynamics Factor..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Complexity Factor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Complexity Factor
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedComplexityFactor?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = complexityFactors.find((c) => c.objectId === value);
|
||||
setSelectedComplexityFactor(selected || null);
|
||||
}}
|
||||
options={complexityFactors}
|
||||
placeholder="Selecteer Complexity Factor..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ICT Governance Model - Full width at the end */}
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ICT Governance Model <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={selectedGovernanceModel?.objectId || ''}
|
||||
onChange={(value) => {
|
||||
const selected = governanceModels.find((m) => m.objectId === value);
|
||||
setSelectedGovernanceModel(selected || null);
|
||||
}}
|
||||
options={governanceModels}
|
||||
placeholder="Selecteer ICT Governance Model..."
|
||||
showSummary={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Berekening Resultaat</h2>
|
||||
|
||||
{isCalculating ? (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<span>Berekenen...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
|
||||
<EffortDisplay
|
||||
effectiveFte={getEffectiveFte(calculatedFte, null, null)}
|
||||
calculatedFte={calculatedFte ?? null}
|
||||
overrideFte={null}
|
||||
breakdown={effortBreakdown}
|
||||
isPreview={true}
|
||||
showDetails={true}
|
||||
showOverrideInput={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGovernanceModel && (
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Selecteer minimaal het ICT Governance Model om een berekening uit te voeren.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
frontend/src/components/GovernanceAnalysis.tsx
Normal file
303
frontend/src/components/GovernanceAnalysis.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface ApplicationWithIssues {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
status: string | null;
|
||||
governanceModel: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
applicationType: string | null;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface GovernanceAnalysisData {
|
||||
totalApplications: number;
|
||||
applicationsWithIssues: number;
|
||||
applications: ApplicationWithIssues[];
|
||||
}
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Default statuses to exclude
|
||||
const DEFAULT_EXCLUDED_STATUSES = ['Closed', 'Deprecated'];
|
||||
|
||||
export default function GovernanceAnalysis() {
|
||||
const [data, setData] = useState<GovernanceAnalysisData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [excludedStatuses, setExcludedStatuses] = useState<string[]>(DEFAULT_EXCLUDED_STATUSES);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/dashboard/governance-analysis`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch governance analysis');
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Get unique statuses from the data for filtering
|
||||
const availableStatuses = Array.from(
|
||||
new Set(data?.applications.map(app => app.status).filter(Boolean) as string[])
|
||||
).sort();
|
||||
|
||||
// Filter applications
|
||||
const filteredApplications = data?.applications.filter(app => {
|
||||
// Filter by excluded statuses
|
||||
if (app.status && excludedStatuses.includes(app.status)) return false;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
app.name.toLowerCase().includes(query) ||
|
||||
app.key.toLowerCase().includes(query) ||
|
||||
(app.governanceModel?.toLowerCase().includes(query)) ||
|
||||
(app.businessImpactAnalyse?.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
// Toggle status exclusion
|
||||
const toggleStatusExclusion = (status: string) => {
|
||||
setExcludedStatuses(prev =>
|
||||
prev.includes(status)
|
||||
? prev.filter(s => s !== status)
|
||||
: [...prev, status]
|
||||
);
|
||||
};
|
||||
|
||||
// Applications filtered by status (before type/search filtering)
|
||||
const statusFilteredApplications = data?.applications.filter(app =>
|
||||
!(app.status && excludedStatuses.includes(app.status))
|
||||
) || [];
|
||||
|
||||
// Count statistics based on status-filtered applications
|
||||
const totalWithIssues = statusFilteredApplications.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Analyseren van regiemodel configuratie...</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Dit kan even duren...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analyse Regiemodel</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Overzicht van applicaties met regiemodel fouten (ongeldig regiemodel voor de BIA classificatie).
|
||||
<br />
|
||||
<span className="text-sm text-gray-400">Standaard worden Closed en Deprecated applicaties uitgesloten.</span>
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/reports"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Terug naar rapporten
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Totaal geanalyseerd</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{data?.totalApplications || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Met regiemodel fouten</div>
|
||||
<div className="text-2xl font-bold text-red-600">{totalWithIssues}</div>
|
||||
{excludedStatuses.length > 0 && data && totalWithIssues !== data.applicationsWithIssues && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
({data.applicationsWithIssues} totaal, {data.applicationsWithIssues - totalWithIssues} uitgesloten)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Zoeken op naam, key, regiemodel..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status exclusion filter */}
|
||||
{availableStatuses.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-gray-600">Status uitsluiten:</span>
|
||||
{availableStatuses.map((status) => (
|
||||
<label key={status} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludedStatuses.includes(status)}
|
||||
onChange={() => toggleStatusExclusion(status)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className={`text-sm ${excludedStatuses.includes(status) ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
|
||||
{status}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{excludedStatuses.length > 0 && (
|
||||
<button
|
||||
onClick={() => setExcludedStatuses([])}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 ml-2"
|
||||
>
|
||||
Alles tonen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="bg-white rounded-lg border border-gray-200">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<span className="text-sm text-gray-600">
|
||||
{filteredApplications.length} applicatie{filteredApplications.length !== 1 ? 's' : ''} gevonden
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredApplications.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="w-12 h-12 text-green-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-500">Geen applicaties met regiemodel fouten gevonden</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Alle applicaties hebben een geldig regiemodel voor hun BIA classificatie</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredApplications.map((app) => (
|
||||
<div key={app.id} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
to={`/app-components/overview/${app.id}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium hover:underline"
|
||||
>
|
||||
{app.name}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-gray-500">
|
||||
<span className="font-mono">{app.key}</span>
|
||||
{app.status && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">{app.status}</span>
|
||||
)}
|
||||
{app.governanceModel && (
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{app.governanceModel}
|
||||
</span>
|
||||
)}
|
||||
{app.businessImpactAnalyse && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
BIA: {app.businessImpactAnalyse}
|
||||
</span>
|
||||
)}
|
||||
{app.applicationType && (
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">
|
||||
{app.applicationType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{app.errors.length > 0 && (
|
||||
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
{app.errors.map((error, i) => (
|
||||
<div key={i} className="text-sm text-red-700 flex items-start gap-2">
|
||||
<span className="flex-shrink-0">❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{app.warnings.length > 0 && (
|
||||
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
{app.warnings.map((warning, i) => (
|
||||
<div key={i} className="text-sm text-yellow-700 flex items-start gap-2">
|
||||
<span className="flex-shrink-0">
|
||||
{warning.startsWith('⚠️') || warning.startsWith('ℹ️') ? '' : 'ℹ️'}
|
||||
</span>
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,7 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.oauthEnabled ? (
|
||||
{config?.authMethod === 'oauth' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleJiraLogin}
|
||||
@@ -76,19 +76,19 @@ export default function Login() {
|
||||
</button>
|
||||
|
||||
<p className="mt-4 text-center text-slate-500 text-sm">
|
||||
Je wordt doorgestuurd naar Jira om in te loggen
|
||||
Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
|
||||
</p>
|
||||
</>
|
||||
) : config?.serviceAccountEnabled ? (
|
||||
) : config?.authMethod === 'pat' ? (
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Service Account Modus</p>
|
||||
<p className="text-slate-300 mb-2">Personal Access Token Modus</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
De applicatie gebruikt een geconfigureerd service account voor Jira toegang.
|
||||
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
@@ -106,7 +106,7 @@ export default function Login() {
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Neem contact op met de beheerder om OAuth of een service account te configureren.
|
||||
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
165
frontend/src/components/ReportsDashboard.tsx
Normal file
165
frontend/src/components/ReportsDashboard.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function ReportsDashboard() {
|
||||
const reports = [
|
||||
{
|
||||
id: 'team-dashboard',
|
||||
title: 'Team-indeling',
|
||||
description: 'Overzicht van teams, subteams en FTE verdeling.',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
href: '/reports/team-dashboard',
|
||||
color: 'blue',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'governance-analysis',
|
||||
title: 'Analyse Regiemodel',
|
||||
description: 'Overzicht van applicaties met regiemodel en BIA configuratie problemen.',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
href: '/reports/governance-analysis',
|
||||
color: 'orange',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'classification-progress',
|
||||
title: 'Classificatie Voortgang',
|
||||
description: 'Voortgang van ZiRA classificatie per domein en afdeling.',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
href: '/reports/classification-progress',
|
||||
color: 'green',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 'governance-overview',
|
||||
title: 'Regiemodel Overzicht',
|
||||
description: 'Verdeling van applicaties per regiemodel en BIA classificatie.',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
|
||||
</svg>
|
||||
),
|
||||
href: '/reports/governance-overview',
|
||||
color: 'purple',
|
||||
available: false,
|
||||
},
|
||||
{
|
||||
id: 'data-model',
|
||||
title: 'Datamodel',
|
||||
description: 'Overzicht van alle object types, attributen en relaties in het Jira Assets schema.',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
),
|
||||
href: '/reports/data-model',
|
||||
color: 'cyan',
|
||||
available: true,
|
||||
},
|
||||
];
|
||||
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50',
|
||||
iconBg: 'bg-blue-100',
|
||||
iconText: 'text-blue-600',
|
||||
hover: 'hover:bg-blue-100',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50',
|
||||
iconBg: 'bg-green-100',
|
||||
iconText: 'text-green-600',
|
||||
hover: 'hover:bg-green-100',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50',
|
||||
iconBg: 'bg-purple-100',
|
||||
iconText: 'text-purple-600',
|
||||
hover: 'hover:bg-purple-100',
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-50',
|
||||
iconBg: 'bg-orange-100',
|
||||
iconText: 'text-orange-600',
|
||||
hover: 'hover:bg-orange-100',
|
||||
},
|
||||
cyan: {
|
||||
bg: 'bg-cyan-50',
|
||||
iconBg: 'bg-cyan-100',
|
||||
iconText: 'text-cyan-600',
|
||||
hover: 'hover:bg-cyan-100',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Rapporten</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Overzicht van beschikbare rapporten en analyses voor de CMDB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reports Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{reports.map((report) => {
|
||||
const colors = colorClasses[report.color as keyof typeof colorClasses];
|
||||
|
||||
if (!report.available) {
|
||||
return (
|
||||
<div
|
||||
key={report.id}
|
||||
className={`relative p-6 rounded-xl border border-gray-200 bg-gray-50 opacity-60`}
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-gray-200 text-gray-600 rounded-full">
|
||||
Binnenkort
|
||||
</span>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4`}>
|
||||
<span className={colors.iconText}>{report.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{report.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={report.id}
|
||||
to={report.href}
|
||||
className={`p-6 rounded-xl border border-gray-200 ${colors.bg} ${colors.hover} transition-colors group`}
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<span className={colors.iconText}>{report.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{report.description}</p>
|
||||
<div className="mt-4 flex items-center text-sm font-medium text-blue-600">
|
||||
Bekijk rapport
|
||||
<svg className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
548
frontend/src/components/SearchDashboard.tsx
Normal file
548
frontend/src/components/SearchDashboard.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
|
||||
|
||||
const ITEMS_PER_PAGE = 25;
|
||||
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
|
||||
|
||||
// Helper to strip HTML tags from description
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Helper to get attribute value from result
|
||||
function getAttributeValue(result: CMDBSearchResult, attributeName: string): string | null {
|
||||
const attr = result.attributes.find(a => a.name === attributeName);
|
||||
if (attr && attr.values && attr.values.length > 0) {
|
||||
return attr.values[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to get status display info
|
||||
function getStatusInfo(status: string | null): { color: string; bg: string } {
|
||||
if (!status) return { color: 'text-gray-600', bg: 'bg-gray-100' };
|
||||
|
||||
const statusLower = status.toLowerCase();
|
||||
if (statusLower.includes('production')) return { color: 'text-green-700', bg: 'bg-green-100' };
|
||||
if (statusLower.includes('implementation')) return { color: 'text-blue-700', bg: 'bg-blue-100' };
|
||||
if (statusLower.includes('deprecated') || statusLower.includes('end of')) return { color: 'text-orange-700', bg: 'bg-orange-100' };
|
||||
if (statusLower.includes('closed')) return { color: 'text-red-700', bg: 'bg-red-100' };
|
||||
if (statusLower.includes('concept') || statusLower.includes('poc')) return { color: 'text-purple-700', bg: 'bg-purple-100' };
|
||||
if (statusLower.includes('shadow')) return { color: 'text-yellow-700', bg: 'bg-yellow-100' };
|
||||
|
||||
return { color: 'text-gray-600', bg: 'bg-gray-100' };
|
||||
}
|
||||
|
||||
export default function SearchDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<CMDBSearchResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<number | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState<Map<number, number>>(new Map());
|
||||
const [jiraHost, setJiraHost] = useState<string>('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// Fetch Jira host for avatar URLs
|
||||
useEffect(() => {
|
||||
getConfig().then(config => {
|
||||
setJiraHost(config.jiraHost);
|
||||
}).catch(() => {
|
||||
// Silently fail, avatars just won't show
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Group results by object type
|
||||
const resultsByType = useMemo(() => {
|
||||
if (!searchResults?.results) return new Map<number, CMDBSearchResult[]>();
|
||||
|
||||
const grouped = new Map<number, CMDBSearchResult[]>();
|
||||
for (const result of searchResults.results) {
|
||||
const typeId = result.objectTypeId;
|
||||
if (!grouped.has(typeId)) {
|
||||
grouped.set(typeId, []);
|
||||
}
|
||||
grouped.get(typeId)!.push(result);
|
||||
}
|
||||
return grouped;
|
||||
}, [searchResults]);
|
||||
|
||||
// Get object type info by ID
|
||||
const objectTypeMap = useMemo(() => {
|
||||
if (!searchResults?.objectTypes) return new Map<number, CMDBSearchObjectType>();
|
||||
|
||||
const map = new Map<number, CMDBSearchObjectType>();
|
||||
for (const ot of searchResults.objectTypes) {
|
||||
map.set(ot.id, ot);
|
||||
}
|
||||
return map;
|
||||
}, [searchResults]);
|
||||
|
||||
// Get sorted object types (by result count, descending)
|
||||
const sortedObjectTypes = useMemo(() => {
|
||||
if (!searchResults?.objectTypes) return [];
|
||||
|
||||
return [...searchResults.objectTypes].sort((a, b) => {
|
||||
const countA = resultsByType.get(a.id)?.length || 0;
|
||||
const countB = resultsByType.get(b.id)?.length || 0;
|
||||
return countB - countA;
|
||||
});
|
||||
}, [searchResults, resultsByType]);
|
||||
|
||||
// Current tab's results
|
||||
const currentTabResults = useMemo(() => {
|
||||
if (selectedTab === null) return [];
|
||||
return resultsByType.get(selectedTab) || [];
|
||||
}, [selectedTab, resultsByType]);
|
||||
|
||||
// Get unique status values for current tab
|
||||
const statusOptions = useMemo(() => {
|
||||
const statuses = new Set<string>();
|
||||
for (const result of currentTabResults) {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
if (status) {
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusName = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
statuses.add(statusName);
|
||||
}
|
||||
}
|
||||
return Array.from(statuses).sort();
|
||||
}, [currentTabResults]);
|
||||
|
||||
// Filter results by status
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!statusFilter) return currentTabResults;
|
||||
|
||||
return currentTabResults.filter(result => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
if (!status) return false;
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusName = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
return statusName === statusFilter;
|
||||
});
|
||||
}, [currentTabResults, statusFilter]);
|
||||
|
||||
// Pagination
|
||||
const pageForCurrentTab = currentPage.get(selectedTab || 0) || 1;
|
||||
const totalPages = Math.ceil(filteredResults.length / ITEMS_PER_PAGE);
|
||||
const paginatedResults = filteredResults.slice(
|
||||
(pageForCurrentTab - 1) * ITEMS_PER_PAGE,
|
||||
pageForCurrentTab * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
// Reset pagination when filter changes
|
||||
useEffect(() => {
|
||||
if (selectedTab !== null) {
|
||||
setCurrentPage(prev => new Map(prev).set(selectedTab, 1));
|
||||
}
|
||||
}, [statusFilter, selectedTab]);
|
||||
|
||||
// Auto-select first tab when results arrive
|
||||
useEffect(() => {
|
||||
if (sortedObjectTypes.length > 0 && selectedTab === null) {
|
||||
setSelectedTab(sortedObjectTypes[0].id);
|
||||
}
|
||||
}, [sortedObjectTypes, selectedTab]);
|
||||
|
||||
// Perform search
|
||||
const handleSearch = useCallback((e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setHasSearched(true);
|
||||
setSelectedTab(null);
|
||||
setStatusFilter('');
|
||||
setCurrentPage(new Map());
|
||||
|
||||
searchCMDB(searchQuery.trim())
|
||||
.then((results) => {
|
||||
setSearchResults(results);
|
||||
|
||||
// Auto-select first tab if results exist
|
||||
if (results.objectTypes && results.objectTypes.length > 0) {
|
||||
// Sort by count and select the first one
|
||||
const sorted = [...results.objectTypes].sort((a, b) => {
|
||||
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
|
||||
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
|
||||
return countB - countA;
|
||||
});
|
||||
setSelectedTab(sorted[0].id);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Zoeken mislukt');
|
||||
setSearchResults(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [searchQuery]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (typeId: number) => {
|
||||
setSelectedTab(typeId);
|
||||
setStatusFilter('');
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (selectedTab !== null) {
|
||||
setCurrentPage(prev => new Map(prev).set(selectedTab, newPage));
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to check if a result is an Application Component (by looking up type name)
|
||||
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
|
||||
const objectType = objectTypeMap.get(result.objectTypeId);
|
||||
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
|
||||
}, [objectTypeMap]);
|
||||
|
||||
// Handle result click (for Application Components)
|
||||
const handleResultClick = (result: CMDBSearchResult) => {
|
||||
if (isApplicationComponent(result)) {
|
||||
navigate(`/application/${result.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Get avatar URL with Jira host prefix
|
||||
const getAvatarUrl = (avatarUrl: string) => {
|
||||
if (!avatarUrl) return null;
|
||||
if (avatarUrl.startsWith('http')) return avatarUrl;
|
||||
return `${jiraHost}${avatarUrl}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
|
||||
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Zoek op naam, key, of beschrijving..."
|
||||
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !searchQuery.trim()}
|
||||
className="absolute inset-y-1.5 right-1.5 px-5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
Zoeken
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && searchResults && !loading && (
|
||||
<div className="space-y-4">
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">{searchResults.metadata.total}</span> resultaten gevonden
|
||||
{searchResults.metadata.total !== searchResults.results.length && (
|
||||
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{searchResults.results.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-500">Geen resultaten gevonden voor "{searchQuery}"</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Probeer een andere zoekterm</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Object Type Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
|
||||
{sortedObjectTypes.map((objectType) => {
|
||||
const count = resultsByType.get(objectType.id)?.length || 0;
|
||||
const isActive = selectedTab === objectType.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={objectType.id}
|
||||
onClick={() => handleTabChange(objectType.id)}
|
||||
className={`
|
||||
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
|
||||
${isActive
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
|
||||
`}
|
||||
>
|
||||
{jiraHost && objectType.iconUrl && (
|
||||
<img
|
||||
src={getAvatarUrl(objectType.iconUrl) || ''}
|
||||
alt=""
|
||||
className="w-4 h-4"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<span>{objectType.name}</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${isActive ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">Filter op status:</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none"
|
||||
>
|
||||
<option value="">Alle statussen ({currentTabResults.length})</option>
|
||||
{statusOptions.map(status => {
|
||||
const count = currentTabResults.filter(r => {
|
||||
const s = getAttributeValue(r, 'Status');
|
||||
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
|
||||
return sName === status;
|
||||
}).length;
|
||||
return (
|
||||
<option key={status} value={status}>
|
||||
{status} ({count})
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{statusFilter && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('')}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Wis filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results List */}
|
||||
<div className="space-y-2">
|
||||
{paginatedResults.map((result) => {
|
||||
const status = getAttributeValue(result, 'Status');
|
||||
// Handle status objects with nested structure (null check required because typeof null === 'object')
|
||||
const statusDisplay = status && typeof status === 'object' && (status as any).name
|
||||
? (status as any).name
|
||||
: status;
|
||||
const statusInfo = getStatusInfo(statusDisplay);
|
||||
const description = getAttributeValue(result, 'Description');
|
||||
const isClickable = isApplicationComponent(result);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
onClick={() => isClickable && handleResultClick(result)}
|
||||
className={`
|
||||
bg-white border border-gray-200 rounded-lg p-4
|
||||
${isClickable
|
||||
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
|
||||
: ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
|
||||
{result.avatarUrl && jiraHost ? (
|
||||
<img
|
||||
src={getAvatarUrl(result.avatarUrl) || ''}
|
||||
alt=""
|
||||
className="w-6 h-6"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).parentElement!.innerHTML = `
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
|
||||
{statusDisplay && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
|
||||
{statusDisplay}
|
||||
</span>
|
||||
)}
|
||||
{isClickable && (
|
||||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Klik om te openen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mt-0.5">{result.label}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{stripHtml(description).substring(0, 200)}
|
||||
{stripHtml(description).length > 200 && '...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab - 1)}
|
||||
disabled={pageForCurrentTab === 1}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Vorige
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(pageForCurrentTab + 1)}
|
||||
disabled={pageForCurrentTab === totalPages}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Volgende
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links (only show when no search has been performed) */}
|
||||
{!hasSearched && (
|
||||
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
|
||||
<a
|
||||
href="/app-components"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Application Components</p>
|
||||
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/reports/team-dashboard"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Rapporten</p>
|
||||
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/app-components/fte-config"
|
||||
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Configuratie</p>
|
||||
<p className="text-sm text-gray-500">FTE berekening</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
212
frontend/src/hooks/useEffortCalculation.ts
Normal file
212
frontend/src/hooks/useEffortCalculation.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { calculateEffort } from '../services/api';
|
||||
import type { ApplicationDetails, EffortCalculationBreakdown, ReferenceValue } from '../types';
|
||||
|
||||
export interface EffortCalculationResult {
|
||||
/** The calculated FTE value (after all factors applied) */
|
||||
calculatedFte: number | null;
|
||||
/** The full breakdown of the calculation */
|
||||
breakdown: EffortCalculationBreakdown | null;
|
||||
/** Whether a calculation is in progress */
|
||||
isCalculating: boolean;
|
||||
/** Any error that occurred during calculation */
|
||||
error: string | null;
|
||||
/** Manually trigger a recalculation */
|
||||
recalculate: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface EffortCalculationOverrides {
|
||||
governanceModel?: ReferenceValue | null;
|
||||
applicationType?: ReferenceValue | null;
|
||||
businessImpactAnalyse?: ReferenceValue | null;
|
||||
applicationManagementHosting?: ReferenceValue | null;
|
||||
dynamicsFactor?: ReferenceValue | null;
|
||||
complexityFactor?: ReferenceValue | null;
|
||||
numberOfUsers?: ReferenceValue | null;
|
||||
hostingType?: ReferenceValue | null;
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
}
|
||||
|
||||
export interface EffortCalculationInput {
|
||||
/** Base application data */
|
||||
application: ApplicationDetails | null;
|
||||
/** Optional overrides for real-time preview (e.g., when user changes fields) */
|
||||
overrides?: EffortCalculationOverrides;
|
||||
/** Whether to auto-calculate on input changes (default: true) */
|
||||
autoCalculate?: boolean;
|
||||
/** Debounce delay in milliseconds (default: 150) */
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build application data with overrides applied.
|
||||
* Exported for use in components that need manual control.
|
||||
*/
|
||||
export function buildApplicationDataWithOverrides(
|
||||
application: ApplicationDetails,
|
||||
overrides?: EffortCalculationOverrides
|
||||
): ApplicationDetails {
|
||||
if (!overrides) return application;
|
||||
|
||||
return {
|
||||
...application,
|
||||
governanceModel: overrides.governanceModel !== undefined
|
||||
? overrides.governanceModel
|
||||
: application.governanceModel,
|
||||
applicationType: overrides.applicationType !== undefined
|
||||
? overrides.applicationType
|
||||
: application.applicationType,
|
||||
businessImpactAnalyse: overrides.businessImpactAnalyse !== undefined
|
||||
? overrides.businessImpactAnalyse
|
||||
: application.businessImpactAnalyse,
|
||||
applicationManagementHosting: overrides.applicationManagementHosting !== undefined
|
||||
? overrides.applicationManagementHosting
|
||||
: application.applicationManagementHosting,
|
||||
dynamicsFactor: overrides.dynamicsFactor !== undefined
|
||||
? overrides.dynamicsFactor
|
||||
: application.dynamicsFactor,
|
||||
complexityFactor: overrides.complexityFactor !== undefined
|
||||
? overrides.complexityFactor
|
||||
: application.complexityFactor,
|
||||
numberOfUsers: overrides.numberOfUsers !== undefined
|
||||
? overrides.numberOfUsers
|
||||
: application.numberOfUsers,
|
||||
hostingType: overrides.hostingType !== undefined
|
||||
? overrides.hostingType
|
||||
: application.hostingType,
|
||||
applicationFunctions: overrides.applicationFunctions !== undefined
|
||||
? overrides.applicationFunctions
|
||||
: application.applicationFunctions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for FTE effort calculation.
|
||||
* Centralizes the logic for calculating application management effort.
|
||||
*
|
||||
* Usage (simple - auto-calculate when application changes):
|
||||
* ```tsx
|
||||
* const { calculatedFte, breakdown } = useEffortCalculation({ application });
|
||||
* ```
|
||||
*
|
||||
* Usage (with overrides for form preview):
|
||||
* ```tsx
|
||||
* const { calculatedFte, breakdown } = useEffortCalculation({
|
||||
* application,
|
||||
* overrides: { governanceModel: selectedGovernanceModel },
|
||||
* debounceMs: 300,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useEffortCalculation({
|
||||
application,
|
||||
overrides,
|
||||
autoCalculate = true,
|
||||
debounceMs = 150,
|
||||
}: EffortCalculationInput): EffortCalculationResult {
|
||||
const [calculatedFte, setCalculatedFte] = useState<number | null>(null);
|
||||
const [breakdown, setBreakdown] = useState<EffortCalculationBreakdown | null>(null);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track mounted state to prevent state updates after unmount
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the application data with any overrides applied
|
||||
const applicationData = useMemo(() => {
|
||||
if (!application) return null;
|
||||
return buildApplicationDataWithOverrides(application, overrides);
|
||||
}, [application, overrides]);
|
||||
|
||||
// Perform the calculation
|
||||
const performCalculation = useCallback(async () => {
|
||||
if (!applicationData) {
|
||||
setCalculatedFte(null);
|
||||
setBreakdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCalculating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await calculateEffort(applicationData);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setCalculatedFte(result.requiredEffortApplicationManagement);
|
||||
setBreakdown(result.breakdown);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
console.error('Failed to calculate effort:', err);
|
||||
setError(err instanceof Error ? err.message : 'Calculation failed');
|
||||
setCalculatedFte(null);
|
||||
setBreakdown(null);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
}
|
||||
}, [applicationData]);
|
||||
|
||||
// Auto-calculate when dependencies change
|
||||
useEffect(() => {
|
||||
if (!autoCalculate) return;
|
||||
if (!application) {
|
||||
setCalculatedFte(null);
|
||||
setBreakdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce to prevent excessive API calls
|
||||
const timeoutId = setTimeout(() => {
|
||||
performCalculation();
|
||||
}, debounceMs);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [autoCalculate, application, applicationData, debounceMs, performCalculation]);
|
||||
|
||||
// Reset when application ID changes
|
||||
useEffect(() => {
|
||||
setCalculatedFte(null);
|
||||
setBreakdown(null);
|
||||
setError(null);
|
||||
}, [application?.id]);
|
||||
|
||||
return {
|
||||
calculatedFte,
|
||||
breakdown,
|
||||
isCalculating,
|
||||
error,
|
||||
recalculate: performCalculation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective FTE value considering override
|
||||
*/
|
||||
export function getEffectiveFte(
|
||||
calculatedFte: number | null,
|
||||
overrideFte: number | null | undefined,
|
||||
fallbackFte: number | null | undefined
|
||||
): number | null {
|
||||
// If override is set, use it
|
||||
if (overrideFte !== null && overrideFte !== undefined) {
|
||||
return overrideFte;
|
||||
}
|
||||
// Otherwise use calculated value
|
||||
if (calculatedFte !== null) {
|
||||
return calculatedFte;
|
||||
}
|
||||
// Fallback to stored value
|
||||
return fallbackFte ?? null;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,48 @@ import type {
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
// =============================================================================
|
||||
// Error Types
|
||||
// =============================================================================
|
||||
|
||||
export interface ConflictError {
|
||||
status: 'conflict';
|
||||
message: string;
|
||||
conflicts?: Array<{
|
||||
field: string;
|
||||
fieldName: string;
|
||||
proposedValue: unknown;
|
||||
jiraValue: unknown;
|
||||
}>;
|
||||
jiraUpdatedAt?: string;
|
||||
canMerge?: boolean;
|
||||
warning?: string;
|
||||
actions: {
|
||||
forceOverwrite: boolean;
|
||||
merge: boolean;
|
||||
discard: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
|
||||
isConflict(): this is ApiError & { data: ConflictError } {
|
||||
return this.status === 409;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Base Fetch
|
||||
// =============================================================================
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
@@ -27,14 +69,21 @@ async function fetchApi<T>(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || error.message || 'API request failed');
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new ApiError(
|
||||
errorData.error || errorData.message || 'API request failed',
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Applications
|
||||
// =============================================================================
|
||||
|
||||
export async function searchApplications(
|
||||
filters: SearchFilters,
|
||||
page: number = 1,
|
||||
@@ -50,6 +99,49 @@ export async function getApplicationById(id: string): Promise<ApplicationDetails
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application for editing (force refresh from Jira)
|
||||
* Returns fresh data with _jiraUpdatedAt for conflict detection
|
||||
*/
|
||||
export async function getApplicationForEdit(id: string): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}?mode=edit`);
|
||||
}
|
||||
|
||||
// Related objects response type
|
||||
export interface RelatedObject {
|
||||
id: number;
|
||||
key: string;
|
||||
name: string;
|
||||
label: string;
|
||||
attributes: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface RelatedObjectsResponse {
|
||||
objects: RelatedObject[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getRelatedObjects(
|
||||
applicationId: string,
|
||||
objectType: string,
|
||||
attributes?: string[]
|
||||
): Promise<RelatedObjectsResponse> {
|
||||
const params = attributes && attributes.length > 0
|
||||
? `?attributes=${encodeURIComponent(attributes.join(','))}`
|
||||
: '';
|
||||
return fetchApi<RelatedObjectsResponse>(`/applications/${applicationId}/related/${objectType}${params}`);
|
||||
}
|
||||
|
||||
export interface UpdateApplicationOptions {
|
||||
/** The _jiraUpdatedAt from when the application was loaded for editing */
|
||||
originalUpdatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update application with optional conflict detection
|
||||
*
|
||||
* @throws {ApiError} with status 409 if there's a conflict
|
||||
*/
|
||||
export async function updateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
@@ -58,7 +150,41 @@ export async function updateApplication(
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationSubteam?: ReferenceValue;
|
||||
applicationTeam?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
applicationManagementHosting?: string;
|
||||
applicationManagementTAM?: string;
|
||||
overrideFTE?: number | null;
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
},
|
||||
options?: UpdateApplicationOptions
|
||||
): Promise<ApplicationDetails> {
|
||||
const body = options?.originalUpdatedAt
|
||||
? { updates, _jiraUpdatedAt: options.originalUpdatedAt }
|
||||
: updates;
|
||||
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Force update application (ignore conflicts)
|
||||
*/
|
||||
export async function forceUpdateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationSubteam?: ReferenceValue;
|
||||
applicationTeam?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
@@ -68,7 +194,7 @@ export async function updateApplication(
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
}
|
||||
): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}/force`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
@@ -94,7 +220,55 @@ export async function getApplicationHistory(id: string): Promise<ClassificationR
|
||||
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cache Management
|
||||
// =============================================================================
|
||||
|
||||
export interface CacheStatus {
|
||||
cache: {
|
||||
totalObjects: number;
|
||||
objectsByType: Record<string, number>;
|
||||
totalRelations: number;
|
||||
lastFullSync: string | null;
|
||||
lastIncrementalSync: string | null;
|
||||
isWarm: boolean;
|
||||
dbSizeBytes: number;
|
||||
};
|
||||
sync: {
|
||||
isRunning: boolean;
|
||||
isSyncing: boolean;
|
||||
lastFullSync: string | null;
|
||||
lastIncrementalSync: string | null;
|
||||
nextIncrementalSync: string | null;
|
||||
incrementalInterval: number;
|
||||
};
|
||||
supportedTypes: string[];
|
||||
}
|
||||
|
||||
export async function getCacheStatus(): Promise<CacheStatus> {
|
||||
return fetchApi<CacheStatus>('/cache/status');
|
||||
}
|
||||
|
||||
export async function triggerSync(): Promise<{ status: string; message: string }> {
|
||||
return fetchApi<{ status: string; message: string }>('/cache/sync', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function triggerTypeSync(objectType: string): Promise<{
|
||||
status: string;
|
||||
objectType: string;
|
||||
stats: { objectsProcessed: number; relationsExtracted: number; duration: number };
|
||||
}> {
|
||||
return fetchApi(`/cache/sync/${objectType}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AI Provider type
|
||||
// =============================================================================
|
||||
|
||||
export type AIProvider = 'claude' | 'openai';
|
||||
|
||||
// AI Status response type
|
||||
@@ -112,7 +286,10 @@ export interface AIStatusResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Classifications
|
||||
// =============================================================================
|
||||
|
||||
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
|
||||
const url = provider
|
||||
? `/classifications/suggest/${id}?provider=${provider}`
|
||||
@@ -144,7 +321,10 @@ export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
|
||||
return fetchApi(`/classifications/prompt/${id}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reference Data
|
||||
// =============================================================================
|
||||
|
||||
export async function getReferenceData(): Promise<{
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
@@ -153,12 +333,14 @@ export async function getReferenceData(): Promise<{
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationSubteams: ReferenceValue[];
|
||||
applicationTeams: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
businessImpactAnalyses: ReferenceValue[];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationManagementTAM: ReferenceValue[];
|
||||
subteamToTeamMapping: Record<string, ReferenceValue | null>;
|
||||
}> {
|
||||
return fetchApi('/reference-data');
|
||||
}
|
||||
@@ -191,8 +373,8 @@ export async function getHostingTypes(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
|
||||
}
|
||||
|
||||
export async function getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters');
|
||||
export async function getApplicationSubteams(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-subteams');
|
||||
}
|
||||
|
||||
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||
@@ -211,12 +393,18 @@ export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config
|
||||
// =============================================================================
|
||||
|
||||
export async function getConfig(): Promise<{ jiraHost: string }> {
|
||||
return fetchApi<{ jiraHost: string }>('/config');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dashboard
|
||||
// =============================================================================
|
||||
|
||||
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
|
||||
const params = forceRefresh ? '?refresh=true' : '';
|
||||
return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
|
||||
@@ -226,7 +414,10 @@ export async function getRecentClassifications(limit: number = 10): Promise<Clas
|
||||
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Team Dashboard
|
||||
// =============================================================================
|
||||
|
||||
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||
const params = new URLSearchParams();
|
||||
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
|
||||
@@ -235,7 +426,10 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[]
|
||||
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
export interface EffortCalculationConfig {
|
||||
governanceModelRules: Array<{
|
||||
governanceModel: string;
|
||||
@@ -365,7 +559,10 @@ export async function updateEffortCalculationConfigV25(config: EffortCalculation
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AI Chat
|
||||
// =============================================================================
|
||||
|
||||
import type { ChatMessage, ChatResponse } from '../types';
|
||||
|
||||
export async function sendChatMessage(
|
||||
@@ -389,3 +586,98 @@ export async function clearConversation(conversationId: string): Promise<{ succe
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CMDB Search
|
||||
// =============================================================================
|
||||
|
||||
export interface CMDBSearchObjectType {
|
||||
id: number;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export interface CMDBSearchResultAttribute {
|
||||
id: number;
|
||||
name: string;
|
||||
objectTypeAttributeId: number;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface CMDBSearchResult {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
objectTypeId: number;
|
||||
avatarUrl: string;
|
||||
attributes: CMDBSearchResultAttribute[];
|
||||
}
|
||||
|
||||
export interface CMDBSearchResponse {
|
||||
metadata: {
|
||||
count: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
criteria: { query: string; type: string; schema: number };
|
||||
};
|
||||
objectTypes: CMDBSearchObjectType[];
|
||||
results: CMDBSearchResult[];
|
||||
}
|
||||
|
||||
// CMDB free-text search
|
||||
export async function searchCMDB(query: string, limit: number = 10000): Promise<CMDBSearchResponse> {
|
||||
return fetchApi<CMDBSearchResponse>(`/search?query=${encodeURIComponent(query)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schema / Data Model
|
||||
// =============================================================================
|
||||
|
||||
export interface SchemaAttributeDefinition {
|
||||
jiraId: number;
|
||||
name: string;
|
||||
fieldName: string;
|
||||
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
|
||||
isMultiple: boolean;
|
||||
isEditable: boolean;
|
||||
isRequired: boolean;
|
||||
isSystem: boolean;
|
||||
referenceTypeId?: number;
|
||||
referenceTypeName?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SchemaObjectTypeDefinition {
|
||||
jiraTypeId: number;
|
||||
name: string;
|
||||
typeName: string;
|
||||
syncPriority: number;
|
||||
objectCount: number;
|
||||
attributes: SchemaAttributeDefinition[];
|
||||
incomingLinks: Array<{
|
||||
fromType: string;
|
||||
fromTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}>;
|
||||
outgoingLinks: Array<{
|
||||
toType: string;
|
||||
toTypeName: string;
|
||||
attributeName: string;
|
||||
isMultiple: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SchemaResponse {
|
||||
metadata: {
|
||||
generatedAt: string;
|
||||
objectTypeCount: number;
|
||||
totalAttributes: number;
|
||||
};
|
||||
objectTypes: Record<string, SchemaObjectTypeDefinition>;
|
||||
}
|
||||
|
||||
export async function getSchema(): Promise<SchemaResponse> {
|
||||
return fetchApi<SchemaResponse>('/schema');
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface User {
|
||||
}
|
||||
|
||||
interface AuthConfig {
|
||||
// The configured authentication method
|
||||
authMethod: 'pat' | 'oauth' | 'none';
|
||||
// Legacy fields (for backward compatibility)
|
||||
oauthEnabled: boolean;
|
||||
serviceAccountEnabled: boolean;
|
||||
jiraHost: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SearchState {
|
||||
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationSubteam: (value: 'all' | 'filled' | 'empty' | string) => void;
|
||||
setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setOrganisation: (value: string | undefined) => void;
|
||||
setHostingType: (value: string | undefined) => void;
|
||||
@@ -40,7 +40,7 @@ const defaultFilters: SearchFilters = {
|
||||
governanceModel: 'all',
|
||||
dynamicsFactor: 'all',
|
||||
complexityFactor: 'all',
|
||||
applicationCluster: 'all',
|
||||
applicationSubteam: 'all',
|
||||
applicationType: 'all',
|
||||
organisation: undefined,
|
||||
hostingType: undefined,
|
||||
@@ -88,9 +88,9 @@ export const useSearchStore = create<SearchState>((set) => ({
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationCluster: (value) =>
|
||||
setApplicationSubteam: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationCluster: value },
|
||||
filters: { ...state.filters, applicationSubteam: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface ReferenceValue {
|
||||
remarks?: string; // Remarks attribute for Governance Model
|
||||
application?: string; // Application attribute for Governance Model
|
||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
|
||||
}
|
||||
|
||||
// Application list item (summary view)
|
||||
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
|
||||
governanceModel: ReferenceValue | null;
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationSubteam: ReferenceValue | null;
|
||||
applicationTeam: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
|
||||
complexityFactor: ReferenceValue | null;
|
||||
numberOfUsers: ReferenceValue | null;
|
||||
governanceModel: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationSubteam: ReferenceValue | null;
|
||||
applicationTeam: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
@@ -92,7 +95,7 @@ export interface SearchFilters {
|
||||
governanceModel?: 'all' | 'filled' | 'empty';
|
||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||
complexityFactor?: 'all' | 'filled' | 'empty';
|
||||
applicationCluster?: 'all' | 'filled' | 'empty';
|
||||
applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name
|
||||
applicationType?: 'all' | 'filled' | 'empty';
|
||||
organisation?: string;
|
||||
hostingType?: string;
|
||||
@@ -168,7 +171,8 @@ export interface PendingChanges {
|
||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
}
|
||||
|
||||
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationSubteams: ReferenceValue[];
|
||||
applicationTeams: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
@@ -220,9 +225,12 @@ export interface ZiraTaxonomy {
|
||||
|
||||
// Dashboard statistics
|
||||
export interface DashboardStats {
|
||||
totalApplications: number;
|
||||
totalApplications: number; // Excluding Closed/Deprecated
|
||||
totalAllApplications: number; // Including all statuses (for status distribution)
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
withApplicationFunction: number;
|
||||
applicationFunctionPercentage: number;
|
||||
byStatus: Record<string, number>;
|
||||
byDomain: Record<string, number>;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
@@ -284,8 +292,9 @@ export interface PlatformWithWorkloads {
|
||||
totalEffort: number; // platformEffort + workloadsEffort
|
||||
}
|
||||
|
||||
export interface TeamDashboardCluster {
|
||||
cluster: ReferenceValue | null;
|
||||
// Subteam level in team dashboard hierarchy
|
||||
export interface TeamDashboardSubteam {
|
||||
subteam: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
@@ -295,17 +304,21 @@ export interface TeamDashboardCluster {
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
}
|
||||
|
||||
// Team level in team dashboard hierarchy (contains subteams)
|
||||
export interface TeamDashboardTeam {
|
||||
team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf"
|
||||
subteams: TeamDashboardSubteam[];
|
||||
// Aggregated KPIs (sum of all subteams)
|
||||
totalEffort: number;
|
||||
minEffort: number;
|
||||
maxEffort: number;
|
||||
applicationCount: number;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TeamDashboardData {
|
||||
clusters: TeamDashboardCluster[];
|
||||
unassigned: {
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
};
|
||||
teams: TeamDashboardTeam[];
|
||||
unassigned: TeamDashboardSubteam; // Apps without team assignment
|
||||
}
|
||||
|
||||
// Chat message for AI conversation
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"dev:backend": "npm run dev --workspace=backend",
|
||||
"dev:frontend": "npm run dev --workspace=frontend",
|
||||
"build": "npm run build --workspaces",
|
||||
"start": "npm run start --workspace=backend"
|
||||
"start": "npm run start --workspace=backend",
|
||||
"generate-schema": "npm run generate-schema --workspace=backend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
|
||||
Reference in New Issue
Block a user