import { UISref } from '@uirouter/react'; import SearchApi from 'js-worker-search'; import { groupBy } from 'lodash'; import { Debounce } from 'lodash-decorators'; import { DateTime } from 'luxon'; import type { ReactNode } from 'react'; import React from 'react'; import type { RowMouseEventHandlerParams, TableCellProps, TableHeaderProps } from 'react-virtualized'; import { AutoSizer, CellMeasurer, CellMeasurerCache, Column, SortDirection, Table } from 'react-virtualized'; import { forkJoin as observableForkJoin, from as observableFrom } from 'rxjs'; import { PageButton } from './PageButton'; import type { IApplicationSummary } from '../application'; import { ApplicationReader } from '../application'; import { SETTINGS } from '../config'; import { Overridable } from '../overrideRegistry'; import type { IOnCall, IPagerDutyService } from './pagerDuty.read.service'; import { PagerDutyReader } from './pagerDuty.read.service'; import { Markdown } from '../presentation'; import { ReactInjector } from '../reactShims'; import { relativeTime } from '../utils/timeFormatters'; import './pager.less'; type SortDirectionType = 'ASC' | 'DESC'; export interface IUserDisplay { level: number; name: string; url: string; } export interface IUserList { [level: number]: IUserDisplay[]; } export interface IOnCallsByService { users?: IUserList; applications: IApplicationSummary[]; last: DateTime; service: IPagerDutyService; searchString: string; } export interface IPagerProps {} export interface IPagerState { accountName: string; app: string; hideNoApps: boolean; notFoundApps: string[]; filterString: string; initialKeys: string[]; selectedKeys: Map; sortBy: string; sortDirection: SortDirectionType; sortedData: IOnCallsByService[]; } const paddingStyle = { paddingTop: '15px', paddingBottom: '15px' }; const ServicePill = (props: { service: IPagerDutyService; changeCallback: (service: IPagerDutyService, value: boolean) => void; }) => { const onClick = () => props.changeCallback(props.service, false); return (
{props.service.name}
); }; const SortIndicator = (props: { direction: SortDirectionType; sorted: boolean }) => { if (props.sorted) { return props.direction === 'ASC' ? : ; } return ; }; @Overridable('pager.banner') class PagerBanner extends React.Component { render(): ReactNode { return null; } } export class Pager extends React.Component { private cache = new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true, }); private allData: IOnCallsByService[] = []; private searchApi = new SearchApi(); constructor(props: IPagerProps) { super(props); const { $stateParams } = ReactInjector; this.state = { accountName: (SETTINGS.pagerDuty && SETTINGS.pagerDuty.accountName) || '', app: $stateParams.app || '', filterString: $stateParams.q || '', hideNoApps: $stateParams.hideNoApps || false, notFoundApps: [], initialKeys: $stateParams.keys || [], sortBy: $stateParams.by || 'service', sortDirection: $stateParams.direction || SortDirection.ASC, sortedData: [], selectedKeys: new Map(), }; } public componentDidMount(): void { // Get the data from all the necessary sources before rendering observableForkJoin( observableFrom(ApplicationReader.listApplications()), PagerDutyReader.listOnCalls(), PagerDutyReader.listServices(), ).subscribe((results: [IApplicationSummary[], { [id: string]: IOnCall[] }, IPagerDutyService[]]) => { const [applications, onCalls, services] = results; const sortedData = this.getOnCallsByService(applications, onCalls, services); Object.assign(this.allData, sortedData); const { app, initialKeys, filterString, hideNoApps, sortBy, sortDirection } = this.state; this.runFilter(app, initialKeys, filterString, sortBy, sortDirection, hideNoApps); }); } private sortByFunction(a: IOnCallsByService, b: IOnCallsByService, sortBy: string): number { if (sortBy === 'service') { return a.service.name.localeCompare(b.service.name); } if (sortBy === 'last') { if (!a.last.isValid) { return 1; } if (!b.last.isValid) { return -1; } return a.last.toMillis() < b.last.toMillis() ? 1 : a.last.toMillis() > b.last.toMillis() ? -1 : 0; } return 0; } public sort = (info: { sortBy: string; sortDirection: SortDirectionType }): void => { const { sortBy, sortDirection } = info; const { sortedData } = this.state; if (sortBy !== this.state.sortBy || sortDirection !== this.state.sortDirection) { ReactInjector.$state.go('.', { by: sortBy, direction: sortDirection }); this.sortList(sortedData, sortBy, sortDirection); this.cache.clearAll(); this.setState({ sortedData, sortBy, sortDirection }); } }; public sortList(sortedData: IOnCallsByService[], sortBy: string, sortDirection: SortDirectionType): void { if (sortBy) { sortedData.sort((a, b) => this.sortByFunction(a, b, sortBy)); if (sortDirection === SortDirection.DESC) { sortedData.reverse(); } } } private findServiceByApplicationName(applicationName: string): IOnCallsByService { return this.allData.find((data) => data.applications.some((application) => application.name === applicationName)); } @Debounce(25) private runFilter( app: string, keys: string[], filterString: string, sortBy: string, sortDirection: SortDirectionType, hideNoApps: boolean, ) { const selectedKeys: Map = new Map(); if (app) { const foundServices: IOnCallsByService[] = []; const notFoundApps: string[] = []; app.split(',').forEach((applicationName) => { const service = this.findServiceByApplicationName(applicationName); if (service) { foundServices.push(service); } else { notFoundApps.push(applicationName); } }); if (foundServices.length > 0) { foundServices.forEach((foundService) => selectedKeys.set(foundService.service.integration_key, foundService.service), ); this.setState({ sortedData: foundServices, selectedKeys, notFoundApps }); return; } if (!filterString) { filterString = app; } app = ''; } if (keys && keys.length > 0) { const selectedServices = this.allData.filter((data) => keys.includes(data.service.integration_key)); selectedServices.forEach((s) => selectedKeys.set(s.service.integration_key, s.service)); this.setState({ selectedKeys }); } ReactInjector.$state.go('.', { app, q: filterString, by: sortBy, direction: sortDirection, hideNoApps: hideNoApps, selectedKeys, }); this.searchApi.search(filterString).then((results: string[]) => { let data = results.map((serviceId) => this.allData.find((service) => service.service.id === serviceId)); if (hideNoApps) { data = data.filter((s) => s.applications.length); } this.sortList(data, sortBy, sortDirection); this.cache.clearAll(); this.setState({ sortedData: data }); }); } public componentDidUpdate(_prevProps: IPagerProps, prevState: IPagerState): void { if (prevState.filterString !== this.state.filterString || prevState.hideNoApps !== this.state.hideNoApps) { this.runFilter( this.state.app, this.state.initialKeys, this.state.filterString, this.state.sortBy, this.state.sortDirection, this.state.hideNoApps, ); } else { this.sort({ sortBy: this.state.sortBy, sortDirection: this.state.sortDirection }); } } private getOnCallsByService( applications: IApplicationSummary[], onCalls: { [id: string]: IOnCall[] }, services: IPagerDutyService[], ): IOnCallsByService[] { const appsByApiKey = groupBy(applications, 'pdApiKey'); return services .filter((a) => a.integration_key) // filter out services without an integration_key .map((service) => { // connect the users attached to the service by way of escalation policy let users: IUserList; const searchTokens: string[] = [service.name]; const levels = onCalls[service.policy]; if (levels) { users = groupBy( levels .map((level) => { return level.user ? { name: level.user.summary, url: level.user.html_url, level: level.escalation_level } : undefined; }) .filter((a) => a), 'level', ); searchTokens.push(...levels.map((level) => (level.user ? level.user.summary : undefined)).filter((n) => n)); } // Get applications associated with the service key const apiKey = service.integration_key; const associatedApplications = appsByApiKey[apiKey] ?? []; searchTokens.push(...associatedApplications.map((app) => `${app.name},${app.aliases || ''}`)); const onCallsByService = { users, applications: associatedApplications, service, last: DateTime.fromISO((service as any).lastIncidentTimestamp), searchString: searchTokens.join(' '), }; this.searchApi.indexDocument(onCallsByService.service.id, onCallsByService.searchString); return onCallsByService; }); } private selectedChanged = (service: IPagerDutyService, value: boolean): void => { const { selectedKeys } = this.state; value ? selectedKeys.set(service.integration_key, service) : selectedKeys.delete(service.integration_key); ReactInjector.$state.go('.', { keys: Array.from(selectedKeys.keys()) }); this.setState({ selectedKeys }); }; private clearAll = (): void => { const { selectedKeys } = this.state; selectedKeys.clear(); this.setState({ selectedKeys }); }; private rowGetter = (data: { index: number }): any => { return this.state.sortedData[data.index]; }; private serviceRenderer = (data: TableCellProps): React.ReactNode => { const service: IPagerDutyService = data.cellData; return ( ); }; private lastIncidentRenderer = (data: TableCellProps): React.ReactNode => { const time: DateTime = data.cellData; return
{time.isValid ? relativeTime(time.toMillis()) : 'Never'}
; }; private applicationRenderer = (data: TableCellProps): React.ReactNode => { const apps: IApplicationSummary[] = data.cellData; const appList = apps.map((app) => { let displayName = app.name; if (app.aliases) { displayName = `${displayName} (${app.aliases.replace(/,/g, ', ')})`; } return (
  • ); }); return (
      {appList}
    ); }; private highlight(text: string): string { const match = this.state.filterString || this.state.app; if (match) { const re = new RegExp(match, 'gi'); return text.replace(re, '$&'); } return text; } private onCallRenderer = (data: TableCellProps): React.ReactNode => { const onCalls: IUserList = data.cellData; return (
    {onCalls ? Object.keys(onCalls).map((level) => { return (
    {level}
    {onCalls[Number(level)] .filter((user) => !user.name.includes('ExcludeFromAudit')) .map((user, index) => ( ))}
    ); }) : 'Nobody'}
    ); }; private pageRenderer = (data: TableCellProps): React.ReactNode => { const service: IPagerDutyService = data.cellData; const checked = this.state.selectedKeys.has(service.integration_key); return (
    ); }; private pageHeaderRenderer = (_data: TableHeaderProps): React.ReactNode => { return ; }; private headerRenderer = (data: TableHeaderProps): React.ReactNode => { const { dataKey, disableSort, label, sortBy, sortDirection } = data; const children = [ {label} , ]; if (!disableSort) { children.push(); } return children; }; private handleFilterChange = (event: React.ChangeEvent): void => { this.setState({ app: '', filterString: event.target.value }); }; private rowClassName = (info: { index: number }): string => { if (info.index === -1) { return 'on-call-header'; } const classNames = ['on-call-row']; const onCallsByService = this.state.sortedData[info.index]; if (this.state.selectedKeys.get(onCallsByService.service.integration_key)) { classNames.push('selected'); } if (onCallsByService.service.status === 'disabled') { classNames.push('disabled'); } return classNames.join(' '); }; private handleHideNoAppsChanged = (event: React.ChangeEvent): void => { const hideNoApps = event.target.checked; this.setState({ hideNoApps }); ReactInjector.$state.go('.', { hideNoApps }); }; private rowClicked = (info: RowMouseEventHandlerParams): void => { // Don't change selection if clicking a link... if ((info.event.target as HTMLElement).closest('A') !== null) { return; } const service: IPagerDutyService = (info.rowData as any).service; if (service.status !== 'disabled') { const flippedValue = !this.state.selectedKeys.get(service.integration_key); this.selectedChanged(service, flippedValue); } }; private closeCallback = (succeeded: boolean): void => { if (succeeded) { this.setState({ selectedKeys: new Map() }); } }; public render() { const { app, filterString, hideNoApps, notFoundApps, selectedKeys, sortBy, sortDirection, sortedData } = this.state; const forceOpen = app && selectedKeys.size === sortedData.length && sortedData.length !== 0 && notFoundApps.length === 0; return (

    Pager

    {notFoundApps.length > 0 && (

    PagerDuty Services were not found for the following applications:{' '} {notFoundApps.map((applicationName, i) => ( <> {applicationName} {i + 1 < notFoundApps.length && ', '} ))}

    )}
    Filter
    {({ height, width }) => (
    )}
    {selectedKeys.size} {selectedKeys.size === 1 ? 'policy' : 'policies'} selected{' '}
    {Array.from(selectedKeys.values()).map((service) => ( ))}
    ); } }