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:
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();
|
||||
|
||||
Reference in New Issue
Block a user