import * as plugins from '../plugins.js'; import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager, unsafeCSS, } from '@design.estate/dees-element'; import type { IIncidentDetails } from '../interfaces/index.js'; import * as sharedStyles from '../styles/shared.styles.js'; import './internal/uplinternal-miniheading.js'; import { demoFunc } from './upl-statuspage-incidents.demo.js'; declare global { interface HTMLElementTagNameMap { 'upl-statuspage-incidents': UplStatuspageIncidents; } } @customElement('upl-statuspage-incidents') export class UplStatuspageIncidents extends DeesElement { // STATIC public static demo = demoFunc; // INSTANCE @property({ type: Array, }) accessor currentIncidents: IIncidentDetails[] = []; @property({ type: Array, }) accessor pastIncidents: IIncidentDetails[] = []; @property({ type: Boolean, }) accessor whitelabel = false; @property({ type: Boolean, }) accessor loading = false; @property({ type: Number, }) accessor daysToShow = 90; @property({ type: Array, }) accessor subscribedIncidentIds: string[] = []; @property({ type: Object, state: true, }) private accessor expandedIncidents: Set = new Set(); @property({ type: Object, state: true, }) private accessor subscribedIncidents: Set = new Set(); constructor() { super(); } async connectedCallback() { await super.connectedCallback(); // Initialize subscribed incidents from the property if (this.subscribedIncidentIds.length > 0) { this.subscribedIncidents = new Set(this.subscribedIncidentIds); } } updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('subscribedIncidentIds')) { this.subscribedIncidents = new Set(this.subscribedIncidentIds); } } public static styles = [ plugins.domtools.elementBasic.staticStyles, sharedStyles.commonStyles, css` :host { display: block; background: transparent; font-family: ${unsafeCSS(sharedStyles.fonts.base)}; color: ${sharedStyles.colors.text.primary}; } .container { max-width: 1200px; margin: 0 auto; padding: 0 ${unsafeCSS(sharedStyles.spacing.lg)} ${unsafeCSS(sharedStyles.spacing.lg)} ${unsafeCSS(sharedStyles.spacing.lg)}; } .noIncidentBox { background: ${sharedStyles.colors.background.card}; padding: ${unsafeCSS(sharedStyles.spacing.xl)}; margin-bottom: ${unsafeCSS(sharedStyles.spacing.lg)}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.lg)}; border: 1px solid ${sharedStyles.colors.border.default}; text-align: center; color: ${sharedStyles.colors.text.secondary}; box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)}; animation: fadeInUp 0.4s ${unsafeCSS(sharedStyles.easings.default)} both; } /* Staggered entrance animations */ .incident-card { background: ${sharedStyles.colors.background.card}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.lg)}; margin-bottom: ${unsafeCSS(sharedStyles.spacing.lg)}; overflow: hidden; box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)}; border: 1px solid ${sharedStyles.colors.border.default}; transition: all ${unsafeCSS(sharedStyles.durations.normal)} ${unsafeCSS(sharedStyles.easings.default)}; animation: fadeInUp 0.4s ${unsafeCSS(sharedStyles.easings.default)} both; } .incident-card:nth-child(1) { animation-delay: 0ms; } .incident-card:nth-child(2) { animation-delay: 50ms; } .incident-card:nth-child(3) { animation-delay: 100ms; } .incident-card:nth-child(4) { animation-delay: 150ms; } .incident-card:nth-child(5) { animation-delay: 200ms; } .incident-card:hover { transform: translateY(-2px); box-shadow: ${unsafeCSS(sharedStyles.shadows.md)}; border-color: ${sharedStyles.colors.border.muted}; } .incident-card.expanded { box-shadow: ${unsafeCSS(sharedStyles.shadows.lg)}; } /* Active incident pulse effect */ .incident-card.active-incident { animation: fadeInUp 0.4s ${unsafeCSS(sharedStyles.easings.default)} both, incident-pulse 3s ease-in-out infinite; } @keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } @keyframes incident-pulse { 0%, 100% { box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)}; } 50% { box-shadow: ${unsafeCSS(sharedStyles.shadows.md)}, 0 0 0 2px ${cssManager.bdTheme('rgba(239, 68, 68, 0.15)', 'rgba(248, 113, 113, 0.2)')}; } } .incident-header { padding: ${unsafeCSS(sharedStyles.spacing.lg)} ${unsafeCSS(sharedStyles.spacing.xl)}; border-left: 4px solid; display: flex; align-items: start; justify-content: space-between; gap: ${unsafeCSS(sharedStyles.spacing.md)}; cursor: pointer; transition: background-color ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; position: relative; } .incident-header:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(255, 255, 255, 0.02)')}; } .incident-header.critical { border-left-color: ${sharedStyles.colors.status.major}; } .incident-header.major { border-left-color: ${sharedStyles.colors.status.partial}; } .incident-header.minor { border-left-color: ${sharedStyles.colors.status.degraded}; } .incident-header.maintenance { border-left-color: ${sharedStyles.colors.status.maintenance}; } .incident-title { font-size: 17px; font-weight: 600; margin: 0; line-height: 1.4; letter-spacing: -0.01em; } .incident-meta { display: flex; gap: ${unsafeCSS(sharedStyles.spacing.lg)}; margin-top: ${unsafeCSS(sharedStyles.spacing.sm)}; font-size: 13px; color: ${sharedStyles.colors.text.secondary}; flex-wrap: wrap; } .incident-meta span { display: flex; align-items: center; gap: 4px; } .incident-status { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: ${unsafeCSS(sharedStyles.borderRadius.full)}; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; } .incident-status.investigating { background: ${cssManager.bdTheme('#fef3c7', '#78350f')}; color: ${cssManager.bdTheme('#92400e', '#fbbf24')}; } .incident-status.identified { background: ${cssManager.bdTheme('#e9d5ff', '#581c87')}; color: ${cssManager.bdTheme('#6b21a8', '#d8b4fe')}; } .incident-status.monitoring { background: ${cssManager.bdTheme('#dbeafe', '#1e3a8a')}; color: ${cssManager.bdTheme('#1e40af', '#93c5fd')}; } .incident-status.resolved { background: ${cssManager.bdTheme('#d1fae5', '#064e3b')}; color: ${cssManager.bdTheme('#047857', '#6ee7b7')}; } .incident-status.postmortem { background: ${cssManager.bdTheme('#e5e7eb', '#374151')}; color: ${cssManager.bdTheme('#4b5563', '#d1d5db')}; } /* Pulse for investigating status */ .incident-status.investigating .status-dot { animation: status-pulse 1.5s ease-in-out infinite; } @keyframes status-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(1.2); } } .incident-body { padding: 0 ${unsafeCSS(sharedStyles.spacing.xl)} ${unsafeCSS(sharedStyles.spacing.xl)} ${unsafeCSS(sharedStyles.spacing.xl)}; animation: slideDown 0.3s ${unsafeCSS(sharedStyles.easings.default)}; } @keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } .incident-impact { margin: ${unsafeCSS(sharedStyles.spacing.md)} 0; padding: ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.lg)}; background: ${sharedStyles.colors.background.secondary}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)}; font-size: 14px; line-height: 1.6; border-left: 3px solid ${sharedStyles.colors.border.muted}; } .affected-services { margin-top: ${unsafeCSS(sharedStyles.spacing.lg)}; } .affected-services-title { font-size: 12px; font-weight: 600; margin-bottom: ${unsafeCSS(sharedStyles.spacing.sm)}; color: ${sharedStyles.colors.text.secondary}; text-transform: uppercase; letter-spacing: 0.04em; } .service-tag { display: inline-block; padding: 4px 10px; margin: 2px; background: ${sharedStyles.colors.background.muted}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.sm)}; font-size: 12px; color: ${sharedStyles.colors.text.secondary}; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; } .service-tag:hover { background: ${sharedStyles.colors.background.secondary}; color: ${sharedStyles.colors.text.primary}; } /* Timeline visualization for updates */ .incident-updates { margin-top: ${unsafeCSS(sharedStyles.spacing.xl)}; border-top: 1px solid ${sharedStyles.colors.border.default}; padding-top: ${unsafeCSS(sharedStyles.spacing.lg)}; } .updates-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; margin: 0 0 ${unsafeCSS(sharedStyles.spacing.lg)} 0; color: ${sharedStyles.colors.text.secondary}; } .timeline { position: relative; padding-left: 24px; } /* Vertical connector line */ .timeline::before { content: ''; position: absolute; left: 5px; top: 8px; bottom: 8px; width: 2px; background: ${cssManager.bdTheme( 'linear-gradient(to bottom, #e5e7eb 0%, #d1d5db 50%, #e5e7eb 100%)', 'linear-gradient(to bottom, #27272a 0%, #3f3f46 50%, #27272a 100%)' )}; border-radius: 1px; } .update-item { position: relative; padding-left: ${unsafeCSS(sharedStyles.spacing.lg)}; padding-bottom: ${unsafeCSS(sharedStyles.spacing.lg)}; animation: fadeInUp 0.3s ${unsafeCSS(sharedStyles.easings.default)} both; } .update-item:last-child { padding-bottom: 0; } /* Timeline dot */ .update-item::before { content: ''; position: absolute; left: -22px; top: 4px; width: 12px; height: 12px; border-radius: 50%; background: ${sharedStyles.colors.background.card}; border: 2px solid ${sharedStyles.colors.border.muted}; z-index: 1; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; } .update-item:first-child::before { border-color: ${sharedStyles.colors.status.operational}; background: ${sharedStyles.colors.status.operational}; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(22, 163, 74, 0.15)', 'rgba(34, 197, 94, 0.2)')}; } .update-item:hover::before { transform: scale(1.2); border-color: ${sharedStyles.colors.text.secondary}; } .update-time { font-size: 11px; color: ${sharedStyles.colors.text.muted}; margin-bottom: 4px; font-family: ${unsafeCSS(sharedStyles.fonts.mono)}; display: flex; align-items: center; gap: 8px; } .update-status-badge { display: inline-flex; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; background: ${sharedStyles.colors.background.muted}; color: ${sharedStyles.colors.text.secondary}; } .update-message { font-size: 14px; line-height: 1.6; color: ${sharedStyles.colors.text.primary}; } .update-author { font-size: 12px; color: ${sharedStyles.colors.text.muted}; margin-top: 4px; display: flex; align-items: center; gap: 4px; } .loading-skeleton { height: 140px; background: ${cssManager.bdTheme( 'linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%)', 'linear-gradient(90deg, #1f1f1f 25%, #262626 50%, #1f1f1f 75%)' )}; background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: ${unsafeCSS(sharedStyles.borderRadius.lg)}; margin-bottom: ${unsafeCSS(sharedStyles.spacing.lg)}; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .show-more { text-align: center; margin-top: ${unsafeCSS(sharedStyles.spacing.xl)}; } .show-more-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; background: transparent; border: 1px solid ${sharedStyles.colors.border.default}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)}; cursor: pointer; font-size: 14px; font-weight: 500; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; color: ${sharedStyles.colors.text.primary}; font-family: ${unsafeCSS(sharedStyles.fonts.base)}; } .show-more-button:hover { background: ${sharedStyles.colors.background.secondary}; border-color: ${sharedStyles.colors.border.muted}; transform: translateY(-2px); box-shadow: ${unsafeCSS(sharedStyles.shadows.sm)}; } .show-more-button:active { transform: translateY(0); } .incident-actions { display: flex; gap: ${unsafeCSS(sharedStyles.spacing.md)}; align-items: center; margin-top: ${unsafeCSS(sharedStyles.spacing.lg)}; padding-top: ${unsafeCSS(sharedStyles.spacing.lg)}; border-top: 1px solid ${sharedStyles.colors.border.default}; } .subscribe-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; background: transparent; border: 1px solid ${sharedStyles.colors.border.default}; border-radius: ${unsafeCSS(sharedStyles.borderRadius.base)}; cursor: pointer; font-size: 13px; font-weight: 500; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; color: ${sharedStyles.colors.text.primary}; font-family: ${unsafeCSS(sharedStyles.fonts.base)}; } .subscribe-button:hover { background: ${sharedStyles.colors.background.secondary}; border-color: ${sharedStyles.colors.border.muted}; transform: translateY(-1px); } .subscribe-button.subscribed { background: ${cssManager.bdTheme('#f0fdf4', '#064e3b')}; border-color: ${cssManager.bdTheme('#86efac', '#047857')}; color: ${cssManager.bdTheme('#047857', '#86efac')}; } .subscribe-button.subscribed:hover { background: ${cssManager.bdTheme('#dcfce7', '#065f46')}; } .collapsed-hint { font-size: 12px; color: ${sharedStyles.colors.text.secondary}; text-align: center; margin-top: ${unsafeCSS(sharedStyles.spacing.md)}; opacity: 0.8; } /* Expand icon animation */ .expand-icon { transition: transform ${unsafeCSS(sharedStyles.durations.normal)} ${unsafeCSS(sharedStyles.easings.default)}; } .expand-icon.rotated { transform: rotate(180deg); } @media (max-width: 640px) { .container { padding: 0 ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)}; } .incident-header { padding: ${unsafeCSS(sharedStyles.spacing.md)}; } .incident-body { padding: 0 ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)}; } .incident-meta { flex-direction: column; gap: ${unsafeCSS(sharedStyles.spacing.xs)}; } .timeline { padding-left: 20px; } .timeline::before { left: 4px; } .update-item::before { left: -18px; width: 10px; height: 10px; } } `, ]; public render(): TemplateResult { return html`
Current Incidents ${this.loading ? html`
` : this.currentIncidents.length ? html` ${this.currentIncidents.map(incident => this.renderIncident(incident, true))} ` : html`
No incidents ongoing.
` } Past Incidents ${this.loading ? html`
` : this.pastIncidents.length ? this.pastIncidents.slice(0, 5).map(incident => this.renderIncident(incident, false)) : html`
No past incidents in the last ${this.daysToShow} days.
` } ${this.pastIncidents.length > 5 && !this.loading ? html`
` : ''}
`; } private renderIncident(incident: IIncidentDetails, isCurrent: boolean): TemplateResult { const latestUpdate = incident.updates[incident.updates.length - 1]; const duration = incident.endTime ? this.formatDuration(incident.endTime - incident.startTime) : this.formatDuration(Date.now() - incident.startTime); const isActive = isCurrent && latestUpdate?.status !== 'resolved'; return html`
this.toggleIncident(incident.id)}>

