feat: Implement Incident Management and Tab Dashboard

This commit is contained in:
Tola Leng
2025-05-23 20:37:00 +08:00
parent 395a580032
commit 747ae5c23f
69 changed files with 4457 additions and 0 deletions
@@ -0,0 +1,14 @@
import { IncidentItem } from './types';
// Export functions to update and invalidate the cache
export const updateCache = (data: IncidentItem[]) => {
console.log(`Updating cache with ${data.length} incidents`);
// The actual cache is now maintained in incidentFetch.ts
};
// Reset cache and request state
export const invalidateCache = () => {
console.log('Invalidating incidents cache');
// The invalidation is handled in incidentFetch.ts implementation
};
@@ -0,0 +1,147 @@
import { pb } from '@/lib/pocketbase';
import { IncidentItem } from './types';
import { normalizeFetchedItem } from './incidentUtils';
import { updateCache, invalidateCache } from './incidentCache';
// Internal state variables instead of importing them
let incidentsCache: {
data: IncidentItem[];
timestamp: number;
} | null = null;
let pendingRequest: Promise<any> | null = null;
let isRequestInProgress = false;
// Cache duration in milliseconds (30 minutes)
const CACHE_DURATION = 30 * 60 * 1000;
// Check if cache is valid
export const isCacheValid = (): boolean => {
const now = Date.now();
return !!(incidentsCache && (now - incidentsCache.timestamp < CACHE_DURATION));
};
// Get all incidents with caching and error handling
export const getAllIncidents = async (forceRefresh = false): Promise<IncidentItem[]> => {
// If a request is in progress, wait for it to complete rather than making a new one
if (isRequestInProgress) {
console.log('Request already in progress, waiting for completion');
try {
if (pendingRequest) {
await pendingRequest;
}
return incidentsCache?.data || [];
} catch (error) {
console.error('Error in existing incidents request:', error);
return incidentsCache?.data || [];
}
}
// Use cache if available, not expired, and not forced refresh
if (!forceRefresh && isCacheValid()) {
console.log('Using cached incidents data from', new Date(incidentsCache!.timestamp).toLocaleTimeString());
return incidentsCache!.data;
}
try {
console.log('Fetching all incidents from API...');
isRequestInProgress = true;
// Implement timeout for the request
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 20000); // Longer timeout (20s)
});
// Create the fetch promise with a unique request key to prevent conflicts
const now = Date.now();
const requestKey = `incidents-${now}`;
const fetchPromise = pb.collection('incidents').getList(1, 100, {
sort: '-created',
requestKey,
$cancelKey: requestKey,
});
// Store the pending request
pendingRequest = Promise.race([fetchPromise, timeoutPromise]);
// Race between fetch and timeout
const result = await pendingRequest;
// Clear request flags
pendingRequest = null;
isRequestInProgress = false;
if (!result || !result.items) {
console.warn('No incidents found in API response');
return [];
}
const normalizedItems = result.items.map(normalizeFetchedItem);
// Update cache
updateCache(normalizedItems);
console.log(`Fetched ${normalizedItems.length} incidents at ${new Date().toLocaleTimeString()}`);
return normalizedItems;
} catch (error) {
if ((error as any)?.isAbort) {
console.log("Request aborted:", error);
return incidentsCache?.data || [];
}
console.error('Error fetching incidents:', error);
// Clear states to allow retry
pendingRequest = null;
isRequestInProgress = false;
// Improve error message
if (error instanceof Error) {
throw new Error(`Failed to fetch incidents: ${error.message}`);
}
// Still return cached data even on error
if (incidentsCache) {
console.log('Returning stale cached data after error');
return incidentsCache.data;
}
return [];
}
};
// Get incident by id
export const getIncidentById = async (id: string): Promise<IncidentItem | null> => {
try {
console.log(`Fetching incident with ID: ${id}`);
// First check if the incident exists in the cache
if (isCacheValid() && incidentsCache) {
const cachedIncident = incidentsCache.data.find(incident => incident.id === id);
if (cachedIncident) {
console.log('Incident found in cache');
return cachedIncident;
}
}
// If not in cache, fetch from API
const result = await pb.collection('incidents').getOne(id);
if (!result) {
console.warn(`No incident found with ID: ${id}`);
return null;
}
const normalizedIncident = normalizeFetchedItem(result);
return normalizedIncident;
} catch (error) {
console.error(`Error fetching incident with ID ${id}:`, error);
if (error instanceof Error) {
throw new Error(`Failed to fetch incident: ${error.message}`);
}
return null;
}
};
@@ -0,0 +1,109 @@
import { pb } from '@/lib/pocketbase';
import { CreateIncidentInput, IncidentItem } from './types';
import { formatStatus } from './incidentUtils';
import { invalidateCache } from './incidentCache';
// Update incident status
export const updateIncidentStatus = async (id: string, status: string): Promise<void> => {
try {
const formattedStatus = formatStatus(status);
console.log(`Updating incident ${id} status to ${status} (formatted: ${formattedStatus})`);
// Update both status and impact_status fields
await pb.collection('incidents').update(id, {
status: formattedStatus,
impact_status: status.toLowerCase(), // Set impact_status to the lowercase status value
...(status.toLowerCase() === 'resolved' ? { resolution_time: new Date().toISOString() } : {})
});
// Invalidate cache after update
invalidateCache();
console.log(`Incident ${id} status updated successfully to ${status}`);
} catch (error) {
console.error('Error updating incident status:', error);
throw error;
}
};
// Delete incident
export const deleteIncident = async (id: string): Promise<void> => {
try {
await pb.collection('incidents').delete(id);
// Invalidate cache after deletion
invalidateCache();
console.log(`Incident ${id} deleted successfully`);
} catch (error) {
console.error('Error deleting incident:', error);
throw error;
}
};
// Create incident
export const createIncident = async (data: CreateIncidentInput): Promise<void> => {
try {
// Format the payload according to API requirements
const payload = {
title: data.title,
description: data.description,
status: formatStatus(data.status),
impact_status: data.status.toLowerCase(),
// Use lowercase for impact and priority to match API expectations
impact: data.impact.toLowerCase(),
affected_systems: data.affected_systems,
priority: data.priority.toLowerCase(),
service_id: data.service_id || '',
assigned_to: data.assigned_to || '', // Direct user ID assignment
root_cause: data.root_cause || '',
resolution_steps: data.resolution_steps || '',
lessons_learned: data.lessons_learned || '',
timestamp: data.timestamp || new Date().toISOString(),
created_by: data.created_by,
resolution_time: data.status.toLowerCase() === 'resolved' ? new Date().toISOString() : null,
};
console.log('Creating new incident with payload:', payload);
await pb.collection('incidents').create(payload);
// Invalidate cache after create
invalidateCache();
console.log('Incident created successfully');
} catch (error) {
console.error('Error creating incident:', error);
throw error;
}
};
// Update incident
export const updateIncident = async (id: string, data: Partial<IncidentItem>): Promise<void> => {
try {
console.log(`Updating incident ${id} with:`, data);
// Make sure impact and priority are lowercase
const payload = {
...data,
impact: data.impact?.toLowerCase(),
priority: data.priority?.toLowerCase(),
status: data.status ? formatStatus(data.status) : undefined,
impact_status: data.status ? data.status.toLowerCase() : undefined,
...(data.status?.toLowerCase() === 'resolved' && !data.resolution_time
? { resolution_time: new Date().toISOString() }
: {})
};
console.log("Final payload for update:", payload);
await pb.collection('incidents').update(id, payload);
// Invalidate cache after update
invalidateCache();
console.log(`Incident ${id} updated successfully`);
} catch (error) {
console.error('Error updating incident:', error);
throw error;
}
};
@@ -0,0 +1,5 @@
import { generateIncidentPDF } from './pdf';
export { generateIncidentPDF };
@@ -0,0 +1,22 @@
import { CreateIncidentInput, IncidentItem, UpdateIncidentInput } from './types';
import { createIncident, updateIncident, updateIncidentStatus, deleteIncident } from './incidentOperations';
import { getAllIncidents, getIncidentById } from './incidentFetch';
import { generateIncidentPDF } from './incidentPdfService';
export const incidentService = {
// Fetch operations
getAllIncidents,
getIncidentById,
// CRUD operations
createIncident,
updateIncident,
updateIncidentStatus,
deleteIncident,
// PDF operations
generateIncidentPDF,
};
export default incidentService;
@@ -0,0 +1,29 @@
import { pb } from '@/lib/pocketbase';
import { IncidentItem } from './types';
// Helper function to get the API URL
export const getApiUrl = (): string => {
try {
return pb.baseUrl;
} catch (error) {
console.error('Error getting API URL:', error);
return '';
}
};
// Normalize fetched items to ensure consistent data structure
export const normalizeFetchedItem = (item: any): IncidentItem => {
return {
...item,
affected_systems: item.affected_systems || '',
status: item.impact_status || item.status || 'Investigating',
impact: item.impact || 'Low',
priority: item.priority || 'Low',
} as IncidentItem;
};
// Format status with first letter capitalized
export const formatStatus = (status: string): string => {
return status.charAt(0).toUpperCase() + status.slice(1);
};
@@ -0,0 +1,12 @@
export * from './types';
export * from './incidentFetch';
export * from './incidentOperations';
export * from './incidentCache';
export * from './incidentUtils';
export * from './incidentPdfService';
export * from './incidentService';
// Export the incidentService as the default export
export { incidentService } from './incidentService';
@@ -0,0 +1,112 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import {
addBasicInfoSection,
addDescriptionSection,
addAffectedSystemsSection,
addRootCauseSection,
addResolutionSection,
addAssignmentSection,
addLessonsLearnedSection
} from './sections';
import { addHeader, addFooter } from './headerFooter';
/**
* Generate a PDF for an incident report
*/
export const generatePdf = async (incident: IncidentItem): Promise<string> => {
// Validate incident data
if (!incident?.id) {
console.error('Invalid incident data for PDF generation');
throw new Error('Invalid incident data');
}
try {
// Create new PDF document with portrait orientation
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Set title and filename
const title = incident.title || `Incident Report #${incident.id}`;
const filename = `incident-report-${incident.id}.pdf`;
// Add metadata
doc.setProperties({
title: title,
subject: 'Incident Report',
author: 'ReamStack System',
creator: 'ReamStack',
});
// Add header section
let yPos = addHeader(doc, incident);
// Add basic information section
yPos = addBasicInfoSection(doc, incident, yPos);
// Add description section
yPos = addDescriptionSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add affected systems section
yPos = addAffectedSystemsSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add root cause section
yPos = addRootCauseSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add resolution steps section
yPos = addResolutionSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add assignment section
yPos = addAssignmentSection(doc, incident, yPos);
// Check if we need to add a new page
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Add lessons learned section if available
addLessonsLearnedSection(doc, incident, yPos);
// Add footer to all pages
addFooter(doc);
// Save the PDF
doc.save(filename);
console.log('PDF generated successfully:', filename);
return filename;
} catch (error) {
console.error('Error generating incident PDF:', error);
throw new Error(`Failed to generate PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
@@ -0,0 +1,63 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import { fonts } from './utils';
/**
* Add the PDF header with title and metadata
*/
export const addHeader = (doc: jsPDF, incident: IncidentItem): number => {
// Set initial y position
let yPos = 15;
// Add header with title
doc.setFont(fonts.bold);
doc.setFontSize(18);
doc.setTextColor(0, 0, 0);
doc.text('INCIDENT REPORT', 105, yPos, { align: 'center' });
// Add incident title
yPos += 8;
doc.setFontSize(14);
const title = incident.title || `Incident Report #${incident.id}`;
doc.text(title, 105, yPos, { align: 'center' });
// Add current date
yPos += 8;
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.text(`Generated on: ${new Date().toLocaleDateString()}`, 105, yPos, { align: 'center' });
// Add ReamStack logo or text
yPos += 8;
doc.setFontSize(12);
doc.setFont(fonts.italic);
doc.text('ReamStack Incident Management', 105, yPos, { align: 'center' });
// Add horizontal line
yPos += 5;
doc.setLineWidth(0.5);
doc.line(15, yPos, 195, yPos);
// Return next Y position with some padding
return yPos + 10;
};
/**
* Add footer to all pages
*/
export const addFooter = (doc: jsPDF): void => {
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.text(
`Page ${i} of ${pageCount} | Generated by ReamStack Incident Management System`,
105,
285,
{ align: 'center' }
);
}
};
@@ -0,0 +1,12 @@
import { generatePdf } from './generator';
import { IncidentItem } from '../types';
/**
* Generate and download PDF for incident report
*/
export const generateIncidentPDF = async (incident: IncidentItem): Promise<void> => {
await generatePdf(incident);
return Promise.resolve();
};
@@ -0,0 +1,192 @@
import jsPDF from 'jspdf';
import { IncidentItem } from '../types';
import { fonts, formatDate, capitalize } from './utils';
import autoTable from 'jspdf-autotable';
/**
* Add the incident overview section to the PDF
*/
export const addBasicInfoSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Incident Overview', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
yPos += 10;
// Basic information table
autoTable(doc, {
startY: yPos,
head: [['Field', 'Information']],
body: [
['ID', incident.id],
['Status', capitalize(incident.status || 'Unknown')],
['Priority', capitalize(incident.priority || 'Unknown')],
['Impact', capitalize(incident.impact || 'Unknown')],
['Created', formatDate(incident.created)],
['Last Updated', formatDate(incident.updated)],
],
headStyles: {
fillColor: [30, 64, 175], // Blue-800
textColor: [255, 255, 255],
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [241, 245, 249], // Slate-100
},
margin: { left: 15, right: 15 },
});
// Return the new Y position after the table
return (doc as any).lastAutoTable.finalY + 10;
};
/**
* Add the description section to the PDF
*/
export const addDescriptionSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Description', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add incident description
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const description = incident.description || 'No description provided';
const splitDescription = doc.splitTextToSize(description, 180);
doc.text(splitDescription, 15, yPos + 10);
// Update y position after the description
return yPos + 15 + (splitDescription.length * 5);
};
/**
* Add the affected systems section to the PDF
*/
export const addAffectedSystemsSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Affected Systems', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add affected systems
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const affectedSystems = incident.affected_systems || 'None specified';
const systemsList = affectedSystems.split(',').map(system => system.trim());
let currentY = yPos + 10;
systemsList.forEach((system) => {
doc.text(`${system}`, 15, currentY);
currentY += 5;
});
return currentY + 5; // Return updated Y position with some padding
};
/**
* Add the root cause section to the PDF
*/
export const addRootCauseSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Root Cause', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add root cause
doc.setFont(fonts.normal);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const rootCause = incident.root_cause || 'Root cause not identified yet';
const splitRootCause = doc.splitTextToSize(rootCause, 180);
doc.text(splitRootCause, 15, yPos + 10);
// Update y position after the root cause
return yPos + 15 + (splitRootCause.length * 5);
};
/**
* Add the resolution steps section to the PDF
*/
export const addResolutionSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFont(fonts.bold);
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Resolution Steps', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Create resolution data table - Using resolution_steps
const resolutionSteps = incident.resolution_steps || 'No resolution steps provided';
const splitResolutionSteps = doc.splitTextToSize(resolutionSteps, 180);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text(splitResolutionSteps, 15, yPos + 10);
return yPos + 20 + (splitResolutionSteps.length * 5);
};
/**
* Add the assignment information section to the PDF
*/
export const addAssignmentSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Assignment Information', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add assigned user info
yPos += 10;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const assignedTo = incident.assigned_to || 'Not assigned';
doc.text(`Assigned to: ${assignedTo}`, 15, yPos);
return yPos + 10;
};
/**
* Add the lessons learned section to the PDF if available
*/
export const addLessonsLearnedSection = (doc: jsPDF, incident: IncidentItem, yPos: number): number => {
if (!incident.lessons_learned) {
return yPos; // No lessons learned, return current position
}
doc.setFontSize(14);
doc.setTextColor(30, 64, 175); // Blue-800
doc.text('Lessons Learned', 15, yPos);
// Draw a line under the heading
doc.line(15, yPos + 2, 195, yPos + 2);
// Add lessons learned
const lessonsLearned = incident.lessons_learned;
const splitLessonsLearned = doc.splitTextToSize(lessonsLearned, 180);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text(splitLessonsLearned, 15, yPos + 10);
return yPos + 15 + (splitLessonsLearned.length * 5);
};
@@ -0,0 +1,26 @@
import { format } from 'date-fns';
// Font configuration for the PDF
export const fonts = {
normal: 'Helvetica',
bold: 'Helvetica-Bold',
italic: 'Helvetica-Oblique',
};
// Helper function to format dates
export const formatDate = (dateString: string | undefined): string => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'PPp');
} catch (e) {
return dateString || 'N/A';
}
};
// Helper function to capitalize first letter
export const capitalize = (str: string): string => {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
};
@@ -0,0 +1,57 @@
// Define the type for incident item
export type IncidentItem = {
id: string;
service_id?: string;
timestamp?: string;
description: string;
assigned_to?: string;
resolution_time?: string;
impact: string;
affected_systems: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
operational_status_id?: string;
server_id?: string;
priority: string;
status: string;
impact_status?: string;
created: string;
updated: string;
category?: string;
title: string;
};
// Define the input type for creating an incident
export type CreateIncidentInput = {
title: string;
description: string;
status: string;
impact: string;
affected_systems: string;
priority: string;
service_id?: string;
assigned_to?: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
timestamp?: string;
created_by: string;
};
// Define the input type for updating an incident
export type UpdateIncidentInput = {
id: string;
title?: string;
description?: string;
status?: string;
impact?: string;
affected_systems?: string;
priority?: string;
service_id?: string;
assigned_to?: string;
root_cause?: string;
resolution_steps?: string;
lessons_learned?: string;
};