Files
catalog/ts_web/elements/upl-statuspage-incidents.ts

714 lines
22 KiB
TypeScript
Raw Normal View History

2022-03-24 16:07:15 +01:00
import * as plugins from '../plugins.js';
2021-09-27 00:49:30 +02:00
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
2021-09-27 00:49:30 +02:00
css,
cssManager,
2025-06-29 19:55:58 +00:00
unsafeCSS,
} from '@design.estate/dees-element';
2025-06-29 19:55:58 +00:00
import type { IIncidentDetails } from '../interfaces/index.js';
import { fonts, colors, shadows, borderRadius, spacing, commonStyles } from '../styles/shared.styles.js';
import './internal/uplinternal-miniheading.js';
import { demoFunc } from './upl-statuspage-incidents.demo.js';
2021-09-27 00:45:17 +02:00
declare global {
interface HTMLElementTagNameMap {
'upl-statuspage-incidents': UplStatuspageIncidents;
}
}
@customElement('upl-statuspage-incidents')
export class UplStatuspageIncidents extends DeesElement {
// STATIC
2025-06-29 19:55:58 +00:00
public static demo = demoFunc;
2021-09-27 00:45:17 +02:00
// INSTANCE
@property({
2021-09-27 00:49:30 +02:00
type: Array,
2021-09-27 00:45:17 +02:00
})
2025-06-29 19:55:58 +00:00
public currentIncidents: IIncidentDetails[] = [];
2021-09-27 00:45:17 +02:00
@property({
2021-09-27 00:49:30 +02:00
type: Array,
2021-09-27 00:45:17 +02:00
})
2025-06-29 19:55:58 +00:00
public pastIncidents: IIncidentDetails[] = [];
2021-09-27 00:45:17 +02:00
@property({
2021-09-27 00:49:30 +02:00
type: Boolean,
2021-09-27 00:45:17 +02:00
})
public whitelabel = false;
2025-06-29 19:55:58 +00:00
@property({
type: Boolean,
})
public loading = false;
@property({
type: Number,
})
public daysToShow = 90;
2025-06-30 07:54:17 +00:00
@property({
type: Array,
})
public subscribedIncidentIds: string[] = [];
2025-06-29 23:43:17 +00:00
@property({
type: Object,
state: true,
})
private expandedIncidents: Set<string> = new Set();
2025-06-30 07:54:17 +00:00
@property({
type: Object,
state: true,
})
private subscribedIncidents: Set<string> = new Set();
2021-09-27 00:45:17 +02:00
constructor() {
super();
}
2025-06-30 07:54:17 +00:00
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<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('subscribedIncidentIds')) {
this.subscribedIncidents = new Set(this.subscribedIncidentIds);
}
}
2021-09-27 00:45:17 +02:00
public static styles = [
plugins.domtools.elementBasic.staticStyles,
2025-06-29 19:55:58 +00:00
commonStyles,
2021-09-27 00:45:17 +02:00
css`
:host {
2021-09-27 00:49:30 +02:00
display: block;
2025-06-29 19:55:58 +00:00
background: transparent;
font-family: ${unsafeCSS(fonts.base)};
color: ${colors.text.primary};
2021-09-27 00:49:30 +02:00
}
2021-09-27 00:45:17 +02:00
2025-06-29 19:55:58 +00:00
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 ${unsafeCSS(spacing.lg)} ${unsafeCSS(spacing.lg)} ${unsafeCSS(spacing.lg)};
2021-09-27 00:49:30 +02:00
}
2021-09-27 00:45:17 +02:00
2021-09-27 00:49:30 +02:00
.noIncidentBox {
2025-06-29 19:55:58 +00:00
background: ${colors.background.card};
padding: ${unsafeCSS(spacing.xl)};
margin-bottom: ${unsafeCSS(spacing.lg)};
border-radius: ${unsafeCSS(borderRadius.md)};
border: 1px solid ${colors.border.default};
text-align: center;
color: ${colors.text.secondary};
box-shadow: ${unsafeCSS(shadows.sm)};
}
.incident-card {
background: ${colors.background.card};
border-radius: ${unsafeCSS(borderRadius.md)};
margin-bottom: ${unsafeCSS(spacing.lg)};
overflow: hidden;
box-shadow: ${unsafeCSS(shadows.sm)};
border: 1px solid ${colors.border.default};
transition: all 0.2s ease;
}
2025-06-29 23:43:17 +00:00
.incident-card.expanded {
2025-06-29 19:55:58 +00:00
box-shadow: ${unsafeCSS(shadows.md)};
}
.incident-header {
padding: ${unsafeCSS(spacing.lg)} ${unsafeCSS(spacing.lg)};
border-left: 4px solid;
display: flex;
align-items: start;
justify-content: space-between;
gap: ${unsafeCSS(spacing.md)};
2025-06-29 23:43:17 +00:00
cursor: pointer;
transition: background-color 0.2s ease;
}
.incident-header:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.02)', 'rgba(255, 255, 255, 0.02)')};
2025-06-29 19:55:58 +00:00
}
.incident-header.critical {
border-left-color: ${colors.status.major};
}
.incident-header.major {
border-left-color: ${colors.status.partial};
}
.incident-header.minor {
border-left-color: ${colors.status.degraded};
}
.incident-header.maintenance {
border-left-color: ${colors.status.maintenance};
}
.incident-title {
font-size: 18px;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.incident-meta {
display: flex;
gap: ${unsafeCSS(spacing.lg)};
margin-top: ${unsafeCSS(spacing.sm)};
font-size: 13px;
color: ${colors.text.secondary};
flex-wrap: wrap;
}
.incident-status {
display: inline-flex;
align-items: center;
gap: ${unsafeCSS(spacing.xs)};
padding: ${unsafeCSS(spacing.xs)} ${unsafeCSS(spacing.md)};
border-radius: ${unsafeCSS(borderRadius.full)};
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.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')};
}
.incident-body {
padding: 0 ${unsafeCSS(spacing.lg)} ${unsafeCSS(spacing.lg)} ${unsafeCSS(spacing.lg)};
}
.incident-impact {
margin: ${unsafeCSS(spacing.md)} 0;
padding: ${unsafeCSS(spacing.md)};
background: ${colors.background.secondary};
border-radius: ${unsafeCSS(borderRadius.base)};
font-size: 14px;
line-height: 1.6;
}
.affected-services {
margin-top: ${unsafeCSS(spacing.md)};
}
.affected-services-title {
font-size: 13px;
font-weight: 600;
margin-bottom: ${unsafeCSS(spacing.sm)};
color: ${colors.text.primary};
}
.service-tag {
display: inline-block;
padding: ${unsafeCSS(spacing.xs)} ${unsafeCSS(spacing.sm)};
margin: 2px;
background: ${colors.background.muted};
border-radius: ${unsafeCSS(borderRadius.sm)};
font-size: 12px;
color: ${colors.text.secondary};
}
.incident-updates {
margin-top: ${unsafeCSS(spacing.lg)};
border-top: 1px solid ${colors.border.default};
padding-top: ${unsafeCSS(spacing.lg)};
}
.update-item {
position: relative;
padding-left: ${unsafeCSS(spacing.lg)};
margin-bottom: ${unsafeCSS(spacing.md)};
}
.update-item::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 8px;
height: 8px;
border-radius: ${unsafeCSS(borderRadius.full)};
background: ${colors.border.muted};
}
.update-time {
font-size: 12px;
color: ${colors.text.secondary};
margin-bottom: ${unsafeCSS(spacing.xs)};
font-family: ${unsafeCSS(fonts.mono)};
}
.update-message {
font-size: 14px;
line-height: 1.6;
color: ${colors.text.primary};
}
.update-author {
font-size: 12px;
color: ${colors.text.secondary};
margin-top: ${unsafeCSS(spacing.xs)};
font-style: italic;
}
.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: loading 1.5s infinite;
border-radius: ${unsafeCSS(borderRadius.md)};
margin-bottom: ${unsafeCSS(spacing.lg)};
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.show-more {
text-align: center;
margin-top: ${unsafeCSS(spacing.lg)};
}
.show-more-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: ${unsafeCSS(spacing.sm)} ${unsafeCSS(spacing.lg)};
background: transparent;
border: 1px solid ${colors.border.default};
border-radius: ${unsafeCSS(borderRadius.base)};
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
color: ${colors.text.primary};
font-family: ${unsafeCSS(fonts.base)};
}
.show-more-button:hover {
background: ${colors.background.secondary};
border-color: ${colors.border.muted};
transform: translateY(-1px);
}
.show-more-button:active {
transform: translateY(0);
}
2025-06-30 07:54:17 +00:00
.incident-actions {
display: flex;
gap: ${unsafeCSS(spacing.md)};
align-items: center;
margin-top: ${unsafeCSS(spacing.lg)};
padding-top: ${unsafeCSS(spacing.lg)};
border-top: 1px solid ${colors.border.default};
}
.subscribe-button {
display: inline-flex;
align-items: center;
gap: ${unsafeCSS(spacing.xs)};
padding: ${unsafeCSS(spacing.xs)} ${unsafeCSS(spacing.md)};
background: transparent;
border: 1px solid ${colors.border.default};
border-radius: ${unsafeCSS(borderRadius.base)};
cursor: pointer;
font-size: 13px;
font-weight: 400;
transition: all 0.2s ease;
color: ${colors.text.primary};
font-family: ${unsafeCSS(fonts.base)};
}
.subscribe-button:hover {
background: ${colors.background.secondary};
border-color: ${colors.border.muted};
}
.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')};
}
2025-06-29 19:55:58 +00:00
2025-06-29 23:43:17 +00:00
.collapsed-hint {
font-size: 12px;
color: ${colors.text.secondary};
text-align: center;
margin-top: ${unsafeCSS(spacing.md)};
opacity: 0.8;
}
2025-06-29 19:55:58 +00:00
@media (max-width: 640px) {
.container {
padding: 0 ${unsafeCSS(spacing.md)} ${unsafeCSS(spacing.md)} ${unsafeCSS(spacing.md)};
}
.incident-header {
2025-06-29 23:43:17 +00:00
padding: ${unsafeCSS(spacing.md)};
2025-06-29 19:55:58 +00:00
}
.incident-meta {
flex-direction: column;
gap: ${unsafeCSS(spacing.xs)};
}
2021-09-27 00:49:30 +02:00
}
`,
];
2021-09-27 00:45:17 +02:00
public render(): TemplateResult {
return html`
2025-06-29 19:55:58 +00:00
<div class="container">
<uplinternal-miniheading>Current Incidents</uplinternal-miniheading>
${this.loading ? html`
<div class="loading-skeleton"></div>
2025-06-29 23:43:17 +00:00
` : this.currentIncidents.length ? html`
${this.currentIncidents.map(incident => this.renderIncident(incident, true))}
` :
2025-06-29 19:55:58 +00:00
html`<div class="noIncidentBox">No incidents ongoing.</div>`
}
<uplinternal-miniheading>Past Incidents</uplinternal-miniheading>
${this.loading ? html`
<div class="loading-skeleton"></div>
<div class="loading-skeleton"></div>
` : this.pastIncidents.length ?
this.pastIncidents.slice(0, 5).map(incident => this.renderIncident(incident, false)) :
html`<div class="noIncidentBox">No past incidents in the last ${this.daysToShow} days.</div>`
}
${this.pastIncidents.length > 5 && !this.loading ? html`
<div class="show-more">
<button class="show-more-button" @click=${this.handleShowMore}>
Show ${this.pastIncidents.length - 5} more incidents
</button>
</div>
` : ''}
</div>
`;
}
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);
return html`
2025-06-29 23:43:17 +00:00
<div class="incident-card ${this.expandedIncidents.has(incident.id) ? 'expanded' : ''}">
<div class="incident-header ${incident.severity}" @click=${() => this.toggleIncident(incident.id)}>
2025-06-29 19:55:58 +00:00
<div>
<h3 class="incident-title">${incident.title}</h3>
<div class="incident-meta">
<span>Started: ${this.formatDate(incident.startTime)}</span>
<span>Duration: ${duration}</span>
${incident.endTime ? html`
<span>Ended: ${this.formatDate(incident.endTime)}</span>
` : ''}
</div>
2025-06-29 23:43:17 +00:00
${!this.expandedIncidents.has(incident.id) ? html`
<div style="
margin-top: ${unsafeCSS(spacing.sm)};
font-size: 13px;
color: ${colors.text.secondary};
display: flex;
align-items: center;
gap: ${unsafeCSS(spacing.md)};
">
${incident.impact ? html`
<span style="
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 500px;
">${incident.impact}</span>
` : ''}
<span style="
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#71717a')};
">
${incident.updates.length} update${incident.updates.length !== 1 ? 's' : ''}
</span>
</div>
` : ''}
2025-06-29 19:55:58 +00:00
</div>
2025-06-29 23:43:17 +00:00
<div style="display: flex; align-items: center; gap: ${unsafeCSS(spacing.md)};">
<div class="incident-status ${latestUpdate.status}">
${this.getStatusIcon(latestUpdate.status)}
${latestUpdate.status.replace(/_/g, ' ')}
</div>
<div class="expand-icon" style="
font-size: 10px;
color: ${cssManager.bdTheme('#6b7280', '#a1a1aa')};
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
${this.expandedIncidents.has(incident.id) ? 'transform: rotate(180deg);' : ''}
">
</div>
2025-06-29 19:55:58 +00:00
</div>
</div>
2025-06-29 23:43:17 +00:00
${this.expandedIncidents.has(incident.id) ? html`
2025-06-29 19:55:58 +00:00
<div class="incident-body">
<div class="incident-impact">
<strong>Impact:</strong> ${incident.impact}
</div>
${incident.affectedServices.length > 0 ? html`
<div class="affected-services">
<div class="affected-services-title">Affected Services:</div>
${incident.affectedServices.map(service => html`
<span class="service-tag">${service}</span>
`)}
</div>
` : ''}
${incident.updates.length > 0 ? html`
<div class="incident-updates">
<h4 style="font-size: 14px; margin: 0 0 12px 0;">Updates</h4>
${incident.updates.slice(-3).reverse().map(update => this.renderUpdate(update))}
</div>
` : ''}
${incident.rootCause && isCurrent === false ? html`
<div class="incident-impact" style="margin-top: 12px;">
<strong>Root Cause:</strong> ${incident.rootCause}
</div>
` : ''}
${incident.resolution && isCurrent === false ? html`
<div class="incident-impact" style="margin-top: 12px;">
<strong>Resolution:</strong> ${incident.resolution}
</div>
` : ''}
2025-06-30 07:54:17 +00:00
<div class="incident-actions">
<button
class="subscribe-button ${this.isSubscribedToIncident(incident.id) ? 'subscribed' : ''}"
@click=${(e: Event) => {
e.stopPropagation();
this.handleIncidentSubscribe(incident);
}}
>
${this.isSubscribedToIncident(incident.id) ? html`
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.6667 3.5L5.25 9.91667L2.33334 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Subscribed to updates
` : html`
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 5.25V8.75C10.5 9.34674 10.2629 9.91903 9.84099 10.341C9.41903 10.7629 8.84674 11 8.25 11L3.75 11C3.15326 11 2.58097 10.7629 2.15901 10.341C1.73705 9.91903 1.5 9.34674 1.5 8.75V4.25C1.5 3.65326 1.73705 3.08097 2.15901 2.65901C2.58097 2.23705 3.15326 2 3.75 2H7.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 1.5H12.5M12.5 1.5V5M12.5 1.5L6 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Subscribe to updates
`}
</button>
${isCurrent ? html`
<span style="
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#a1a1aa')};
">Get notified when this incident is updated or resolved</span>
` : ''}
</div>
2025-06-29 19:55:58 +00:00
</div>
2025-06-29 23:43:17 +00:00
` : ''}
2021-09-27 00:45:17 +02:00
</div>
`;
}
2025-06-29 19:55:58 +00:00
private renderUpdate(update: any): TemplateResult {
return html`
<div class="update-item">
<div class="update-time">${this.formatDate(update.timestamp)}</div>
<div class="update-message">${update.message}</div>
${update.author ? html`
<div class="update-author"> ${update.author}</div>
` : ''}
</div>
`;
}
2025-06-29 23:43:17 +00:00
private getStatusIcon(status: string): TemplateResult {
return html`<span style="
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
background: ${status === 'resolved' ? colors.status.operational :
status === 'monitoring' ? colors.status.maintenance :
status === 'identified' ? colors.status.degraded :
colors.status.partial};
"></span>`;
2025-06-29 19:55:58 +00:00
}
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`;
}
}
2025-06-29 23:43:17 +00:00
private toggleIncident(incidentId: string) {
const newExpanded = new Set(this.expandedIncidents);
if (newExpanded.has(incidentId)) {
newExpanded.delete(incidentId);
} else {
newExpanded.add(incidentId);
}
this.expandedIncidents = newExpanded;
}
2025-06-29 19:55:58 +00:00
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');
}
2025-06-30 07:54:17 +00:00
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;
}
2021-09-27 00:45:17 +02:00
public dispatchReportNewIncident() {
2025-06-29 19:55:58 +00:00
this.dispatchEvent(new CustomEvent('reportNewIncident', {
bubbles: true,
composed: true
}));
2021-09-27 00:45:17 +02:00
}
public dispatchStatusSubscribe() {
2025-06-29 19:55:58 +00:00
this.dispatchEvent(new CustomEvent('statusSubscribe', {
bubbles: true,
composed: true
}));
2021-09-27 00:45:17 +02:00
}
2021-09-27 00:49:30 +02:00
}