This commit is contained in:
2025-12-24 10:57:43 +00:00
commit ba79b4bfb6
118 changed files with 292546 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from './upladmin-incident-list.js';

View File

@@ -0,0 +1,94 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import type { IIncidentDetails } from '../../interfaces/index.js';
import './upladmin-incident-list.js';
export const demoFunc = () => html`
<style>
${css`
.demo-container {
padding: 24px;
background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
min-height: 100vh;
}
`}
</style>
<div class="demo-container">
<upladmin-incident-list
.incidents=${[
{
id: 'inc-1',
title: 'CDN Performance Degradation',
status: 'monitoring',
severity: 'minor',
affectedServices: ['cdn'],
startTime: Date.now() - 4 * 60 * 60 * 1000,
impact: 'Some users may experience slower page loads for static assets.',
rootCause: 'Network congestion at edge locations',
updates: [
{ id: 'u1', timestamp: Date.now() - 1 * 60 * 60 * 1000, status: 'monitoring', message: 'Fix deployed, monitoring results.', author: 'Infrastructure Team' },
{ id: 'u2', timestamp: Date.now() - 2 * 60 * 60 * 1000, status: 'identified', message: 'Root cause identified as network congestion at edge nodes.', author: 'Infrastructure Team' },
{ id: 'u3', timestamp: Date.now() - 4 * 60 * 60 * 1000, status: 'investigating', message: 'We are investigating reports of slow content delivery.', author: 'Support Team' },
],
},
{
id: 'inc-2',
title: 'Search Cluster Partial Failure',
status: 'investigating',
severity: 'major',
affectedServices: ['search', 'api'],
startTime: Date.now() - 45 * 60 * 1000,
impact: 'Search functionality may return incomplete results. API responses may be delayed.',
updates: [
{ id: 'u4', timestamp: Date.now() - 45 * 60 * 1000, status: 'investigating', message: 'We are investigating issues with the search cluster. Some nodes are not responding.', author: 'Platform Team' },
],
},
{
id: 'inc-3',
title: 'Scheduled Database Maintenance',
status: 'investigating',
severity: 'maintenance',
affectedServices: ['db', 'api', 'web'],
startTime: Date.now() - 15 * 60 * 1000,
impact: 'Brief interruptions may occur during the maintenance window.',
updates: [
{ id: 'u5', timestamp: Date.now() - 15 * 60 * 1000, status: 'investigating', message: 'Starting scheduled database maintenance. Expected duration: 2 hours.', author: 'DBA Team' },
],
},
{
id: 'inc-4',
title: 'Authentication Service Outage',
status: 'resolved',
severity: 'critical',
affectedServices: ['auth', 'api', 'web'],
startTime: Date.now() - 24 * 60 * 60 * 1000,
endTime: Date.now() - 22 * 60 * 60 * 1000,
impact: 'Users were unable to log in or access authenticated features.',
rootCause: 'Certificate expiration on the identity provider.',
resolution: 'Renewed certificates and implemented automated monitoring for future expirations.',
updates: [
{ id: 'u6', timestamp: Date.now() - 22 * 60 * 60 * 1000, status: 'resolved', message: 'Issue has been fully resolved. All authentication services are operational.', author: 'Security Team' },
{ id: 'u7', timestamp: Date.now() - 23 * 60 * 60 * 1000, status: 'identified', message: 'Root cause identified: expired SSL certificate on identity provider.', author: 'Security Team' },
{ id: 'u8', timestamp: Date.now() - 24 * 60 * 60 * 1000, status: 'investigating', message: 'We are aware of authentication issues and are investigating.', author: 'On-call Engineer' },
],
},
{
id: 'inc-5',
title: 'Payment Processing Delays',
status: 'postmortem',
severity: 'major',
affectedServices: ['payment', 'api'],
startTime: Date.now() - 72 * 60 * 60 * 1000,
endTime: Date.now() - 70 * 60 * 60 * 1000,
impact: 'Payment transactions were delayed by up to 5 minutes.',
rootCause: 'Third-party payment provider experienced capacity issues.',
resolution: 'Provider resolved their capacity issues. Implemented fallback payment routing.',
updates: [
{ id: 'u9', timestamp: Date.now() - 48 * 60 * 60 * 1000, status: 'postmortem', message: 'Postmortem complete. Implementing additional redundancy measures.', author: 'Engineering Lead' },
{ id: 'u10', timestamp: Date.now() - 70 * 60 * 60 * 1000, status: 'resolved', message: 'Payment processing has returned to normal.', author: 'Payments Team' },
],
},
] as IIncidentDetails[]}
></upladmin-incident-list>
</div>
`;

