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:
2026-01-10 02:16:55 +01:00
parent ea1c84262c
commit ca21b9538d
54 changed files with 13444 additions and 1789 deletions

View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
}
// Export singleton instance
export const jiraAssetsClient = new JiraAssetsClient();