${incident.title}

Started: ${this.formatDate(incident.startTime)} Duration: ${duration} ${incident.endTime ? html` Ended: ${this.formatDate(incident.endTime)} ` : ''}
${!this.expandedIncidents.has(incident.id) ? html`
${incident.impact ? html` ${incident.impact} ` : ''} ${incident.updates.length} update${incident.updates.length !== 1 ? 's' : ''}
` : ''}
${this.getStatusIcon(latestUpdate.status)} ${latestUpdate.status.replace(/_/g, ' ')}
${this.expandedIncidents.has(incident.id) ? html`
Impact: ${incident.impact}
${incident.affectedServices.length > 0 ? html`
Affected Services:
${incident.affectedServices.map(service => html` ${service} `)}
` : ''} ${incident.updates.length > 0 ? html`

Updates

${incident.updates.slice(-3).reverse().map((update, index) => this.renderUpdate(update, index))}
` : ''} ${incident.rootCause && isCurrent === false ? html`
Root Cause: ${incident.rootCause}
` : ''} ${incident.resolution && isCurrent === false ? html`
Resolution: ${incident.resolution}
` : ''}
${isCurrent ? html` Get notified when this incident is updated or resolved ` : ''}
` : ''}
`; } private renderUpdate(update: any, index: number = 0): TemplateResult { return html`
${this.formatDate(update.timestamp)} ${update.status ? html`${update.status}` : ''}
${update.message}
${update.author ? html`
${update.author}
` : ''}
`; } private getStatusIcon(status: string): TemplateResult { return html``; } private formatDate(timestamp: number): string { const date = new Date(timestamp); const now = Date.now(); const diff = now - timestamp; // Less than 1 hour ago if (diff < 60 * 60 * 1000) { const minutes = Math.floor(diff / (60 * 1000)); return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; } // Less than 24 hours ago if (diff < 24 * 60 * 60 * 1000) { const hours = Math.floor(diff / (60 * 60 * 1000)); return `${hours} hour${hours !== 1 ? 's' : ''} ago`; } // Less than 7 days ago if (diff < 7 * 24 * 60 * 60 * 1000) { const days = Math.floor(diff / (24 * 60 * 60 * 1000)); return `${days} day${days !== 1 ? 's' : ''} ago`; } // Default to full date return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined }); } private formatDuration(milliseconds: number): string { const minutes = Math.floor(milliseconds / (60 * 1000)); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days}d ${hours % 24}h`; } else if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else { return `${minutes}m`; } } private toggleIncident(incidentId: string) { const newExpanded = new Set(this.expandedIncidents); if (newExpanded.has(incidentId)) { newExpanded.delete(incidentId); } else { newExpanded.add(incidentId); } this.expandedIncidents = newExpanded; } private handleIncidentClick(incident: IIncidentDetails) { this.dispatchEvent(new CustomEvent('incidentClick', { detail: { incident }, bubbles: true, composed: true })); } private handleShowMore() { // This would typically load more incidents or navigate to a full list console.log('Show more incidents'); } private isSubscribedToIncident(incidentId: string): boolean { return this.subscribedIncidents.has(incidentId); } private handleIncidentSubscribe(incident: IIncidentDetails) { const newSubscribed = new Set(this.subscribedIncidents); if (newSubscribed.has(incident.id)) { newSubscribed.delete(incident.id); this.dispatchEvent(new CustomEvent('incidentUnsubscribe', { detail: { incident, incidentId: incident.id }, bubbles: true, composed: true })); } else { newSubscribed.add(incident.id); this.dispatchEvent(new CustomEvent('incidentSubscribe', { detail: { incident, incidentId: incident.id, incidentTitle: incident.title, affectedServices: incident.affectedServices }, bubbles: true, composed: true })); } this.subscribedIncidents = newSubscribed; } public dispatchReportNewIncident() { this.dispatchEvent(new CustomEvent('reportNewIncident', { bubbles: true, composed: true })); } public dispatchStatusSubscribe() { this.dispatchEvent(new CustomEvent('statusSubscribe', { bubbles: true, composed: true })); } }