View File

@@ -0,0 +1,722 @@
import * as plugins from '../../plugins.js';
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
unsafeCSS,
state,
} from '@design.estate/dees-element';
import * as sharedStyles from '../../styles/shared.styles.js';
import type { IIncidentDetails } from '../../interfaces/index.js';
import { demoFunc } from './upladmin-incident-list.demo.js';
declare global {
interface HTMLElementTagNameMap {
'upladmin-incident-list': UpladminIncidentList;
}
}
type TSeverity = 'critical' | 'major' | 'minor' | 'maintenance';
type TIncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved' | 'postmortem';
type TTabFilter = 'current' | 'past' | 'all';
@customElement('upladmin-incident-list')
export class UpladminIncidentList extends DeesElement {
public static demo = demoFunc;
@property({ type: Array })
accessor incidents: IIncidentDetails[] = [];
@property({ type: Boolean })
accessor loading: boolean = false;
@state()
accessor tabFilter: TTabFilter = 'current';
@state()
accessor severityFilter: TSeverity | 'all' = 'all';
@state()
accessor expandedIncidents: Set<string> = new Set();
private statusIcons: Record<TIncidentStatus, string> = {
investigating: 'lucide:Search',
identified: 'lucide:Target',
monitoring: 'lucide:Eye',
resolved: 'lucide:CheckCircle',
postmortem: 'lucide:FileText',
};
private statusLabels: Record<TIncidentStatus, string> = {
investigating: 'Investigating',
identified: 'Identified',
monitoring: 'Monitoring',
resolved: 'Resolved',
postmortem: 'Postmortem',
};
public static styles = [
plugins.domtools.elementBasic.staticStyles,
sharedStyles.commonStyles,
css`
:host {
display: block;
font-family: ${unsafeCSS(sharedStyles.fonts.base)};
}
.list-container {
background: ${sharedStyles.colors.background.secondary};
border: 1px solid ${sharedStyles.colors.border.default};
border-radius: ${unsafeCSS(sharedStyles.borderRadius.lg)};
overflow: hidden;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: ${unsafeCSS(sharedStyles.spacing.md)};
padding: ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.lg)};
border-bottom: 1px solid ${sharedStyles.colors.border.default};
flex-wrap: wrap;
}
.tabs {
display: flex;
gap: 4px;
background: ${sharedStyles.colors.background.muted};
padding: 4px;
border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)};
}
.tab {
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: ${sharedStyles.colors.text.secondary};
background: transparent;
border: none;
border-radius: ${unsafeCSS(sharedStyles.borderRadius.sm)};
cursor: pointer;
transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)};
display: flex;
align-items: center;
gap: 8px;
}
.tab:hover {
color: ${sharedStyles.colors.text.primary};
}
.tab.active {
background: ${sharedStyles.colors.background.primary};
color: ${sharedStyles.colors.text.primary};
box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)};
}
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
font-size: 11px;
font-weight: 600;
background: ${sharedStyles.colors.background.muted};
border-radius: 11px;
}
.tab.active .tab-count {
background: ${sharedStyles.colors.accent.primary};
color: white;
}
.list-controls {
display: flex;
align-items: center;
gap: ${unsafeCSS(sharedStyles.spacing.sm)};
}
.filter-select {
padding: 10px 32px 10px 12px;
font-size: 13px;
font-family: ${unsafeCSS(sharedStyles.fonts.base)};
color: ${sharedStyles.colors.text.primary};
background: ${sharedStyles.colors.background.primary};
border: 1px solid ${sharedStyles.colors.border.default};
border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)};
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2371717a' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.incidents-list {
padding: ${unsafeCSS(sharedStyles.spacing.sm)};
}
.incident-card {
background: ${sharedStyles.colors.background.primary};
border: 1px solid ${sharedStyles.colors.border.default};
border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)};
margin-bottom: ${unsafeCSS(sharedStyles.spacing.sm)};
overflow: hidden;
transition: box-shadow ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)};
}
.incident-card:last-child {
margin-bottom: 0;
}
.incident-card:hover {
box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)};
}
.incident-header {
display: flex;
align-items: flex-start;
gap: ${unsafeCSS(sharedStyles.spacing.md)};
padding: ${unsafeCSS(sharedStyles.spacing.md)};
cursor: pointer;
}
.incident-severity {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
}
.incident-severity.critical { background: ${sharedStyles.colors.status.majorOutage}; }
.incident-severity.major { background: ${sharedStyles.colors.status.partialOutage}; }
.incident-severity.minor { background: ${sharedStyles.colors.status.degraded}; }
.incident-severity.maintenance { background: ${sharedStyles.colors.status.maintenance}; }
.incident-main {
flex: 1;
min-width: 0;
}
.incident-title-row {
display: flex;
align-items: center;
gap: ${unsafeCSS(sharedStyles.spacing.sm)};
margin-bottom: 6px;
flex-wrap: wrap;
}
.incident-title {
font-size: 15px;
font-weight: 600;
color: ${sharedStyles.colors.text.primary};
margin: 0;
}
.incident-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.incident-status dees-icon {
font-size: 12px;
}
.incident-status.investigating {
background: ${cssManager.bdTheme('rgba(249, 115, 22, 0.1)', 'rgba(249, 115, 22, 0.2)')};
color: ${sharedStyles.colors.status.partialOutage};
--icon-color: ${sharedStyles.colors.status.partialOutage};
}
.incident-status.identified {
background: ${cssManager.bdTheme('rgba(234, 179, 8, 0.1)', 'rgba(234, 179, 8, 0.2)')};
color: ${sharedStyles.colors.status.degraded};
--icon-color: ${sharedStyles.colors.status.degraded};
}
.incident-status.monitoring {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.2)')};
color: ${sharedStyles.colors.status.maintenance};
--icon-color: ${sharedStyles.colors.status.maintenance};
}
.incident-status.resolved {
background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.1)', 'rgba(34, 197, 94, 0.2)')};
color: ${sharedStyles.colors.status.operational};
--icon-color: ${sharedStyles.colors.status.operational};
}
.incident-status.postmortem {
background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.1)', 'rgba(168, 85, 247, 0.2)')};
color: #a855f7;
--icon-color: #a855f7;
}
.incident-meta {
display: flex;
align-items: center;
gap: ${unsafeCSS(sharedStyles.spacing.md)};
font-size: 12px;
color: ${sharedStyles.colors.text.muted};
flex-wrap: wrap;
}
.incident-meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.incident-meta-item dees-icon {
--icon-color: ${sharedStyles.colors.text.muted};
opacity: 0.7;
}
.incident-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.incident-expand {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${sharedStyles.colors.text.muted};
background: transparent;
border: none;
border-radius: ${unsafeCSS(sharedStyles.borderRadius.sm)};
cursor: pointer;
transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)};
}
.incident-expand:hover {
background: ${sharedStyles.colors.background.muted};
color: ${sharedStyles.colors.text.primary};
}
.incident-expand.expanded dees-icon {
transform: rotate(180deg);
}
.incident-expand dees-icon {
transition: transform ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)};
--icon-color: currentColor;
}
.incident-details {
padding: 0 ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)};
padding-left: calc(${unsafeCSS(sharedStyles.spacing.md)} + 4px + ${unsafeCSS(sharedStyles.spacing.md)});
border-top: 1px solid ${sharedStyles.colors.border.light};
}
.detail-section {
margin-top: ${unsafeCSS(sharedStyles.spacing.md)};
}
.detail-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${sharedStyles.colors.text.muted};
margin-bottom: 8px;
}
.detail-text {
font-size: 13px;
color: ${sharedStyles.colors.text.primary};
line-height: 1.6;
}
.services-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.service-tag {
display: inline-block;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
background: ${sharedStyles.colors.background.muted};
color: ${sharedStyles.colors.text.secondary};
border-radius: ${unsafeCSS(sharedStyles.borderRadius.sm)};
}
.updates-timeline {
position: relative;
padding-left: 24px;
}
.updates-timeline::before {
content: '';
position: absolute;
left: 7px;
top: 8px;
bottom: 8px;
width: 2px;
background: ${sharedStyles.colors.border.default};
border-radius: 1px;
}
.update-item {
position: relative;
padding-bottom: ${unsafeCSS(sharedStyles.spacing.md)};
}
.update-item:last-child {
padding-bottom: 0;
}
.update-item::before {
content: '';
position: absolute;
left: -17px;
top: 6px;
width: 10px;
height: 10px;
background: ${sharedStyles.colors.background.secondary};
border: 2px solid ${sharedStyles.colors.accent.primary};
border-radius: 50%;
}
.update-header {
display: flex;
align-items: center;
gap: ${unsafeCSS(sharedStyles.spacing.sm)};
margin-bottom: 6px;
}
.update-status {
font-size: 13px;
font-weight: 600;
text-transform: capitalize;
color: ${sharedStyles.colors.text.primary};
}
.update-time {
font-size: 11px;
color: ${sharedStyles.colors.text.muted};
}
.update-message {
font-size: 13px;
color: ${sharedStyles.colors.text.secondary};
line-height: 1.6;
}
.update-author {
font-size: 11px;
color: ${sharedStyles.colors.text.muted};
margin-top: 6px;
font-style: italic;
}
.empty-state {
padding: 64px 24px;
text-align: center;
}
.empty-state dees-icon {
--icon-color: ${sharedStyles.colors.status.operational};
opacity: 0.6;
margin-bottom: 20px;
}
.empty-title {
font-size: 16px;
font-weight: 600;
color: ${sharedStyles.colors.text.primary};
margin-bottom: 8px;
}
.empty-text {
font-size: 14px;
color: ${sharedStyles.colors.text.muted};
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${cssManager.bdTheme('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)')};
z-index: 10;
backdrop-filter: blur(2px);
}
`
];
public render(): TemplateResult {
const filteredIncidents = this.getFilteredIncidents();
const currentCount = this.incidents.filter(i => !['resolved', 'postmortem'].includes(i.status)).length;
const pastCount = this.incidents.filter(i => ['resolved', 'postmortem'].includes(i.status)).length;
return html`
<div class="list-container" style="position: relative;">
${this.loading ? html`
<div class="loading-overlay">
<dees-spinner></dees-spinner>
</div>
` : ''}
<div class="list-header">
<div class="tabs">
<button
class="tab ${this.tabFilter === 'current' ? 'active' : ''}"
@click="${() => this.tabFilter = 'current'}"
>
Current
<span class="tab-count">${currentCount}</span>
</button>
<button
class="tab ${this.tabFilter === 'past' ? 'active' : ''}"
@click="${() => this.tabFilter = 'past'}"
>
Past
<span class="tab-count">${pastCount}</span>
</button>
<button
class="tab ${this.tabFilter === 'all' ? 'active' : ''}"
@click="${() => this.tabFilter = 'all'}"
>
All
<span class="tab-count">${this.incidents.length}</span>
</button>
</div>
<div class="list-controls">
<select class="filter-select" @change="${this.handleSeverityFilter}">
<option value="all" ?selected="${this.severityFilter === 'all'}">All Severities</option>
<option value="critical" ?selected="${this.severityFilter === 'critical'}">Critical</option>
<option value="major" ?selected="${this.severityFilter === 'major'}">Major</option>
<option value="minor" ?selected="${this.severityFilter === 'minor'}">Minor</option>
<option value="maintenance" ?selected="${this.severityFilter === 'maintenance'}">Maintenance</option>
</select>
<dees-button type="highlighted" @click="${this.handleAddClick}">
<dees-icon .icon=${'lucide:Plus'} .iconSize=${16}></dees-icon>
New Incident
</dees-button>
</div>
</div>
${filteredIncidents.length > 0 ? html`
<div class="incidents-list">
${filteredIncidents.map(incident => this.renderIncidentCard(incident))}
</div>
` : html`
<div class="empty-state">
<dees-icon .icon=${'lucide:PartyPopper'} .iconSize=${48}></dees-icon>
<div class="empty-title">
${this.tabFilter === 'current' ? 'No active incidents' : 'No incidents found'}
</div>
<div class="empty-text">
${this.tabFilter === 'current'
? 'All systems are operating normally'
: 'Try adjusting your filters'}
</div>
</div>
`}
</div>
`;
}
private renderIncidentCard(incident: IIncidentDetails): TemplateResult {
const isExpanded = this.expandedIncidents.has(incident.id);
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
const formatDuration = (start: number, end?: number) => {
const duration = (end || Date.now()) - start;
const hours = Math.floor(duration / (1000 * 60 * 60));
const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
return html`
<div class="incident-card">
<div class="incident-header" @click="${() => this.toggleExpanded(incident.id)}">
<div class="incident-severity ${incident.severity}"></div>
<div class="incident-main">
<div class="incident-title-row">
<h3 class="incident-title">${incident.title}</h3>
<span class="incident-status ${incident.status}">
<dees-icon .icon=${this.statusIcons[incident.status]} .iconSize=${12}></dees-icon>
${this.statusLabels[incident.status]}
</span>
</div>
<div class="incident-meta">
<span class="incident-meta-item">
<dees-icon .icon=${'lucide:Calendar'} .iconSize=${12}></dees-icon>
${formatTime(incident.startTime)}
</span>
<span class="incident-meta-item">
<dees-icon .icon=${'lucide:Clock'} .iconSize=${12}></dees-icon>
${formatDuration(incident.startTime, incident.endTime)}
</span>
<span class="incident-meta-item">
<dees-icon .icon=${'lucide:Server'} .iconSize=${12}></dees-icon>
${incident.affectedServices.length} services
</span>
<span class="incident-meta-item">
<dees-icon .icon=${'lucide:MessageSquare'} .iconSize=${12}></dees-icon>
${incident.updates.length} updates
</span>
</div>
</div>
<div class="incident-actions" @click="${(e: Event) => e.stopPropagation()}">
${!['resolved', 'postmortem'].includes(incident.status) ? html`
<dees-button type="highlighted" @click="${() => this.handleAddUpdate(incident)}">
<dees-icon .icon=${'lucide:Plus'} .iconSize=${14}></dees-icon>
Update
</dees-button>
` : ''}
<dees-button type="discreet" @click="${() => this.handleEdit(incident)}">
<dees-icon .icon=${'lucide:Pencil'} .iconSize=${14}></dees-icon>
</dees-button>
</div>
<button class="incident-expand ${isExpanded ? 'expanded' : ''}">
<dees-icon .icon=${'lucide:ChevronDown'} .iconSize=${16}></dees-icon>
</button>
</div>
${isExpanded ? html`
<div class="incident-details">
<div class="detail-section">
<div class="detail-label">Impact</div>
<div class="detail-text">${incident.impact}</div>
</div>
<div class="detail-section">
<div class="detail-label">Affected Services</div>
<div class="services-list">
${incident.affectedServices.map(service => html`
<span class="service-tag">${service}</span>
`)}
</div>
</div>
${incident.rootCause ? html`
<div class="detail-section">
<div class="detail-label">Root Cause</div>
<div class="detail-text">${incident.rootCause}</div>
</div>
` : ''}
${incident.resolution ? html`
<div class="detail-section">
<div class="detail-label">Resolution</div>
<div class="detail-text">${incident.resolution}</div>
</div>
` : ''}
${incident.updates.length > 0 ? html`
<div class="detail-section">
<div class="detail-label">Updates Timeline</div>
<div class="updates-timeline">
${incident.updates.slice().reverse().map(update => html`
<div class="update-item">
<div class="update-header">
<span class="update-status">${update.status}</span>
<span class="update-time">${formatTime(update.timestamp)}</span>
</div>
<div class="update-message">${update.message}</div>
${update.author ? html`<div class="update-author">— ${update.author}</div>` : ''}
</div>
`)}
</div>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
private getFilteredIncidents(): IIncidentDetails[] {
let result = [...this.incidents];
// Tab filter
switch (this.tabFilter) {
case 'current':
result = result.filter(i => !['resolved', 'postmortem'].includes(i.status));
break;
case 'past':
result = result.filter(i => ['resolved', 'postmortem'].includes(i.status));
break;
}
// Severity filter
if (this.severityFilter !== 'all') {
result = result.filter(i => i.severity === this.severityFilter);
}
// Sort by start time descending
result.sort((a, b) => b.startTime - a.startTime);
return result;
}
private handleSeverityFilter(e: Event) {
this.severityFilter = (e.target as HTMLSelectElement).value as TSeverity | 'all';
}
private toggleExpanded(incidentId: string) {
const newSet = new Set(this.expandedIncidents);
if (newSet.has(incidentId)) {
newSet.delete(incidentId);
} else {
newSet.add(incidentId);
}
this.expandedIncidents = newSet;
}
private handleAddClick() {
this.dispatchEvent(new CustomEvent('incidentAdd', {
bubbles: true,
composed: true
}));
}
private handleEdit(incident: IIncidentDetails) {
this.dispatchEvent(new CustomEvent('incidentEdit', {
detail: { incident },
bubbles: true,
composed: true
}));
}
private handleAddUpdate(incident: IIncidentDetails) {
this.dispatchEvent(new CustomEvent('incidentAddUpdate', {
detail: { incident },
bubbles: true,
composed: true
}));
}
}