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
|
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
|
# Claude API
|
||||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
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
|
│ ├── App.tsx # Main component with routing
|
||||||
│ ├── index.css # Tailwind CSS imports
|
│ ├── index.css # Tailwind CSS imports
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── Dashboard.tsx # Overview statistics
|
│ │ ├── SearchDashboard.tsx # Main dashboard with CMDB search
|
||||||
│ │ ├── ApplicationList.tsx # Search & filter view
|
│ │ ├── Dashboard.tsx # App Component statistics
|
||||||
│ │ └── ApplicationDetail.tsx # Edit & AI classify
|
│ │ ├── 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
|
│ ├── services/api.ts # API client
|
||||||
│ ├── stores/
|
│ ├── stores/
|
||||||
│ │ ├── searchStore.ts # Filter state (Zustand)
|
│ │ ├── searchStore.ts # Filter state (Zustand)
|
||||||
│ │ └── navigationStore.ts # Navigation state
|
│ │ ├── navigationStore.ts # Navigation state
|
||||||
|
│ │ └── authStore.ts # Authentication state
|
||||||
│ └── types/index.ts # TypeScript interfaces
|
│ └── types/index.ts # TypeScript interfaces
|
||||||
└── data/
|
└── data/
|
||||||
├── zira-taxonomy.json
|
├── zira-taxonomy.json
|
||||||
└── management-parameters.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
|
## Key Domain Concepts
|
||||||
|
|
||||||
### ZiRA (Ziekenhuis Referentie Architectuur)
|
### ZiRA (Ziekenhuis Referentie Architectuur)
|
||||||
@@ -134,17 +156,21 @@ Dutch hospital reference architecture with 90+ application functions organized i
|
|||||||
```env
|
```env
|
||||||
# Jira Data Center
|
# Jira Data Center
|
||||||
JIRA_HOST=https://jira.zuyderland.nl
|
JIRA_HOST=https://jira.zuyderland.nl
|
||||||
JIRA_PAT=<personal_access_token> # Service account PAT (fallback when OAuth disabled)
|
|
||||||
JIRA_SCHEMA_ID=<schema_id>
|
JIRA_SCHEMA_ID=<schema_id>
|
||||||
|
|
||||||
# Jira OAuth 2.0 (optional - enables user authentication)
|
# Jira Authentication Method: 'pat' or 'oauth'
|
||||||
JIRA_OAUTH_ENABLED=false # Set to 'true' to enable 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_ID=<oauth_client_id> # From Jira Application Link
|
||||||
JIRA_OAUTH_CLIENT_SECRET=<oauth_secret> # 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_CALLBACK_URL=http://localhost:3001/api/auth/callback
|
||||||
JIRA_OAUTH_SCOPES=READ WRITE
|
JIRA_OAUTH_SCOPES=READ WRITE
|
||||||
|
|
||||||
# Session Configuration
|
# Session Configuration (required for OAuth)
|
||||||
SESSION_SECRET=<random_secret_string> # Change in production!
|
SESSION_SECRET=<random_secret_string> # Change in production!
|
||||||
|
|
||||||
# Jira Object Type IDs
|
# Jira Object Type IDs
|
||||||
@@ -179,17 +205,34 @@ FRONTEND_URL=http://localhost:5173
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The application supports two authentication modes:
|
The application supports two authentication methods, configured via `JIRA_AUTH_METHOD`:
|
||||||
|
|
||||||
### 1. Service Account Mode (Default)
|
### 1. Personal Access Token (PAT) Mode (`JIRA_AUTH_METHOD=pat`)
|
||||||
- Uses a single PAT (`JIRA_PAT`) for all Jira API calls
|
- **Default mode** - Uses a single PAT for all Jira API calls
|
||||||
- Users don't need to log in
|
- Users don't need to log in
|
||||||
- All changes are attributed to the service account
|
- 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
|
- Each user logs in with their own Jira credentials
|
||||||
- API calls are made under the user's account
|
- API calls are made under the user's account
|
||||||
- Better audit trail and access control
|
- 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+)
|
### 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`
|
- Set Redirect URL: `http://localhost:3001/api/auth/callback`
|
||||||
- Note the Client ID and Secret
|
- Note the Client ID and Secret
|
||||||
|
|
||||||
2. **Configure Environment:**
|
2. **Configure Environment Variables** (see above)
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **For Production:**
|
3. **For Production:**
|
||||||
- Update callback URL to production domain
|
- Update callback URL to production domain
|
||||||
- Set `SESSION_SECRET` to a random string
|
- Set `SESSION_SECRET` to a cryptographically secure random string
|
||||||
- Use HTTPS
|
- Use HTTPS
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"generate-schema": "tsx scripts/generate-schema.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.32.1",
|
"@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 dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
// Load .env from project root
|
// Get __dirname equivalent for ES modules
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
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 {
|
interface Config {
|
||||||
// Jira Assets
|
// Jira Assets
|
||||||
jiraHost: string;
|
jiraHost: string;
|
||||||
jiraPat: string;
|
|
||||||
jiraSchemaId: string;
|
jiraSchemaId: string;
|
||||||
|
|
||||||
// Jira OAuth 2.0 Configuration
|
// Jira Authentication Method ('pat' or 'oauth')
|
||||||
jiraOAuthEnabled: boolean;
|
jiraAuthMethod: JiraAuthMethod;
|
||||||
|
|
||||||
|
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
|
||||||
|
jiraPat: string;
|
||||||
|
|
||||||
|
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
|
||||||
jiraOAuthClientId: string;
|
jiraOAuthClientId: string;
|
||||||
jiraOAuthClientSecret: string;
|
jiraOAuthClientSecret: string;
|
||||||
jiraOAuthCallbackUrl: string;
|
jiraOAuthCallbackUrl: string;
|
||||||
@@ -20,38 +45,6 @@ interface Config {
|
|||||||
// Session Configuration
|
// Session Configuration
|
||||||
sessionSecret: string;
|
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
|
// AI API Keys
|
||||||
anthropicApiKey: string;
|
anthropicApiKey: string;
|
||||||
openaiApiKey: string;
|
openaiApiKey: string;
|
||||||
@@ -72,26 +65,44 @@ interface Config {
|
|||||||
jiraApiBatchSize: number;
|
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 {
|
function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
|
||||||
return process.env[name] || defaultValue;
|
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 = {
|
export const config: Config = {
|
||||||
// Jira Assets
|
// Jira Assets
|
||||||
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
|
||||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
|
||||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||||
|
|
||||||
// Jira OAuth 2.0 Configuration
|
// Jira Authentication Method
|
||||||
jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true',
|
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'),
|
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||||
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||||
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
|
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
|
||||||
@@ -100,38 +111,6 @@ export const config: Config = {
|
|||||||
// Session Configuration
|
// Session Configuration
|
||||||
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
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
|
// AI API Keys
|
||||||
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
|
||||||
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
|
||||||
@@ -154,10 +133,34 @@ export const config: Config = {
|
|||||||
|
|
||||||
export function validateConfig(): void {
|
export function validateConfig(): void {
|
||||||
const missingVars: string[] = [];
|
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.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) {
|
if (missingVars.length > 0) {
|
||||||
console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`);
|
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 { config, validateConfig } from './config/env.js';
|
||||||
import { logger } from './services/logger.js';
|
import { logger } from './services/logger.js';
|
||||||
import { dataService } from './services/dataService.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 applicationsRouter from './routes/applications.js';
|
||||||
import classificationsRouter from './routes/classifications.js';
|
import classificationsRouter from './routes/classifications.js';
|
||||||
import referenceDataRouter from './routes/referenceData.js';
|
import referenceDataRouter from './routes/referenceData.js';
|
||||||
import dashboardRouter from './routes/dashboard.js';
|
import dashboardRouter from './routes/dashboard.js';
|
||||||
import configurationRouter from './routes/configuration.js';
|
import configurationRouter from './routes/configuration.js';
|
||||||
import authRouter, { authMiddleware } from './routes/auth.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
|
// Validate configuration
|
||||||
validateConfig();
|
validateConfig();
|
||||||
@@ -50,16 +55,16 @@ app.use((req, res, next) => {
|
|||||||
// Auth middleware - extract session info for all requests
|
// Auth middleware - extract session info for all requests
|
||||||
app.use(authMiddleware);
|
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) => {
|
app.use((req, res, next) => {
|
||||||
// Set user's OAuth token if available
|
// Set user's OAuth token if available
|
||||||
if (req.accessToken) {
|
if (req.accessToken) {
|
||||||
jiraAssetsService.setRequestToken(req.accessToken);
|
cmdbService.setUserToken(req.accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear token after response is sent
|
// Clear token after response is sent
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
jiraAssetsService.clearRequestToken();
|
cmdbService.clearUserToken();
|
||||||
});
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -68,12 +73,19 @@ app.use((req, res, next) => {
|
|||||||
// Health check
|
// Health check
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
const jiraConnected = await dataService.testConnection();
|
const jiraConnected = await dataService.testConnection();
|
||||||
|
const cacheStatus = dataService.getCacheStatus();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets' : 'mock-data',
|
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
|
||||||
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
|
||||||
aiConfigured: !!config.anthropicApiKey,
|
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/reference-data', referenceDataRouter);
|
||||||
app.use('/api/dashboard', dashboardRouter);
|
app.use('/api/dashboard', dashboardRouter);
|
||||||
app.use('/api/configuration', configurationRouter);
|
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
|
// Error handling
|
||||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
@@ -108,20 +124,33 @@ app.use((req, res) => {
|
|||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
const PORT = config.port;
|
const PORT = config.port;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, async () => {
|
||||||
logger.info(`Server running on http://localhost:${PORT}`);
|
logger.info(`Server running on http://localhost:${PORT}`);
|
||||||
logger.info(`Environment: ${config.nodeEnv}`);
|
logger.info(`Environment: ${config.nodeEnv}`);
|
||||||
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
|
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
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
const shutdown = () => {
|
||||||
logger.info('SIGTERM signal received: closing HTTP server');
|
logger.info('Shutdown signal received: stopping services...');
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
// Stop sync engine
|
||||||
logger.info('SIGINT signal received: closing HTTP server');
|
syncEngine.stop();
|
||||||
|
|
||||||
|
logger.info('Services stopped, exiting');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { dataService } from '../services/dataService.js';
|
import { dataService } from '../services/dataService.js';
|
||||||
import { databaseService } from '../services/database.js';
|
import { databaseService } from '../services/database.js';
|
||||||
|
import { cmdbService } from '../services/cmdbService.js';
|
||||||
import { logger } from '../services/logger.js';
|
import { logger } from '../services/logger.js';
|
||||||
import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
|
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
|
||||||
import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -50,9 +52,12 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get application by ID
|
// 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) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
const mode = req.query.mode as string | undefined;
|
||||||
|
|
||||||
// Don't treat special routes as application IDs
|
// Don't treat special routes as application IDs
|
||||||
if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') {
|
if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') {
|
||||||
@@ -60,7 +65,10 @@ router.get('/:id', async (req: Request, res: Response) => {
|
|||||||
return;
|
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) {
|
if (!application) {
|
||||||
res.status(404).json({ error: 'Application not found' });
|
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) => {
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body as {
|
const { updates, _jiraUpdatedAt } = req.body as {
|
||||||
|
updates?: {
|
||||||
applicationFunctions?: ReferenceValue[];
|
applicationFunctions?: ReferenceValue[];
|
||||||
dynamicsFactor?: ReferenceValue;
|
dynamicsFactor?: ReferenceValue;
|
||||||
complexityFactor?: ReferenceValue;
|
complexityFactor?: ReferenceValue;
|
||||||
numberOfUsers?: ReferenceValue;
|
numberOfUsers?: ReferenceValue;
|
||||||
governanceModel?: 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';
|
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);
|
const application = await dataService.getApplicationById(id);
|
||||||
if (!application) {
|
if (!application) {
|
||||||
@@ -95,61 +117,111 @@ router.put('/:id', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Build changes object for history
|
// Build changes object for history
|
||||||
const changes: ClassificationResult['changes'] = {};
|
const changes: ClassificationResult['changes'] = {};
|
||||||
if (updates.applicationFunctions) {
|
if (actualUpdates.applicationFunctions) {
|
||||||
changes.applicationFunctions = {
|
changes.applicationFunctions = {
|
||||||
from: application.applicationFunctions,
|
from: application.applicationFunctions,
|
||||||
to: updates.applicationFunctions,
|
to: actualUpdates.applicationFunctions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (updates.dynamicsFactor) {
|
if (actualUpdates.dynamicsFactor) {
|
||||||
changes.dynamicsFactor = {
|
changes.dynamicsFactor = {
|
||||||
from: application.dynamicsFactor,
|
from: application.dynamicsFactor,
|
||||||
to: updates.dynamicsFactor,
|
to: actualUpdates.dynamicsFactor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (updates.complexityFactor) {
|
if (actualUpdates.complexityFactor) {
|
||||||
changes.complexityFactor = {
|
changes.complexityFactor = {
|
||||||
from: application.complexityFactor,
|
from: application.complexityFactor,
|
||||||
to: updates.complexityFactor,
|
to: actualUpdates.complexityFactor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (updates.numberOfUsers) {
|
if (actualUpdates.numberOfUsers) {
|
||||||
changes.numberOfUsers = {
|
changes.numberOfUsers = {
|
||||||
from: application.numberOfUsers,
|
from: application.numberOfUsers,
|
||||||
to: updates.numberOfUsers,
|
to: actualUpdates.numberOfUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (updates.governanceModel) {
|
if (actualUpdates.governanceModel) {
|
||||||
changes.governanceModel = {
|
changes.governanceModel = {
|
||||||
from: application.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
|
// Save to classification history
|
||||||
const classificationResult: ClassificationResult = {
|
const classificationResult: ClassificationResult = {
|
||||||
applicationId: id,
|
applicationId: id,
|
||||||
applicationName: application.name,
|
applicationName: application.name,
|
||||||
changes,
|
changes,
|
||||||
source: updates.source || 'MANUAL',
|
source: actualUpdates.source || 'MANUAL',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
databaseService.saveClassificationResult(classificationResult);
|
databaseService.saveClassificationResult(classificationResult);
|
||||||
|
|
||||||
const updatedApp = await dataService.getApplicationById(id);
|
// Return updated application
|
||||||
|
const updatedApp = result.data || await dataService.getApplicationById(id);
|
||||||
res.json(updatedApp);
|
res.json(updatedApp);
|
||||||
} else {
|
|
||||||
res.status(500).json({ error: 'Failed to update application' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to update application', error);
|
logger.error('Failed to update application', error);
|
||||||
res.status(500).json({ error: 'Failed to update application' });
|
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)
|
// Calculate FTE effort for an application (real-time calculation without saving)
|
||||||
router.post('/calculate-effort', async (req: Request, res: Response) => {
|
router.post('/calculate-effort', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -180,7 +252,8 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
|
|||||||
complexityFactor: applicationData.complexityFactor || null,
|
complexityFactor: applicationData.complexityFactor || null,
|
||||||
numberOfUsers: applicationData.numberOfUsers || null,
|
numberOfUsers: applicationData.numberOfUsers || null,
|
||||||
governanceModel: applicationData.governanceModel || null,
|
governanceModel: applicationData.governanceModel || null,
|
||||||
applicationCluster: applicationData.applicationCluster || null,
|
applicationSubteam: applicationData.applicationSubteam || null,
|
||||||
|
applicationTeam: applicationData.applicationTeam || null,
|
||||||
applicationType: applicationData.applicationType || null,
|
applicationType: applicationData.applicationType || null,
|
||||||
platform: applicationData.platform || null,
|
platform: applicationData.platform || null,
|
||||||
requiredEffortApplicationManagement: 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;
|
export default router;
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ declare global {
|
|||||||
|
|
||||||
// Get auth configuration
|
// Get auth configuration
|
||||||
router.get('/config', (req: Request, res: Response) => {
|
router.get('/config', (req: Request, res: Response) => {
|
||||||
|
const authMethod = authService.getAuthMethod();
|
||||||
res.json({
|
res.json({
|
||||||
|
// Configured authentication method ('pat', 'oauth', or 'none')
|
||||||
|
authMethod,
|
||||||
|
// Legacy fields for backward compatibility
|
||||||
oauthEnabled: authService.isOAuthEnabled(),
|
oauthEnabled: authService.isOAuthEnabled(),
|
||||||
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||||
|
// Jira host for display purposes
|
||||||
jiraHost: config.jiraHost,
|
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 { Router, Request, Response } from 'express';
|
||||||
import { readFile, writeFile } from 'fs/promises';
|
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 { logger } from '../services/logger.js';
|
||||||
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
|
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
|
||||||
import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/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();
|
const router = Router();
|
||||||
|
|
||||||
// Path to the configuration files
|
// Path to the configuration files
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { dataService } from '../services/dataService.js';
|
import { dataService } from '../services/dataService.js';
|
||||||
import { databaseService } from '../services/database.js';
|
import { databaseService } from '../services/database.js';
|
||||||
|
import { syncEngine } from '../services/syncEngine.js';
|
||||||
import { logger } from '../services/logger.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();
|
const router = Router();
|
||||||
|
|
||||||
// Simple in-memory cache for dashboard stats
|
// Simple in-memory cache for dashboard stats
|
||||||
interface CachedStats {
|
interface CachedStats {
|
||||||
data: any;
|
data: unknown;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let statsCache: CachedStats | null = null;
|
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
|
// Get dashboard statistics
|
||||||
router.get('/stats', async (req: Request, res: Response) => {
|
router.get('/stats', async (req: Request, res: Response) => {
|
||||||
@@ -24,7 +27,8 @@ router.get('/stats', async (req: Request, res: Response) => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) {
|
if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) {
|
||||||
logger.debug('Returning cached dashboard stats');
|
logger.debug('Returning cached dashboard stats');
|
||||||
return res.json(statsCache.data);
|
res.json(statsCache.data);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Dashboard: Fetching fresh stats...');
|
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 stats = await dataService.getStats(includeDistributions);
|
||||||
const dbStats = databaseService.getStats();
|
const dbStats = databaseService.getStats();
|
||||||
|
|
||||||
|
// Get cache status
|
||||||
|
const cacheStatus = dataService.getCacheStatus();
|
||||||
|
const syncStatus = syncEngine.getStatus();
|
||||||
|
|
||||||
const responseData = {
|
const responseData = {
|
||||||
...stats,
|
...stats,
|
||||||
classificationStats: dbStats,
|
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
|
// Update cache
|
||||||
@@ -53,11 +76,12 @@ router.get('/stats', async (req: Request, res: Response) => {
|
|||||||
// Return cached data if available (even if expired)
|
// Return cached data if available (even if expired)
|
||||||
if (statsCache) {
|
if (statsCache) {
|
||||||
logger.info('Dashboard: Returning stale cached data due to error');
|
logger.info('Dashboard: Returning stale cached data due to error');
|
||||||
return res.json({
|
res.json({
|
||||||
...statsCache.data,
|
...statsCache.data as object,
|
||||||
stale: true,
|
stale: true,
|
||||||
error: 'Using cached data due to API timeout',
|
error: 'Using cached data due to API timeout',
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({ error: 'Failed to get dashboard stats' });
|
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;
|
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,
|
organisations,
|
||||||
hostingTypes,
|
hostingTypes,
|
||||||
applicationFunctions,
|
applicationFunctions,
|
||||||
applicationClusters,
|
applicationSubteams,
|
||||||
|
applicationTeams,
|
||||||
applicationTypes,
|
applicationTypes,
|
||||||
businessImportance,
|
businessImportance,
|
||||||
businessImpactAnalyses,
|
businessImpactAnalyses,
|
||||||
applicationManagementHosting,
|
applicationManagementHosting,
|
||||||
applicationManagementTAM,
|
applicationManagementTAM,
|
||||||
|
subteamToTeamMapping,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
dataService.getDynamicsFactors(),
|
dataService.getDynamicsFactors(),
|
||||||
dataService.getComplexityFactors(),
|
dataService.getComplexityFactors(),
|
||||||
@@ -29,12 +31,14 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
dataService.getOrganisations(),
|
dataService.getOrganisations(),
|
||||||
dataService.getHostingTypes(),
|
dataService.getHostingTypes(),
|
||||||
dataService.getApplicationFunctions(),
|
dataService.getApplicationFunctions(),
|
||||||
dataService.getApplicationClusters(),
|
dataService.getApplicationSubteams(),
|
||||||
|
dataService.getApplicationTeams(),
|
||||||
dataService.getApplicationTypes(),
|
dataService.getApplicationTypes(),
|
||||||
dataService.getBusinessImportance(),
|
dataService.getBusinessImportance(),
|
||||||
dataService.getBusinessImpactAnalyses(),
|
dataService.getBusinessImpactAnalyses(),
|
||||||
dataService.getApplicationManagementHosting(),
|
dataService.getApplicationManagementHosting(),
|
||||||
dataService.getApplicationManagementTAM(),
|
dataService.getApplicationManagementTAM(),
|
||||||
|
dataService.getSubteamToTeamMapping(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -45,12 +49,14 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
organisations,
|
organisations,
|
||||||
hostingTypes,
|
hostingTypes,
|
||||||
applicationFunctions,
|
applicationFunctions,
|
||||||
applicationClusters,
|
applicationSubteams,
|
||||||
|
applicationTeams,
|
||||||
applicationTypes,
|
applicationTypes,
|
||||||
businessImportance,
|
businessImportance,
|
||||||
businessImpactAnalyses,
|
businessImpactAnalyses,
|
||||||
applicationManagementHosting,
|
applicationManagementHosting,
|
||||||
applicationManagementTAM,
|
applicationManagementTAM,
|
||||||
|
subteamToTeamMapping,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get reference data', 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)
|
// Get application subteams (from Jira Assets)
|
||||||
router.get('/application-clusters', async (req: Request, res: Response) => {
|
router.get('/application-subteams', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const clusters = await dataService.getApplicationClusters();
|
const subteams = await dataService.getApplicationSubteams();
|
||||||
res.json(clusters);
|
res.json(subteams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get application clusters', error);
|
logger.error('Failed to get application subteams', error);
|
||||||
res.status(500).json({ error: 'Failed to get application clusters' });
|
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;
|
return existed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if OAuth is enabled
|
// Check if OAuth is enabled (jiraAuthMethod = 'oauth')
|
||||||
isOAuthEnabled(): boolean {
|
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 {
|
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');
|
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(
|
function formatReferenceObjectsForPrompt(
|
||||||
objects: ReferenceValue[],
|
objects: ReferenceValue[],
|
||||||
useSummary: boolean = false
|
useSummary: boolean = false
|
||||||
@@ -391,10 +391,30 @@ function formatReferenceObjectsForPrompt(
|
|||||||
.join('\n');
|
.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)
|
// Format reference objects with emphasis on exact name (for fields where AI must use exact name)
|
||||||
function formatReferenceObjectsWithExactNames(
|
function formatReferenceObjectsWithExactNames(
|
||||||
objects: ReferenceValue[],
|
objects: ReferenceValue[],
|
||||||
useSummary: boolean = false
|
useDescription: boolean = false
|
||||||
): string {
|
): string {
|
||||||
if (objects.length === 0) {
|
if (objects.length === 0) {
|
||||||
return 'Geen objecten beschikbaar.';
|
return 'Geen objecten beschikbaar.';
|
||||||
@@ -402,9 +422,7 @@ function formatReferenceObjectsWithExactNames(
|
|||||||
|
|
||||||
return objects
|
return objects
|
||||||
.map((obj) => {
|
.map((obj) => {
|
||||||
const displayText = useSummary && obj.summary
|
const displayText = useDescription && obj.description ? obj.description : '';
|
||||||
? obj.summary
|
|
||||||
: obj.description || '';
|
|
||||||
// Emphasize the exact name that should be used
|
// Emphasize the exact name that should be used
|
||||||
return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`;
|
return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`;
|
||||||
})
|
})
|
||||||
@@ -892,8 +910,8 @@ class AIService {
|
|||||||
applicationFunctionCategories
|
applicationFunctionCategories
|
||||||
);
|
);
|
||||||
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
||||||
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true);
|
const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
|
||||||
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true);
|
const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
|
||||||
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
||||||
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
||||||
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);
|
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);
|
||||||
@@ -1133,8 +1151,8 @@ class AIService {
|
|||||||
applicationFunctionCategories
|
applicationFunctionCategories
|
||||||
);
|
);
|
||||||
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
|
||||||
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true);
|
const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
|
||||||
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true);
|
const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
|
||||||
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
|
||||||
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
|
||||||
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(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,
|
HostingRule,
|
||||||
} from '../config/effortCalculation.js';
|
} from '../config/effortCalculation.js';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { ApplicationDetails, EffortCalculationBreakdown } from '../types/index.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)
|
// Path to the configuration file (v25)
|
||||||
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');
|
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' },
|
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -63,7 +64,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: null,
|
governanceModel: null,
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -90,7 +92,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: null,
|
complexityFactor: null,
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -117,7 +120,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -144,7 +148,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: null,
|
governanceModel: null,
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -171,7 +176,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
|
||||||
numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' },
|
numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' },
|
||||||
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -198,7 +204,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||||
numberOfUsers: null,
|
numberOfUsers: null,
|
||||||
governanceModel: null,
|
governanceModel: null,
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -225,7 +232,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
|
||||||
numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' },
|
numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' },
|
||||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -252,7 +260,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||||
numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' },
|
numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' },
|
||||||
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -279,7 +288,8 @@ const mockApplications: ApplicationDetails[] = [
|
|||||||
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
|
||||||
numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' },
|
numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' },
|
||||||
governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' },
|
governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' },
|
||||||
applicationCluster: null,
|
applicationSubteam: null,
|
||||||
|
applicationTeam: null,
|
||||||
applicationType: null,
|
applicationType: null,
|
||||||
platform: null,
|
platform: null,
|
||||||
requiredEffortApplicationManagement: null,
|
requiredEffortApplicationManagement: null,
|
||||||
@@ -347,10 +357,10 @@ const mockBusinessImpactAnalyses: ReferenceValue[] = [
|
|||||||
{ objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
|
{ objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockApplicationClusters: ReferenceValue[] = [
|
const mockApplicationSubteams: ReferenceValue[] = [
|
||||||
{ objectId: '1', key: 'CLUSTER-1', name: 'Zorgapplicaties' },
|
{ objectId: '1', key: 'SUBTEAM-1', name: 'Zorgapplicaties' },
|
||||||
{ objectId: '2', key: 'CLUSTER-2', name: 'Bedrijfsvoering' },
|
{ objectId: '2', key: 'SUBTEAM-2', name: 'Bedrijfsvoering' },
|
||||||
{ objectId: '3', key: 'CLUSTER-3', name: 'Infrastructuur' },
|
{ objectId: '3', key: 'SUBTEAM-3', name: 'Infrastructuur' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockApplicationTypes: ReferenceValue[] = [
|
const mockApplicationTypes: ReferenceValue[] = [
|
||||||
@@ -420,11 +430,11 @@ export class MockDataService {
|
|||||||
filtered = filtered.filter((app) => !!app.complexityFactor);
|
filtered = filtered.filter((app) => !!app.complexityFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applicationCluster filter
|
// Apply applicationSubteam filter
|
||||||
if (filters.applicationCluster === 'empty') {
|
if (filters.applicationSubteam === 'empty') {
|
||||||
filtered = filtered.filter((app) => !app.applicationCluster);
|
filtered = filtered.filter((app) => !app.applicationSubteam);
|
||||||
} else if (filters.applicationCluster === 'filled') {
|
} else if (filters.applicationSubteam === 'filled') {
|
||||||
filtered = filtered.filter((app) => !!app.applicationCluster);
|
filtered = filtered.filter((app) => !!app.applicationSubteam);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply applicationType filter
|
// Apply applicationType filter
|
||||||
@@ -468,7 +478,8 @@ export class MockDataService {
|
|||||||
governanceModel: app.governanceModel,
|
governanceModel: app.governanceModel,
|
||||||
dynamicsFactor: app.dynamicsFactor,
|
dynamicsFactor: app.dynamicsFactor,
|
||||||
complexityFactor: app.complexityFactor,
|
complexityFactor: app.complexityFactor,
|
||||||
applicationCluster: app.applicationCluster,
|
applicationSubteam: app.applicationSubteam,
|
||||||
|
applicationTeam: app.applicationTeam,
|
||||||
applicationType: app.applicationType,
|
applicationType: app.applicationType,
|
||||||
platform: app.platform,
|
platform: app.platform,
|
||||||
requiredEffortApplicationManagement: effort,
|
requiredEffortApplicationManagement: effort,
|
||||||
@@ -501,7 +512,8 @@ export class MockDataService {
|
|||||||
complexityFactor?: ReferenceValue;
|
complexityFactor?: ReferenceValue;
|
||||||
numberOfUsers?: ReferenceValue;
|
numberOfUsers?: ReferenceValue;
|
||||||
governanceModel?: ReferenceValue;
|
governanceModel?: ReferenceValue;
|
||||||
applicationCluster?: ReferenceValue;
|
applicationSubteam?: ReferenceValue;
|
||||||
|
applicationTeam?: ReferenceValue;
|
||||||
applicationType?: ReferenceValue;
|
applicationType?: ReferenceValue;
|
||||||
hostingType?: ReferenceValue;
|
hostingType?: ReferenceValue;
|
||||||
businessImpactAnalyse?: ReferenceValue;
|
businessImpactAnalyse?: ReferenceValue;
|
||||||
@@ -527,8 +539,11 @@ export class MockDataService {
|
|||||||
if (updates.governanceModel !== undefined) {
|
if (updates.governanceModel !== undefined) {
|
||||||
app.governanceModel = updates.governanceModel;
|
app.governanceModel = updates.governanceModel;
|
||||||
}
|
}
|
||||||
if (updates.applicationCluster !== undefined) {
|
if (updates.applicationSubteam !== undefined) {
|
||||||
app.applicationCluster = updates.applicationCluster;
|
app.applicationSubteam = updates.applicationSubteam;
|
||||||
|
}
|
||||||
|
if (updates.applicationTeam !== undefined) {
|
||||||
|
app.applicationTeam = updates.applicationTeam;
|
||||||
}
|
}
|
||||||
if (updates.applicationType !== undefined) {
|
if (updates.applicationType !== undefined) {
|
||||||
app.applicationType = updates.applicationType;
|
app.applicationType = updates.applicationType;
|
||||||
@@ -539,12 +554,6 @@ export class MockDataService {
|
|||||||
if (updates.businessImpactAnalyse !== undefined) {
|
if (updates.businessImpactAnalyse !== undefined) {
|
||||||
app.businessImpactAnalyse = updates.businessImpactAnalyse;
|
app.businessImpactAnalyse = updates.businessImpactAnalyse;
|
||||||
}
|
}
|
||||||
if (updates.applicationCluster !== undefined) {
|
|
||||||
app.applicationCluster = updates.applicationCluster;
|
|
||||||
}
|
|
||||||
if (updates.applicationType !== undefined) {
|
|
||||||
app.applicationType = updates.applicationType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -601,7 +610,7 @@ export class MockDataService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApplicationClusters(): Promise<ReferenceValue[]> {
|
async getApplicationSubteams(): Promise<ReferenceValue[]> {
|
||||||
// Return empty for mock - in real implementation, this comes from Jira
|
// Return empty for mock - in real implementation, this comes from Jira
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -671,7 +680,8 @@ export class MockDataService {
|
|||||||
governanceModel: app.governanceModel,
|
governanceModel: app.governanceModel,
|
||||||
dynamicsFactor: app.dynamicsFactor,
|
dynamicsFactor: app.dynamicsFactor,
|
||||||
complexityFactor: app.complexityFactor,
|
complexityFactor: app.complexityFactor,
|
||||||
applicationCluster: app.applicationCluster,
|
applicationSubteam: app.applicationSubteam,
|
||||||
|
applicationTeam: app.applicationTeam,
|
||||||
applicationType: app.applicationType,
|
applicationType: app.applicationType,
|
||||||
platform: app.platform,
|
platform: app.platform,
|
||||||
requiredEffortApplicationManagement: app.requiredEffortApplicationManagement,
|
requiredEffortApplicationManagement: app.requiredEffortApplicationManagement,
|
||||||
@@ -726,8 +736,8 @@ export class MockDataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group all applications (regular + platforms + workloads) by cluster
|
// Group all applications (regular + platforms + workloads) by subteam
|
||||||
const clusterMap = new Map<string, {
|
const subteamMap = new Map<string, {
|
||||||
regular: ApplicationListItem[];
|
regular: ApplicationListItem[];
|
||||||
platforms: import('../types/index.js').PlatformWithWorkloads[];
|
platforms: import('../types/index.js').PlatformWithWorkloads[];
|
||||||
}>();
|
}>();
|
||||||
@@ -739,39 +749,39 @@ export class MockDataService {
|
|||||||
platforms: [],
|
platforms: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group regular applications by cluster
|
// Group regular applications by subteam
|
||||||
for (const app of regularApplications) {
|
for (const app of regularApplications) {
|
||||||
if (app.applicationCluster) {
|
if (app.applicationSubteam) {
|
||||||
const clusterId = app.applicationCluster.objectId;
|
const subteamId = app.applicationSubteam.objectId;
|
||||||
if (!clusterMap.has(clusterId)) {
|
if (!subteamMap.has(subteamId)) {
|
||||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
subteamMap.set(subteamId, { regular: [], platforms: [] });
|
||||||
}
|
}
|
||||||
clusterMap.get(clusterId)!.regular.push(app);
|
subteamMap.get(subteamId)!.regular.push(app);
|
||||||
} else {
|
} else {
|
||||||
unassigned.regular.push(app);
|
unassigned.regular.push(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group platforms by cluster
|
// Group platforms by subteam
|
||||||
for (const platformWithWorkloads of platformsWithWorkloads) {
|
for (const platformWithWorkloads of platformsWithWorkloads) {
|
||||||
const platform = platformWithWorkloads.platform;
|
const platform = platformWithWorkloads.platform;
|
||||||
if (platform.applicationCluster) {
|
if (platform.applicationSubteam) {
|
||||||
const clusterId = platform.applicationCluster.objectId;
|
const subteamId = platform.applicationSubteam.objectId;
|
||||||
if (!clusterMap.has(clusterId)) {
|
if (!subteamMap.has(subteamId)) {
|
||||||
clusterMap.set(clusterId, { regular: [], platforms: [] });
|
subteamMap.set(subteamId, { regular: [], platforms: [] });
|
||||||
}
|
}
|
||||||
clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads);
|
subteamMap.get(subteamId)!.platforms.push(platformWithWorkloads);
|
||||||
} else {
|
} else {
|
||||||
unassigned.platforms.push(platformWithWorkloads);
|
unassigned.platforms.push(platformWithWorkloads);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all clusters
|
// Build subteams from mock data
|
||||||
const allClusters = mockApplicationClusters;
|
const allSubteams = mockApplicationSubteams;
|
||||||
const clusters = allClusters.map(cluster => {
|
const subteams: import('../types/index.js').TeamDashboardSubteam[] = allSubteams.map(subteamRef => {
|
||||||
const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] };
|
const subteamData = subteamMap.get(subteamRef.objectId) || { regular: [], platforms: [] };
|
||||||
const regularApps = clusterData.regular;
|
const regularApps = subteamData.regular;
|
||||||
const platforms = clusterData.platforms;
|
const platforms = subteamData.platforms;
|
||||||
|
|
||||||
// Calculate total effort: regular apps + platforms (including their workloads)
|
// Calculate total effort: regular apps + platforms (including their workloads)
|
||||||
const regularEffort = regularApps.reduce((sum, app) =>
|
const regularEffort = regularApps.reduce((sum, app) =>
|
||||||
@@ -803,7 +813,7 @@ export class MockDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cluster,
|
subteam: subteamRef,
|
||||||
applications: regularApps,
|
applications: regularApps,
|
||||||
platforms,
|
platforms,
|
||||||
totalEffort,
|
totalEffort,
|
||||||
@@ -812,7 +822,28 @@ export class MockDataService {
|
|||||||
applicationCount,
|
applicationCount,
|
||||||
byGovernanceModel,
|
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
|
// Calculate unassigned totals
|
||||||
const unassignedRegularEffort = unassigned.regular.reduce((sum, app) =>
|
const unassignedRegularEffort = unassigned.regular.reduce((sum, app) =>
|
||||||
@@ -842,8 +873,9 @@ export class MockDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clusters,
|
teams: subteams.length > 0 ? [virtualTeam] : [],
|
||||||
unassigned: {
|
unassigned: {
|
||||||
|
subteam: null,
|
||||||
applications: unassigned.regular,
|
applications: unassigned.regular,
|
||||||
platforms: unassigned.platforms,
|
platforms: unassigned.platforms,
|
||||||
totalEffort: unassignedTotalEffort,
|
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
|
remarks?: string; // Remarks attribute for Governance Model
|
||||||
application?: string; // Application attribute for Governance Model
|
application?: string; // Application attribute for Governance Model
|
||||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||||
|
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Application list item (summary view)
|
// Application list item (summary view)
|
||||||
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
|
|||||||
governanceModel: ReferenceValue | null;
|
governanceModel: ReferenceValue | null;
|
||||||
dynamicsFactor: ReferenceValue | null;
|
dynamicsFactor: ReferenceValue | null;
|
||||||
complexityFactor: ReferenceValue | null;
|
complexityFactor: ReferenceValue | null;
|
||||||
applicationCluster: ReferenceValue | null;
|
applicationSubteam: ReferenceValue | null;
|
||||||
|
applicationTeam: ReferenceValue | null;
|
||||||
applicationType: ReferenceValue | null;
|
applicationType: ReferenceValue | null;
|
||||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||||
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
|
|||||||
complexityFactor: ReferenceValue | null;
|
complexityFactor: ReferenceValue | null;
|
||||||
numberOfUsers: ReferenceValue | null;
|
numberOfUsers: ReferenceValue | null;
|
||||||
governanceModel: ReferenceValue | null;
|
governanceModel: ReferenceValue | null;
|
||||||
applicationCluster: ReferenceValue | null;
|
applicationSubteam: ReferenceValue | null;
|
||||||
|
applicationTeam: ReferenceValue | null;
|
||||||
applicationType: ReferenceValue | null;
|
applicationType: ReferenceValue | null;
|
||||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||||
@@ -92,7 +95,7 @@ export interface SearchFilters {
|
|||||||
governanceModel?: 'all' | 'filled' | 'empty';
|
governanceModel?: 'all' | 'filled' | 'empty';
|
||||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||||
complexityFactor?: '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';
|
applicationType?: 'all' | 'filled' | 'empty';
|
||||||
organisation?: string;
|
organisation?: string;
|
||||||
hostingType?: string;
|
hostingType?: string;
|
||||||
@@ -168,7 +171,8 @@ export interface PendingChanges {
|
|||||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
governanceModel?: { 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 };
|
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
|
|||||||
numberOfUsers: ReferenceValue[];
|
numberOfUsers: ReferenceValue[];
|
||||||
governanceModels: ReferenceValue[];
|
governanceModels: ReferenceValue[];
|
||||||
applicationFunctions: ReferenceValue[];
|
applicationFunctions: ReferenceValue[];
|
||||||
applicationClusters: ReferenceValue[];
|
applicationSubteams: ReferenceValue[];
|
||||||
|
applicationTeams: ReferenceValue[];
|
||||||
applicationTypes: ReferenceValue[];
|
applicationTypes: ReferenceValue[];
|
||||||
organisations: ReferenceValue[];
|
organisations: ReferenceValue[];
|
||||||
hostingTypes: ReferenceValue[];
|
hostingTypes: ReferenceValue[];
|
||||||
@@ -297,6 +302,31 @@ export interface PlatformWithWorkloads {
|
|||||||
totalEffort: number; // platformEffort + workloadsEffort
|
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 {
|
export interface TeamDashboardCluster {
|
||||||
cluster: ReferenceValue | null;
|
cluster: ReferenceValue | null;
|
||||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||||
@@ -309,16 +339,8 @@ export interface TeamDashboardCluster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamDashboardData {
|
export interface TeamDashboardData {
|
||||||
clusters: TeamDashboardCluster[];
|
teams: TeamDashboardTeam[];
|
||||||
unassigned: {
|
unassigned: TeamDashboardSubteam; // Apps without team assignment
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jira Assets API types
|
// Jira Assets API types
|
||||||
@@ -347,6 +369,9 @@ export interface JiraAssetsAttribute {
|
|||||||
objectKey: string;
|
objectKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
status?: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +389,8 @@ export interface ApplicationUpdateRequest {
|
|||||||
complexityFactor?: string;
|
complexityFactor?: string;
|
||||||
numberOfUsers?: string;
|
numberOfUsers?: string;
|
||||||
governanceModel?: string;
|
governanceModel?: string;
|
||||||
applicationCluster?: string;
|
applicationSubteam?: string;
|
||||||
|
applicationTeam?: string;
|
||||||
applicationType?: string;
|
applicationType?: string;
|
||||||
hostingType?: string;
|
hostingType?: string;
|
||||||
businessImpactAnalyse?: string;
|
businessImpactAnalyse?: string;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="nl">
|
<html lang="nl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ZiRA Classificatie Tool - Zuyderland</title>
|
<title>CMDB Analyse Tool - Zuyderland</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { useEffect, useState, useRef } from 'react';
|
||||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import SearchDashboard from './components/SearchDashboard';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import ApplicationList from './components/ApplicationList';
|
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 TeamDashboard from './components/TeamDashboard';
|
||||||
import ConfigurationV25 from './components/ConfigurationV25';
|
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 Login from './components/Login';
|
||||||
import { useAuthStore } from './stores/authStore';
|
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() {
|
function UserMenu() {
|
||||||
const { user, authMethod, logout } = useAuthStore();
|
const { user, authMethod, logout } = useAuthStore();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -84,12 +182,32 @@ function UserMenu() {
|
|||||||
function AppContent() {
|
function AppContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const navItems = [
|
// Navigation structure
|
||||||
{ path: '/', label: 'Dashboard', exact: true },
|
const appComponentsDropdown: NavDropdown = {
|
||||||
{ path: '/applications', label: 'Applicaties', exact: false },
|
label: 'Application Component',
|
||||||
{ path: '/teams', label: 'Team-indeling', exact: true },
|
basePath: '/application',
|
||||||
{ path: '/configuration', label: 'FTE Config v25', exact: true },
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<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="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 justify-between items-center h-16">
|
||||||
<div className="flex items-center space-x-8">
|
<div className="flex items-center space-x-8">
|
||||||
<div className="flex items-center space-x-3">
|
<Link to="/" className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
|
||||||
<span className="text-white font-bold text-sm">ZiRA</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-gray-900">
|
<h1 className="text-lg font-semibold text-gray-900">
|
||||||
Classificatie Tool
|
Analyse Tool
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex space-x-1">
|
<nav className="hidden md:flex items-center space-x-1">
|
||||||
{navItems.map((item) => {
|
{/* Dashboard (Search) */}
|
||||||
const isActive = item.exact
|
|
||||||
? location.pathname === item.path
|
|
||||||
: location.pathname.startsWith(item.path);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
to="/"
|
||||||
to={item.path}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
isActive
|
isDashboardActive
|
||||||
? 'bg-blue-50 text-blue-700'
|
? 'bg-blue-50 text-blue-700'
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
);
|
|
||||||
})}
|
{/* Application Component Dropdown */}
|
||||||
|
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
|
||||||
|
|
||||||
|
{/* Reports Dropdown */}
|
||||||
|
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -142,9 +256,30 @@ function AppContent() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
{/* Main Dashboard (Search) */}
|
||||||
<Route path="/applications" element={<ApplicationList />} />
|
<Route path="/" element={<SearchDashboard />} />
|
||||||
<Route path="/applications/:id" element={<ApplicationDetail />} />
|
|
||||||
|
{/* 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="/teams" element={<TeamDashboard />} />
|
||||||
<Route path="/configuration" element={<ConfigurationV25 />} />
|
<Route path="/configuration" element={<ConfigurationV25 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -178,12 +313,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show login if OAuth is enabled and not authenticated
|
// Show login if OAuth is enabled and not authenticated
|
||||||
if (config?.oauthEnabled && !isAuthenticated) {
|
if (config?.authMethod === 'oauth' && !isAuthenticated) {
|
||||||
return <Login />;
|
return <Login />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login if nothing is configured
|
// Show login if nothing is configured
|
||||||
if (!config?.oauthEnabled && !config?.serviceAccountEnabled) {
|
if (config?.authMethod === 'none') {
|
||||||
return <Login />;
|
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,
|
setStatuses,
|
||||||
setApplicationFunction,
|
setApplicationFunction,
|
||||||
setGovernanceModel,
|
setGovernanceModel,
|
||||||
setApplicationCluster,
|
setApplicationSubteam,
|
||||||
setApplicationType,
|
setApplicationType,
|
||||||
setOrganisation,
|
setOrganisation,
|
||||||
setHostingType,
|
setHostingType,
|
||||||
@@ -45,6 +45,7 @@ export default function ApplicationList() {
|
|||||||
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
|
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
|
||||||
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
|
||||||
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
|
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
|
||||||
|
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
|
||||||
const [showFilters, setShowFilters] = useState(true);
|
const [showFilters, setShowFilters] = useState(true);
|
||||||
|
|
||||||
// Sync URL params with store on mount
|
// Sync URL params with store on mount
|
||||||
@@ -98,6 +99,7 @@ export default function ApplicationList() {
|
|||||||
setOrganisations(data.organisations);
|
setOrganisations(data.organisations);
|
||||||
setHostingTypes(data.hostingTypes);
|
setHostingTypes(data.hostingTypes);
|
||||||
setBusinessImportanceOptions(data.businessImportance || []);
|
setBusinessImportanceOptions(data.businessImportance || []);
|
||||||
|
setApplicationSubteams(data.applicationSubteams || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load reference data', err);
|
console.error('Failed to load reference data', err);
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ export default function ApplicationList() {
|
|||||||
// Only navigate programmatically for regular clicks
|
// Only navigate programmatically for regular clicks
|
||||||
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
navigate(`/applications/${app.id}`);
|
navigate(`/application/${app.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,26 +259,6 @@ export default function ApplicationList() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="label mb-2">Application Type</label>
|
<label className="label mb-2">Application Type</label>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -347,6 +329,23 @@ export default function ApplicationList() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,7 +404,7 @@ export default function ApplicationList() {
|
|||||||
>
|
>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3 text-sm text-gray-500"
|
className="block px-4 py-3 text-sm text-gray-500"
|
||||||
>
|
>
|
||||||
@@ -414,7 +413,7 @@ export default function ApplicationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3"
|
className="block px-4 py-3"
|
||||||
>
|
>
|
||||||
@@ -426,7 +425,7 @@ export default function ApplicationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3"
|
className="block px-4 py-3"
|
||||||
>
|
>
|
||||||
@@ -435,7 +434,7 @@ export default function ApplicationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3"
|
className="block px-4 py-3"
|
||||||
>
|
>
|
||||||
@@ -460,7 +459,7 @@ export default function ApplicationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3"
|
className="block px-4 py-3"
|
||||||
>
|
>
|
||||||
@@ -477,7 +476,7 @@ export default function ApplicationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0">
|
<td className="py-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/applications/${app.id}`}
|
to={`/application/${app.id}`}
|
||||||
onClick={(e) => handleRowClick(app, index, e)}
|
onClick={(e) => handleRowClick(app, index, e)}
|
||||||
className="block px-4 py-3 text-sm text-gray-900"
|
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">
|
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||||
{currentPage > 1 ? (
|
{currentPage > 1 ? (
|
||||||
<Link
|
<Link
|
||||||
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`}
|
to={currentPage === 2 ? '/application/overview' : `/application/overview?page=${currentPage - 1}`}
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
>
|
>
|
||||||
@@ -518,7 +517,7 @@ export default function ApplicationList() {
|
|||||||
</span>
|
</span>
|
||||||
{currentPage < result.totalPages ? (
|
{currentPage < result.totalPages ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/applications?page=${currentPage + 1}`}
|
to={`/application/overview?page=${currentPage + 1}`}
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
className="btn btn-secondary"
|
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
|
// Helper function to get display text for an option
|
||||||
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
|
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
|
||||||
if (showRemarks) {
|
if (showRemarks) {
|
||||||
// Concatenate description and remarks with ". "
|
// Concatenate description, remarks, and indicators with ". "
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (option.description) parts.push(option.description);
|
if (option.description) parts.push(option.description);
|
||||||
if (option.remarks) parts.push(option.remarks);
|
if (option.remarks) parts.push(option.remarks);
|
||||||
|
if (option.indicators) parts.push(option.indicators);
|
||||||
return parts.length > 0 ? parts.join('. ') : null;
|
return parts.length > 0 ? parts.join('. ') : null;
|
||||||
}
|
}
|
||||||
if (showSummary && option.summary) {
|
if (showSummary && option.summary) {
|
||||||
|
// Include indicators if available
|
||||||
|
if (option.indicators) {
|
||||||
|
return `${option.summary}. ${option.indicators}`;
|
||||||
|
}
|
||||||
return option.summary;
|
return option.summary;
|
||||||
}
|
}
|
||||||
if (showSummary && !option.summary && option.description) {
|
if (showSummary && !option.summary && option.description) {
|
||||||
|
// Include indicators if available
|
||||||
|
if (option.indicators) {
|
||||||
|
return `${option.description}. ${option.indicators}`;
|
||||||
|
}
|
||||||
return option.description;
|
return option.description;
|
||||||
}
|
}
|
||||||
if (!showSummary && option.description) {
|
if (!showSummary && option.description) {
|
||||||
|
// Include indicators if available
|
||||||
|
if (option.indicators) {
|
||||||
|
return `${option.description}. ${option.indicators}`;
|
||||||
|
}
|
||||||
return option.description;
|
return option.description;
|
||||||
}
|
}
|
||||||
return null;
|
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 { Link } from 'react-router-dom';
|
||||||
import { getDashboardStats, getRecentClassifications } from '../services/api';
|
import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
|
||||||
import type { DashboardStats, ClassificationResult } from '../types';
|
import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
|
||||||
|
|
||||||
// Extended type to include stale indicator from API
|
// Extended type to include stale indicator from API
|
||||||
interface DashboardStatsWithMeta extends DashboardStats {
|
interface DashboardStatsWithMeta extends DashboardStats {
|
||||||
@@ -12,9 +12,36 @@ interface DashboardStatsWithMeta extends DashboardStats {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
|
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
|
||||||
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
|
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
|
||||||
|
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||||
|
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) => {
|
const fetchData = useCallback(async (forceRefresh: boolean = false) => {
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
@@ -25,12 +52,14 @@ export default function Dashboard() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [statsData, recentData] = await Promise.all([
|
const [statsData, recentData, refData] = await Promise.all([
|
||||||
getDashboardStats(forceRefresh),
|
getDashboardStats(forceRefresh),
|
||||||
getRecentClassifications(10),
|
getRecentClassifications(10),
|
||||||
|
getReferenceData(),
|
||||||
]);
|
]);
|
||||||
setStats(statsData as DashboardStatsWithMeta);
|
setStats(statsData as DashboardStatsWithMeta);
|
||||||
setRecentClassifications(recentData);
|
setRecentClassifications(recentData);
|
||||||
|
setGovernanceModels(refData.governanceModels);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,7 +133,7 @@ export default function Dashboard() {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
|
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
|
||||||
</button>
|
</button>
|
||||||
<Link to="/applications" className="btn btn-primary">
|
<Link to="/app-components/overview" className="btn btn-primary">
|
||||||
Start classificeren
|
Start classificeren
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,16 +170,18 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bars */}
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
Classificatie voortgang
|
Classificatie voortgang
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ICT Governance Model Progress */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
<span>ApplicationFunction ingevuld</span>
|
<span>ICT Governance Model ingevuld</span>
|
||||||
<span>
|
<span>
|
||||||
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0}
|
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0} ({progressPercentage}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||||
@@ -160,6 +191,23 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Two column layout */}
|
{/* Two column layout */}
|
||||||
@@ -186,7 +234,7 @@ export default function Dashboard() {
|
|||||||
<div
|
<div
|
||||||
className="bg-blue-600 h-2 rounded-full"
|
className="bg-blue-600 h-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
width: `${(count / (stats?.totalAllApplications || 1)) * 100}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,37 +248,110 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Governance model distribution */}
|
{/* Governance model distribution */}
|
||||||
<div className="card p-6">
|
<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">
|
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center gap-2">
|
||||||
Verdeling per regiemodel
|
Verdeling per regiemodel
|
||||||
|
<span className="text-gray-400 text-xs font-normal" title="Hover voor details">ⓘ</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="flex flex-wrap gap-2" style={{ overflow: 'visible' }}>
|
||||||
{stats?.byGovernanceModel &&
|
{stats?.byGovernanceModel &&
|
||||||
Object.entries(stats.byGovernanceModel)
|
[
|
||||||
.sort((a, b) => {
|
...governanceModels
|
||||||
// Sort alphabetically, but put "Niet ingesteld" at the end
|
.map(g => g.name)
|
||||||
if (a[0] === 'Niet ingesteld') return 1;
|
.sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
|
||||||
if (b[0] === 'Niet ingesteld') return -1;
|
'Niet ingesteld'
|
||||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
]
|
||||||
})
|
.filter(govModel => stats.byGovernanceModel[govModel] !== undefined || govModel === 'Niet ingesteld')
|
||||||
.map(([model, count]) => (
|
.map((govModel) => {
|
||||||
<div key={model} className="flex items-center justify-between">
|
const count = stats.byGovernanceModel[govModel] || 0;
|
||||||
<span className="text-sm text-gray-600">{model}</span>
|
const colors = (() => {
|
||||||
<div className="flex items-center space-x-2">
|
if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
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
|
<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={{
|
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>
|
</div>
|
||||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
<div className="text-xl font-bold leading-tight">
|
||||||
{count}
|
{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>
|
||||||
</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 ||
|
{(!stats?.byGovernanceModel ||
|
||||||
Object.keys(stats.byGovernanceModel).length === 0) && (
|
Object.keys(stats.byGovernanceModel).length === 0) && (
|
||||||
<p className="text-sm text-gray-500">Geen data beschikbaar</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config?.oauthEnabled ? (
|
{config?.authMethod === 'oauth' ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleJiraLogin}
|
onClick={handleJiraLogin}
|
||||||
@@ -76,19 +76,19 @@ export default function Login() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-slate-500 text-sm">
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : config?.serviceAccountEnabled ? (
|
) : config?.authMethod === 'pat' ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
@@ -106,7 +106,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
|
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
|
||||||
<p className="text-slate-500 text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</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';
|
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>(
|
async function fetchApi<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
@@ -27,14 +69,21 @@ async function fetchApi<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
throw new Error(error.error || error.message || 'API request failed');
|
throw new ApiError(
|
||||||
|
errorData.error || errorData.message || 'API request failed',
|
||||||
|
response.status,
|
||||||
|
errorData
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Applications
|
// Applications
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function searchApplications(
|
export async function searchApplications(
|
||||||
filters: SearchFilters,
|
filters: SearchFilters,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
@@ -50,6 +99,49 @@ export async function getApplicationById(id: string): Promise<ApplicationDetails
|
|||||||
return fetchApi<ApplicationDetails>(`/applications/${id}`);
|
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(
|
export async function updateApplication(
|
||||||
id: string,
|
id: string,
|
||||||
updates: {
|
updates: {
|
||||||
@@ -58,7 +150,41 @@ export async function updateApplication(
|
|||||||
complexityFactor?: ReferenceValue;
|
complexityFactor?: ReferenceValue;
|
||||||
numberOfUsers?: ReferenceValue;
|
numberOfUsers?: ReferenceValue;
|
||||||
governanceModel?: 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;
|
applicationType?: ReferenceValue;
|
||||||
hostingType?: ReferenceValue;
|
hostingType?: ReferenceValue;
|
||||||
businessImpactAnalyse?: ReferenceValue;
|
businessImpactAnalyse?: ReferenceValue;
|
||||||
@@ -68,7 +194,7 @@ export async function updateApplication(
|
|||||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||||
}
|
}
|
||||||
): Promise<ApplicationDetails> {
|
): Promise<ApplicationDetails> {
|
||||||
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
|
return fetchApi<ApplicationDetails>(`/applications/${id}/force`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
});
|
});
|
||||||
@@ -94,7 +220,55 @@ export async function getApplicationHistory(id: string): Promise<ClassificationR
|
|||||||
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
|
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
|
// AI Provider type
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export type AIProvider = 'claude' | 'openai';
|
export type AIProvider = 'claude' | 'openai';
|
||||||
|
|
||||||
// AI Status response type
|
// AI Status response type
|
||||||
@@ -112,7 +286,10 @@ export interface AIStatusResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Classifications
|
// Classifications
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
|
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
|
||||||
const url = provider
|
const url = provider
|
||||||
? `/classifications/suggest/${id}?provider=${provider}`
|
? `/classifications/suggest/${id}?provider=${provider}`
|
||||||
@@ -144,7 +321,10 @@ export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
|
|||||||
return fetchApi(`/classifications/prompt/${id}`);
|
return fetchApi(`/classifications/prompt/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Reference Data
|
// Reference Data
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function getReferenceData(): Promise<{
|
export async function getReferenceData(): Promise<{
|
||||||
dynamicsFactors: ReferenceValue[];
|
dynamicsFactors: ReferenceValue[];
|
||||||
complexityFactors: ReferenceValue[];
|
complexityFactors: ReferenceValue[];
|
||||||
@@ -153,12 +333,14 @@ export async function getReferenceData(): Promise<{
|
|||||||
organisations: ReferenceValue[];
|
organisations: ReferenceValue[];
|
||||||
hostingTypes: ReferenceValue[];
|
hostingTypes: ReferenceValue[];
|
||||||
applicationFunctions: ReferenceValue[];
|
applicationFunctions: ReferenceValue[];
|
||||||
applicationClusters: ReferenceValue[];
|
applicationSubteams: ReferenceValue[];
|
||||||
|
applicationTeams: ReferenceValue[];
|
||||||
applicationTypes: ReferenceValue[];
|
applicationTypes: ReferenceValue[];
|
||||||
businessImportance: ReferenceValue[];
|
businessImportance: ReferenceValue[];
|
||||||
businessImpactAnalyses: ReferenceValue[];
|
businessImpactAnalyses: ReferenceValue[];
|
||||||
applicationManagementHosting: ReferenceValue[];
|
applicationManagementHosting: ReferenceValue[];
|
||||||
applicationManagementTAM: ReferenceValue[];
|
applicationManagementTAM: ReferenceValue[];
|
||||||
|
subteamToTeamMapping: Record<string, ReferenceValue | null>;
|
||||||
}> {
|
}> {
|
||||||
return fetchApi('/reference-data');
|
return fetchApi('/reference-data');
|
||||||
}
|
}
|
||||||
@@ -191,8 +373,8 @@ export async function getHostingTypes(): Promise<ReferenceValue[]> {
|
|||||||
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
|
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getApplicationClusters(): Promise<ReferenceValue[]> {
|
export async function getApplicationSubteams(): Promise<ReferenceValue[]> {
|
||||||
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters');
|
return fetchApi<ReferenceValue[]>('/reference-data/application-subteams');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
|
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||||
@@ -211,12 +393,18 @@ export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
|||||||
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
|
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Config
|
// Config
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function getConfig(): Promise<{ jiraHost: string }> {
|
export async function getConfig(): Promise<{ jiraHost: string }> {
|
||||||
return fetchApi<{ jiraHost: string }>('/config');
|
return fetchApi<{ jiraHost: string }>('/config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Dashboard
|
// Dashboard
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
|
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
|
||||||
const params = forceRefresh ? '?refresh=true' : '';
|
const params = forceRefresh ? '?refresh=true' : '';
|
||||||
return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
|
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}`);
|
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Team Dashboard
|
// Team Dashboard
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
|
// 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}`);
|
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// Configuration
|
// Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
export interface EffortCalculationConfig {
|
export interface EffortCalculationConfig {
|
||||||
governanceModelRules: Array<{
|
governanceModelRules: Array<{
|
||||||
governanceModel: string;
|
governanceModel: string;
|
||||||
@@ -365,7 +559,10 @@ export async function updateEffortCalculationConfigV25(config: EffortCalculation
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
// AI Chat
|
// AI Chat
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
import type { ChatMessage, ChatResponse } from '../types';
|
import type { ChatMessage, ChatResponse } from '../types';
|
||||||
|
|
||||||
export async function sendChatMessage(
|
export async function sendChatMessage(
|
||||||
@@ -389,3 +586,98 @@ export async function clearConversation(conversationId: string): Promise<{ succe
|
|||||||
method: 'DELETE',
|
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 {
|
interface AuthConfig {
|
||||||
|
// The configured authentication method
|
||||||
|
authMethod: 'pat' | 'oauth' | 'none';
|
||||||
|
// Legacy fields (for backward compatibility)
|
||||||
oauthEnabled: boolean;
|
oauthEnabled: boolean;
|
||||||
serviceAccountEnabled: boolean;
|
serviceAccountEnabled: boolean;
|
||||||
jiraHost: string;
|
jiraHost: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface SearchState {
|
|||||||
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
|
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
|
||||||
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
|
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||||
setComplexityFactor: (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;
|
setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
|
||||||
setOrganisation: (value: string | undefined) => void;
|
setOrganisation: (value: string | undefined) => void;
|
||||||
setHostingType: (value: string | undefined) => void;
|
setHostingType: (value: string | undefined) => void;
|
||||||
@@ -40,7 +40,7 @@ const defaultFilters: SearchFilters = {
|
|||||||
governanceModel: 'all',
|
governanceModel: 'all',
|
||||||
dynamicsFactor: 'all',
|
dynamicsFactor: 'all',
|
||||||
complexityFactor: 'all',
|
complexityFactor: 'all',
|
||||||
applicationCluster: 'all',
|
applicationSubteam: 'all',
|
||||||
applicationType: 'all',
|
applicationType: 'all',
|
||||||
organisation: undefined,
|
organisation: undefined,
|
||||||
hostingType: undefined,
|
hostingType: undefined,
|
||||||
@@ -88,9 +88,9 @@ export const useSearchStore = create<SearchState>((set) => ({
|
|||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
setApplicationCluster: (value) =>
|
setApplicationSubteam: (value) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
filters: { ...state.filters, applicationCluster: value },
|
filters: { ...state.filters, applicationSubteam: value },
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface ReferenceValue {
|
|||||||
remarks?: string; // Remarks attribute for Governance Model
|
remarks?: string; // Remarks attribute for Governance Model
|
||||||
application?: string; // Application attribute for Governance Model
|
application?: string; // Application attribute for Governance Model
|
||||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||||
|
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Application list item (summary view)
|
// Application list item (summary view)
|
||||||
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
|
|||||||
governanceModel: ReferenceValue | null;
|
governanceModel: ReferenceValue | null;
|
||||||
dynamicsFactor: ReferenceValue | null;
|
dynamicsFactor: ReferenceValue | null;
|
||||||
complexityFactor: ReferenceValue | null;
|
complexityFactor: ReferenceValue | null;
|
||||||
applicationCluster: ReferenceValue | null;
|
applicationSubteam: ReferenceValue | null;
|
||||||
|
applicationTeam: ReferenceValue | null;
|
||||||
applicationType: ReferenceValue | null;
|
applicationType: ReferenceValue | null;
|
||||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||||
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
|
|||||||
complexityFactor: ReferenceValue | null;
|
complexityFactor: ReferenceValue | null;
|
||||||
numberOfUsers: ReferenceValue | null;
|
numberOfUsers: ReferenceValue | null;
|
||||||
governanceModel: ReferenceValue | null;
|
governanceModel: ReferenceValue | null;
|
||||||
applicationCluster: ReferenceValue | null;
|
applicationSubteam: ReferenceValue | null;
|
||||||
|
applicationTeam: ReferenceValue | null;
|
||||||
applicationType: ReferenceValue | null;
|
applicationType: ReferenceValue | null;
|
||||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||||
@@ -92,7 +95,7 @@ export interface SearchFilters {
|
|||||||
governanceModel?: 'all' | 'filled' | 'empty';
|
governanceModel?: 'all' | 'filled' | 'empty';
|
||||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||||
complexityFactor?: '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';
|
applicationType?: 'all' | 'filled' | 'empty';
|
||||||
organisation?: string;
|
organisation?: string;
|
||||||
hostingType?: string;
|
hostingType?: string;
|
||||||
@@ -168,7 +171,8 @@ export interface PendingChanges {
|
|||||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
governanceModel?: { 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 };
|
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
|
|||||||
numberOfUsers: ReferenceValue[];
|
numberOfUsers: ReferenceValue[];
|
||||||
governanceModels: ReferenceValue[];
|
governanceModels: ReferenceValue[];
|
||||||
applicationFunctions: ReferenceValue[];
|
applicationFunctions: ReferenceValue[];
|
||||||
applicationClusters: ReferenceValue[];
|
applicationSubteams: ReferenceValue[];
|
||||||
|
applicationTeams: ReferenceValue[];
|
||||||
applicationTypes: ReferenceValue[];
|
applicationTypes: ReferenceValue[];
|
||||||
organisations: ReferenceValue[];
|
organisations: ReferenceValue[];
|
||||||
hostingTypes: ReferenceValue[];
|
hostingTypes: ReferenceValue[];
|
||||||
@@ -220,9 +225,12 @@ export interface ZiraTaxonomy {
|
|||||||
|
|
||||||
// Dashboard statistics
|
// Dashboard statistics
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
totalApplications: number;
|
totalApplications: number; // Excluding Closed/Deprecated
|
||||||
|
totalAllApplications: number; // Including all statuses (for status distribution)
|
||||||
classifiedCount: number;
|
classifiedCount: number;
|
||||||
unclassifiedCount: number;
|
unclassifiedCount: number;
|
||||||
|
withApplicationFunction: number;
|
||||||
|
applicationFunctionPercentage: number;
|
||||||
byStatus: Record<string, number>;
|
byStatus: Record<string, number>;
|
||||||
byDomain: Record<string, number>;
|
byDomain: Record<string, number>;
|
||||||
byGovernanceModel: Record<string, number>;
|
byGovernanceModel: Record<string, number>;
|
||||||
@@ -284,8 +292,9 @@ export interface PlatformWithWorkloads {
|
|||||||
totalEffort: number; // platformEffort + workloadsEffort
|
totalEffort: number; // platformEffort + workloadsEffort
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamDashboardCluster {
|
// Subteam level in team dashboard hierarchy
|
||||||
cluster: ReferenceValue | null;
|
export interface TeamDashboardSubteam {
|
||||||
|
subteam: ReferenceValue | null;
|
||||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||||
@@ -295,17 +304,21 @@ export interface TeamDashboardCluster {
|
|||||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
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 {
|
export interface TeamDashboardData {
|
||||||
clusters: TeamDashboardCluster[];
|
teams: TeamDashboardTeam[];
|
||||||
unassigned: {
|
unassigned: TeamDashboardSubteam; // Apps without team assignment
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat message for AI conversation
|
// Chat message for AI conversation
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"dev:backend": "npm run dev --workspace=backend",
|
"dev:backend": "npm run dev --workspace=backend",
|
||||||
"dev:frontend": "npm run dev --workspace=frontend",
|
"dev:frontend": "npm run dev --workspace=frontend",
|
||||||
"build": "npm run build --workspaces",
|
"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": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user