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; } } type TIncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved' | 'postmortem'; @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(); private statusIcons: Record = { investigating: 'lucide:Search', identified: 'lucide:Target', monitoring: 'lucide:Eye', resolved: 'lucide:CheckCircle', postmortem: 'lucide:FileText', }; private statusLabels: Record = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved', postmortem: 'Postmortem', }; 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)')}; } } /* New header layout matching admin catalog */ .incident-header { display: flex; align-items: flex-start; gap: 16px; padding: 16px; cursor: pointer; transition: background-color ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; } .incident-header:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(255, 255, 255, 0.02)')}; } /* Internal severity bar (replacing border-left) */ .incident-severity { width: 4px; align-self: stretch; border-radius: 2px; flex-shrink: 0; } .incident-severity.critical { background: ${sharedStyles.colors.status.major}; } .incident-severity.major { background: ${sharedStyles.colors.status.partial}; } .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: 8px; margin-bottom: 6px; flex-wrap: wrap; } .incident-title { font-size: 15px; font-weight: 600; margin: 0; color: ${sharedStyles.colors.text.primary}; } /* Status badge inline with title */ .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; flex-shrink: 0; } .incident-status dees-icon { --icon-size: 12px; } .incident-status.investigating { background: ${cssManager.bdTheme('rgba(249, 115, 22, 0.1)', 'rgba(249, 115, 22, 0.2)')}; color: ${cssManager.bdTheme('#ea580c', '#fb923c')}; --icon-color: ${cssManager.bdTheme('#ea580c', '#fb923c')}; } .incident-status.identified { background: ${cssManager.bdTheme('rgba(234, 179, 8, 0.1)', 'rgba(234, 179, 8, 0.2)')}; color: ${cssManager.bdTheme('#ca8a04', '#facc15')}; --icon-color: ${cssManager.bdTheme('#ca8a04', '#facc15')}; } .incident-status.monitoring { background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.2)')}; color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; --icon-color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; } .incident-status.resolved { background: ${cssManager.bdTheme('rgba(34, 197, 94, 0.1)', 'rgba(34, 197, 94, 0.2)')}; color: ${cssManager.bdTheme('#16a34a', '#4ade80')}; --icon-color: ${cssManager.bdTheme('#16a34a', '#4ade80')}; } .incident-status.postmortem { background: ${cssManager.bdTheme('rgba(168, 85, 247, 0.1)', 'rgba(168, 85, 247, 0.2)')}; color: ${cssManager.bdTheme('#9333ea', '#c084fc')}; --icon-color: ${cssManager.bdTheme('#9333ea', '#c084fc')}; } .incident-meta { display: flex; align-items: center; gap: 16px; font-size: 12px; color: ${sharedStyles.colors.text.secondary}; flex-wrap: wrap; } .incident-meta-item { display: flex; align-items: center; gap: 6px; } .incident-meta-item dees-icon { --icon-size: 12px; --icon-color: ${sharedStyles.colors.text.muted}; } .incident-expand { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: ${sharedStyles.colors.text.muted}; transition: all ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; flex-shrink: 0; } .incident-expand:hover { background: ${sharedStyles.colors.background.muted}; color: ${sharedStyles.colors.text.primary}; } .incident-expand dees-icon { --icon-size: 16px; transition: transform ${unsafeCSS(sharedStyles.durations.fast)} ${unsafeCSS(sharedStyles.easings.default)}; } .incident-expand.expanded dees-icon { transform: rotate(180deg); } .incident-body { padding: 0 16px 16px 36px; 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; } .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; } .update-item:not(:last-child)::after { content: ''; position: absolute; left: -15px; top: 18px; bottom: 0; width: 2px; background: ${sharedStyles.colors.border.default}; } .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')}; } @media (max-width: 640px) { .container { padding: 0 ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)} ${unsafeCSS(sharedStyles.spacing.md)}; } .incident-header { padding: 12px; } .incident-body { padding: 0 12px 12px 24px; } .incident-meta { flex-direction: column; align-items: flex-start; gap: 8px; } .timeline { padding-left: 20px; } .update-item::before { left: -18px; width: 10px; height: 10px; } .update-item:not(:last-child)::after { left: -12px; top: 16px; } } `, ]; 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'; const isExpanded = this.expandedIncidents.has(incident.id); return html`
this.toggleIncident(incident.id)}>

${incident.title}

${this.statusLabels[latestUpdate.status as TIncidentStatus] || latestUpdate.status}
${this.formatDate(incident.startTime)} ${duration} ${incident.affectedServices.length} service${incident.affectedServices.length !== 1 ? 's' : ''} ${incident.updates.length} update${incident.updates.length !== 1 ? 's' : ''}
${isExpanded ? html`
${incident.impact ? 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 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 })); } }