- Add admin dashboard with sync controls and data browser - Implement RMM, Auvik, and Addigy organization mappings - Add chunked ticket sync with progress tracking - Implement entity sync service with rate limiting - Add analytics engine and performance optimizer - Create data browser for all PSA entities - Add navigation components and UI improvements - Implement background processing and sync services - Add comprehensive documentation and migration scripts - Update configuration items with multi-system support - Enhance contact management and purchase history - Add issue type assignment and LLM analyzer - Improve error handling and logging utilities
420 lines
11 KiB
TypeScript
420 lines
11 KiB
TypeScript
/**
|
|
* Performance Optimizer Service
|
|
* Implements performance optimizations for large datasets and caching strategies
|
|
*/
|
|
|
|
import { TimeEntry } from '@/lib/types/database';
|
|
import { AnalyticsInsight, AggregateAnalysis } from '@/lib/types/analytics';
|
|
|
|
export interface CacheConfig {
|
|
ttl: number; // Time to live in milliseconds
|
|
maxSize: number; // Maximum number of items in cache
|
|
strategy: 'lru' | 'fifo' | 'lfu';
|
|
}
|
|
|
|
export interface PerformanceMetrics {
|
|
queryTime: number;
|
|
cacheHitRate: number;
|
|
memoryUsage: number;
|
|
recordsProcessed: number;
|
|
recordsPerSecond: number;
|
|
}
|
|
|
|
export class PerformanceOptimizer {
|
|
private cache: Map<string, { data: any; timestamp: number; accessCount: number }> = new Map();
|
|
private cacheConfig: CacheConfig = {
|
|
ttl: 5 * 60 * 1000, // 5 minutes default
|
|
maxSize: 1000,
|
|
strategy: 'lru',
|
|
};
|
|
|
|
constructor(config?: Partial<CacheConfig>) {
|
|
if (config) {
|
|
this.cacheConfig = { ...this.cacheConfig, ...config };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cached data
|
|
*/
|
|
getCachedData(key: string): any | null {
|
|
const item = this.cache.get(key);
|
|
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
|
|
// Check if item is expired
|
|
if (Date.now() - item.timestamp > this.cacheConfig.ttl) {
|
|
this.cache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
// Update access count for LFU strategy
|
|
item.accessCount++;
|
|
|
|
return item.data;
|
|
}
|
|
|
|
/**
|
|
* Set cached data
|
|
*/
|
|
setCachedData(key: string, data: any): void {
|
|
// Remove oldest items if cache is full
|
|
if (this.cache.size >= this.cacheConfig.maxSize) {
|
|
this.evictCache();
|
|
}
|
|
|
|
this.cache.set(key, {
|
|
data,
|
|
timestamp: Date.now(),
|
|
accessCount: 1,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear cache
|
|
*/
|
|
clearCache(): void {
|
|
this.cache.clear();
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
getCacheStats(): {
|
|
size: number;
|
|
maxSize: number;
|
|
hitRate: number;
|
|
memoryUsage: number;
|
|
} {
|
|
return {
|
|
size: this.cache.size,
|
|
maxSize: this.cacheConfig.maxSize,
|
|
hitRate: 0, // Would need to track hits/misses for real implementation
|
|
memoryUsage: this.estimateMemoryUsage(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optimize time entries query with pagination and filtering
|
|
*/
|
|
optimizeTimeEntriesQuery(
|
|
baseQuery: string,
|
|
filters: Record<string, any>,
|
|
pagination: { limit: number; offset: number }
|
|
): { query: string; params: any[] } {
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
// Add filter conditions
|
|
Object.entries(filters).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
if (Array.isArray(value)) {
|
|
conditions.push(`${key} = ANY($${paramIndex})`);
|
|
params.push(value);
|
|
} else {
|
|
conditions.push(`${key} = $${paramIndex}`);
|
|
params.push(value);
|
|
}
|
|
paramIndex++;
|
|
}
|
|
});
|
|
|
|
// Build WHERE clause
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
// Add pagination
|
|
const query = `
|
|
${baseQuery}
|
|
${whereClause}
|
|
ORDER BY entry_date DESC
|
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
|
`;
|
|
|
|
params.push(pagination.limit, pagination.offset);
|
|
|
|
return { query, params };
|
|
}
|
|
|
|
/**
|
|
* Batch process large datasets
|
|
*/
|
|
async batchProcess<T, R>(
|
|
items: T[],
|
|
processor: (batch: T[]) => Promise<R[]>,
|
|
batchSize: number = 100,
|
|
onProgress?: (processed: number, total: number) => void
|
|
): Promise<R[]> {
|
|
const results: R[] = [];
|
|
|
|
for (let i = 0; i < items.length; i += batchSize) {
|
|
const batch = items.slice(i, i + batchSize);
|
|
const batchResults = await processor(batch);
|
|
results.push(...batchResults);
|
|
|
|
if (onProgress) {
|
|
onProgress(Math.min(i + batchSize, items.length), items.length);
|
|
}
|
|
|
|
// Small delay to prevent overwhelming the system
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Optimize analytics calculations for large datasets
|
|
*/
|
|
optimizeAnalyticsCalculation(timeEntries: TimeEntry[]): {
|
|
summary: {
|
|
totalEntries: number;
|
|
totalHours: number;
|
|
averageHoursPerEntry: number;
|
|
billableEntries: number;
|
|
approvedEntries: number;
|
|
};
|
|
scores: {
|
|
activity: number;
|
|
content: number;
|
|
timeliness: number;
|
|
overall: number;
|
|
};
|
|
} {
|
|
// Use efficient single-pass calculations
|
|
let totalHours = 0;
|
|
let billableEntries = 0;
|
|
let approvedEntries = 0;
|
|
let activityScoreSum = 0;
|
|
let contentScoreSum = 0;
|
|
let timelinessScoreSum = 0;
|
|
|
|
for (const entry of timeEntries) {
|
|
totalHours += entry.hours_worked;
|
|
|
|
if (entry.billable) billableEntries++;
|
|
if (entry.approved) approvedEntries++;
|
|
|
|
// Simplified scoring for performance (would use full analytics engine in real implementation)
|
|
activityScoreSum += this.calculateQuickActivityScore(entry);
|
|
contentScoreSum += this.calculateQuickContentScore(entry);
|
|
timelinessScoreSum += this.calculateQuickTimelinessScore(entry);
|
|
}
|
|
|
|
const count = timeEntries.length;
|
|
|
|
return {
|
|
summary: {
|
|
totalEntries: count,
|
|
totalHours,
|
|
averageHoursPerEntry: count > 0 ? totalHours / count : 0,
|
|
billableEntries,
|
|
approvedEntries,
|
|
},
|
|
scores: {
|
|
activity: count > 0 ? activityScoreSum / count : 0,
|
|
content: count > 0 ? contentScoreSum / count : 0,
|
|
timeliness: count > 0 ? timelinessScoreSum / count : 0,
|
|
overall: count > 0 ? (activityScoreSum + contentScoreSum + timelinessScoreSum) / (3 * count) : 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate performance metrics
|
|
*/
|
|
generateMetrics(startTime: number, recordsProcessed: number): PerformanceMetrics {
|
|
const queryTime = Date.now() - startTime;
|
|
|
|
return {
|
|
queryTime,
|
|
cacheHitRate: this.getCacheStats().hitRate,
|
|
memoryUsage: this.getCacheStats().memoryUsage,
|
|
recordsProcessed,
|
|
recordsPerSecond: recordsProcessed > 0 ? (recordsProcessed / queryTime) * 1000 : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optimize insight generation by grouping and batching
|
|
*/
|
|
optimizeInsightGeneration(
|
|
timeEntries: TimeEntry[],
|
|
existingInsights: AnalyticsInsight[] = []
|
|
): AnalyticsInsight[] {
|
|
const insights: AnalyticsInsight[] = [...existingInsights];
|
|
|
|
// Group by resource for efficiency
|
|
const resourceGroups = new Map<number, TimeEntry[]>();
|
|
|
|
for (const entry of timeEntries) {
|
|
if (!resourceGroups.has(entry.resource_id)) {
|
|
resourceGroups.set(entry.resource_id, []);
|
|
}
|
|
resourceGroups.get(entry.resource_id)!.push(entry);
|
|
}
|
|
|
|
// Generate insights per resource
|
|
for (const [resourceId, entries] of resourceGroups) {
|
|
const totalHours = entries.reduce((sum, entry) => sum + entry.hours_worked, 0);
|
|
const avgScore = entries.reduce((sum, entry) =>
|
|
sum + this.calculateQuickOverallScore(entry), 0) / entries.length;
|
|
|
|
// Add insight if needed
|
|
if (avgScore < 0.5) {
|
|
insights.push({
|
|
type: 'warning',
|
|
category: 'activity',
|
|
title: `Low Performance: Resource ${resourceId}`,
|
|
description: `Average score: ${(avgScore * 100).toFixed(1)}%`,
|
|
recommendation: 'Review time entry quality and provide training',
|
|
severity: 'medium',
|
|
actionable: true,
|
|
});
|
|
}
|
|
|
|
if (totalHours > 40) { // More than 40 hours in period
|
|
insights.push({
|
|
type: 'info',
|
|
category: 'performance',
|
|
title: `High Activity: Resource ${resourceId}`,
|
|
description: `${Number(totalHours).toFixed(1)} hours logged`,
|
|
recommendation: 'Monitor workload and resource allocation',
|
|
severity: 'low',
|
|
actionable: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return insights;
|
|
}
|
|
|
|
/**
|
|
* Memory-efficient data streaming for large exports
|
|
*/
|
|
async* streamDataForExport<T>(
|
|
data: T[],
|
|
chunkSize: number = 1000
|
|
): AsyncGenerator<T[], void, unknown> {
|
|
for (let i = 0; i < data.length; i += chunkSize) {
|
|
yield data.slice(i, i + chunkSize);
|
|
|
|
// Allow event loop to process other tasks
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Private helper methods
|
|
*/
|
|
private evictCache(): void {
|
|
switch (this.cacheConfig.strategy) {
|
|
case 'lru':
|
|
this.evictLRU();
|
|
break;
|
|
case 'fifo':
|
|
this.evictFIFO();
|
|
break;
|
|
case 'lfu':
|
|
this.evictLFU();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private evictLRU(): void {
|
|
let oldestKey = '';
|
|
let oldestTime = Date.now();
|
|
|
|
for (const [key, item] of this.cache.entries()) {
|
|
if (item.timestamp < oldestTime) {
|
|
oldestTime = item.timestamp;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
|
|
if (oldestKey) {
|
|
this.cache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
private evictFIFO(): void {
|
|
const firstKey = this.cache.keys().next().value;
|
|
if (firstKey) {
|
|
this.cache.delete(firstKey);
|
|
}
|
|
}
|
|
|
|
private evictLFU(): void {
|
|
let leastUsedKey = '';
|
|
let leastCount = Infinity;
|
|
|
|
for (const [key, item] of this.cache.entries()) {
|
|
if (item.accessCount < leastCount) {
|
|
leastCount = item.accessCount;
|
|
leastUsedKey = key;
|
|
}
|
|
}
|
|
|
|
if (leastUsedKey) {
|
|
this.cache.delete(leastUsedKey);
|
|
}
|
|
}
|
|
|
|
private estimateMemoryUsage(): number {
|
|
// Rough estimation - in real implementation would use more sophisticated tracking
|
|
let totalSize = 0;
|
|
|
|
for (const [key, item] of this.cache.entries()) {
|
|
totalSize += key.length * 2; // String size
|
|
totalSize += JSON.stringify(item.data).length * 2; // Data size
|
|
totalSize += 16; // Metadata overhead
|
|
}
|
|
|
|
return totalSize;
|
|
}
|
|
|
|
private calculateQuickActivityScore(entry: TimeEntry): number {
|
|
let score = 0;
|
|
if (entry.title) score += 0.25;
|
|
if (entry.notes && entry.notes.length > 10) score += 0.25;
|
|
if (entry.start_date_time && entry.end_date_time) score += 0.25;
|
|
if (entry.ticket_id || entry.task_id) score += 0.25;
|
|
return score;
|
|
}
|
|
|
|
private calculateQuickContentScore(entry: TimeEntry): number {
|
|
let score = 0;
|
|
if (entry.title && entry.title.length > 5) score += 0.3;
|
|
if (entry.notes && entry.notes.length > 20) score += 0.4;
|
|
if (entry.internal_notes) score += 0.3;
|
|
return score;
|
|
}
|
|
|
|
private calculateQuickTimelinessScore(entry: TimeEntry): number {
|
|
const entryDate = new Date(entry.entry_date);
|
|
const createdDate = new Date(entry.created_at);
|
|
const delayDays = (createdDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
|
|
if (delayDays <= 1) return 1.0;
|
|
if (delayDays <= 3) return 0.8;
|
|
if (delayDays <= 7) return 0.6;
|
|
return 0.4;
|
|
}
|
|
|
|
private calculateQuickOverallScore(entry: TimeEntry): number {
|
|
return (
|
|
this.calculateQuickActivityScore(entry) * 0.3 +
|
|
this.calculateQuickContentScore(entry) * 0.4 +
|
|
this.calculateQuickTimelinessScore(entry) * 0.3
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create singleton instance with optimized configuration
|
|
export const performanceOptimizer = new PerformanceOptimizer({
|
|
ttl: 10 * 60 * 1000, // 10 minutes
|
|
maxSize: 500,
|
|
strategy: 'lru',
|
|
});
|