diff --git a/App/FeatureSet/Workers/DataMigrations/AddEndDateToMonitorStatusTimeline.ts b/App/FeatureSet/Workers/DataMigrations/AddEndDateToMonitorStatusTimeline.ts new file mode 100644 index 0000000000..5990e98f4a --- /dev/null +++ b/App/FeatureSet/Workers/DataMigrations/AddEndDateToMonitorStatusTimeline.ts @@ -0,0 +1,105 @@ +import DataMigrationBase from './DataMigrationBase'; +import LIMIT_MAX from 'Common/Types/Database/LimitMax'; +import Project from 'Model/Models/Project'; +import ProjectService from 'CommonServer/Services/ProjectService'; +import Monitor from 'Model/Models/Monitor'; +import MonitorService from 'CommonServer/Services/MonitorService'; +import MonitorStatusTimeline from 'Model/Models/MonitorStatusTimeline'; +import MonitorStatusTimelineService from 'CommonServer/Services/MonitorStatusTimelineService'; +import SortOrder from 'Common/Types/BaseDatabase/SortOrder'; + +export default class AddEndDateToMonitorStatusTimeline extends DataMigrationBase { + public constructor() { + super('AddEndDateToMonitorStatusTimeline'); + } + + public override async migrate(): Promise { + // get all the users with email isVerified true. + + const projects: Array = await ProjectService.findBy({ + query: {}, + select: { + _id: true, + }, + skip: 0, + limit: LIMIT_MAX, + props: { + isRoot: true, + }, + }); + + for (const project of projects) { + // add ended scheduled maintenance state for each of these projects. + // first fetch resolved state. Ended state order is -1 of resolved state. + + const monitors: Array = await MonitorService.findBy({ + query: { + projectId: project.id!, + }, + select: { + _id: true, + }, + skip: 0, + limit: LIMIT_MAX, + props: { + isRoot: true, + }, + }); + + for (const monitor of monitors) { + const statusTimelines: Array = + await MonitorStatusTimelineService.findBy({ + query: { + monitorId: monitor.id!, + }, + select: { + _id: true, + createdAt: true, + }, + skip: 0, + limit: LIMIT_MAX, + props: { + isRoot: true, + }, + sort: { + createdAt: SortOrder.Ascending, + }, + }); + + for (let i: number = 0; i < statusTimelines.length; i++) { + const statusTimeline: MonitorStatusTimeline | undefined = + statusTimelines[i]; + + if (!statusTimeline) { + continue; + } + + let endDate: Date | null = null; + + if ( + statusTimelines[i + 1] && + statusTimelines[i + 1]?.createdAt + ) { + endDate = statusTimelines[i + 1]!.createdAt!; + } + + if (endDate) { + await MonitorStatusTimelineService.updateOneById({ + id: statusTimeline!.id!, + data: { + endsAt: endDate, + }, + props: { + isRoot: true, + }, + }); + } + } + } + } + } + + public override async rollback(): Promise { + return; + } +} diff --git a/App/FeatureSet/Workers/DataMigrations/Index.ts b/App/FeatureSet/Workers/DataMigrations/Index.ts index d39b2aafba..0dc37692d8 100644 --- a/App/FeatureSet/Workers/DataMigrations/Index.ts +++ b/App/FeatureSet/Workers/DataMigrations/Index.ts @@ -1,5 +1,6 @@ import AddDefaultGlobalConfig from './AddDefaultGlobalConfig'; import AddDowntimeMonitorStatusToStatusPage from './AddDowntimeMonitorStatusToStatusPage'; +import AddEndDateToMonitorStatusTimeline from './AddEndDateToMonitorStatusTimeline'; import AddEndedState from './AddEndedState'; import AddMonitoringDatesToMonitor from './AddMonitoringDatesToMonitors'; import AddOwnerInfoToProjects from './AddOwnerInfoToProject'; @@ -27,6 +28,7 @@ const DataMigrations: Array = [ new AddPostedAtToPublicNotes(), new MoveEnableSubscribersToEnableEmailSubscribersOnStatusPage(), new AddDowntimeMonitorStatusToStatusPage(), + new AddEndDateToMonitorStatusTimeline(), ]; export default DataMigrations; diff --git a/Common/Types/Date.ts b/Common/Types/Date.ts index f17e2a2ef0..58d3f76d8a 100644 --- a/Common/Types/Date.ts +++ b/Common/Types/Date.ts @@ -79,6 +79,14 @@ export default class OneUptimeDate { return moment(date).fromNow(); } + public static differenceBetweenTwoDatesAsFromattedString( + date1: Date, + date2: Date + ): string { + const seconds: number = this.getSecondsBetweenTwoDates(date1, date2); + return this.secondsToFormattedFriendlyTimeString(seconds); + } + public static toTimeString(date: Date | string): string { if (typeof date === 'string') { date = this.fromString(date); @@ -497,34 +505,104 @@ export default class OneUptimeDate { public static secondsToFormattedFriendlyTimeString( seconds: number ): string { + const startDate: moment.Moment = moment.utc(0); const date: moment.Moment = moment.utc(seconds * 1000); - const hours: string = date.format('HH'); - const mins: string = date.format('mm'); - const secs: string = date.format('ss'); - let text: string = ''; - let hasHours: boolean = false; - let hasMins: boolean = false; - if (hours !== '00') { - hasHours = true; - text += hours + ' hours '; - } + // get the difference between the two dates as friendly formatted string - if (mins !== '00' || hasHours) { - hasMins = true; + let formattedString: string = ''; - if (hasHours) { - text += ', '; + // years between two dates + const years: number = date.diff(startDate, 'years'); + + if (years > 0) { + let text: string = 'years '; + if (years === 1) { + text = 'year '; } - text += mins + ' minutes '; + // add years to start date + startDate.add(years, 'years'); + + formattedString += years + ' ' + text; } - if (!(hasHours && hasMins)) { - text += secs + ' seconds. '; + const months: number = date.diff(startDate, 'months'); + + if (months > 0) { + let text: string = 'months '; + + if (months === 1) { + text = 'month '; + } + + // add months to start date + startDate.add(months, 'months'); + + formattedString += months + ' ' + text; } - return text; + const days: number = date.diff(startDate, 'days'); + + if (days > 0) { + let text: string = 'days '; + + if (days === 1) { + text = 'day '; + } + + // add days to start date + startDate.add(days, 'days'); + + formattedString += days + ' ' + text; + } + + const hours: number = date.diff(startDate, 'hours'); + + if (hours > 0) { + let text: string = 'hours '; + + if (hours === 1) { + text = 'hour '; + } + + // add hours to start date + startDate.add(hours, 'hours'); + + formattedString += hours + ' ' + text; + } + + const minutes: number = date.diff(startDate, 'minutes'); + + if (minutes > 0) { + let text: string = 'mins '; + + if (minutes === 1) { + text = 'min '; + } + + // add minutes to start date + startDate.add(minutes, 'minutes'); + + formattedString += minutes + ' ' + text; + } + + const secondsLeft: number = date.diff(startDate, 'seconds'); + + if (secondsLeft > 0) { + let text: string = 'secs '; + + if (secondsLeft === 1) { + text = 'sec '; + } + + // add seconds to start date + startDate.add(secondsLeft, 'seconds'); + + formattedString += secondsLeft + ' ' + text; + } + + return formattedString.trim(); } public static getGreaterDate(a: Date, b: Date): Date { @@ -810,7 +888,7 @@ export default class OneUptimeDate { momentDate.format(formatstring) + ' ' + (onlyShowDate ? '' : this.getCurrentTimezoneString()) - ); + ).trim(); } public static getDayInSeconds(days?: number | undefined): number { diff --git a/CommonServer/API/StatusPageAPI.ts b/CommonServer/API/StatusPageAPI.ts index e1ff99d62f..6bc8c3b084 100644 --- a/CommonServer/API/StatusPageAPI.ts +++ b/CommonServer/API/StatusPageAPI.ts @@ -736,7 +736,7 @@ export default class StatusPageAPI extends BaseAPI< monitorId: QueryHelper.in( monitorsOnStatusPageForTimeline ), - createdAt: QueryHelper.inBetween( + endsAt: QueryHelper.inBetweenOrNull( startDate, endDate ), @@ -744,6 +744,7 @@ export default class StatusPageAPI extends BaseAPI< select: { monitorId: true, createdAt: true, + endsAt: true, monitorStatus: { name: true, color: true, diff --git a/CommonServer/Services/MonitorStatusTimelineService.ts b/CommonServer/Services/MonitorStatusTimelineService.ts index 22302508ba..d30d387455 100644 --- a/CommonServer/Services/MonitorStatusTimelineService.ts +++ b/CommonServer/Services/MonitorStatusTimelineService.ts @@ -12,6 +12,7 @@ import PositiveNumber from 'Common/Types/PositiveNumber'; import CreateBy from '../Types/Database/CreateBy'; import UserService from './UserService'; import User from 'Model/Models/User'; +import OneUptimeDate from 'Common/Types/Date'; export class Service extends DatabaseService { public constructor(postgresDatabase?: PostgresDatabase) { @@ -22,6 +23,10 @@ export class Service extends DatabaseService { protected override async onBeforeCreate( createBy: CreateBy ): Promise> { + if (!createBy.data.monitorId) { + throw new BadDataException('monitorId is null'); + } + if ( (createBy.data.createdByUserId || createBy.data.createdByUser || @@ -57,7 +62,29 @@ export class Service extends DatabaseService { } } - return { createBy, carryForward: null }; + const lastMonitorStatusTimeline: MonitorStatusTimeline | null = + await this.findOneBy({ + query: { + monitorId: createBy.data.monitorId, + }, + sort: { + createdAt: SortOrder.Descending, + }, + props: { + isRoot: true, + }, + select: { + _id: true, + }, + }); + + return { + createBy, + carryForward: { + lastMonitorStatusTimelineId: + lastMonitorStatusTimeline?.id || null, + }, + }; } protected override async onCreateSuccess( @@ -72,6 +99,21 @@ export class Service extends DatabaseService { throw new BadDataException('monitorStatusId is null'); } + // update the last status as ended. + + if (onCreate.carryForward.lastMonitorStatusTimelineId) { + await this.updateOneById({ + id: onCreate.carryForward.lastMonitorStatusTimelineId!, + data: { + endsAt: + createdItem.createdAt || OneUptimeDate.getCurrentDate(), + }, + props: { + isRoot: true, + }, + }); + } + await MonitorService.updateOneBy({ query: { _id: createdItem.monitorId?.toString(), diff --git a/CommonServer/Types/Database/QueryHelper.ts b/CommonServer/Types/Database/QueryHelper.ts index fb8dcd9f13..e9ae7fe757 100644 --- a/CommonServer/Types/Database/QueryHelper.ts +++ b/CommonServer/Types/Database/QueryHelper.ts @@ -241,6 +241,23 @@ export default class QueryHelper { ); } + public static inBetweenOrNull( + startValue: number | Date, + endValue: number | Date + ): FindOperator { + const rid1: string = Text.generateRandomText(10); + const rid2: string = Text.generateRandomText(10); + return Raw( + (alias: string) => { + return `((${alias} >= :${rid1} and ${alias} <= :${rid2})) or (${alias} IS NULL)`; + }, + { + [rid1]: startValue, + [rid2]: endValue, + } + ); + } + public static queryJson(value: JSONObject): FindOperator { // seed random text const values: JSONObject = {}; diff --git a/CommonUI/src/Components/Graphs/DayUptimeGraph.tsx b/CommonUI/src/Components/Graphs/DayUptimeGraph.tsx index dc8f96102a..38ec1e9401 100644 --- a/CommonUI/src/Components/Graphs/DayUptimeGraph.tsx +++ b/CommonUI/src/Components/Graphs/DayUptimeGraph.tsx @@ -164,16 +164,23 @@ const DayUptimeGraph: FunctionComponent = ( for (const key in secondsOfEvent) { hasEvents = true; - toolTipText += `, ${ - eventLabels[key] - } for ${OneUptimeDate.secondsToFormattedFriendlyTimeString( - secondsOfEvent[key] || 0 - )}`; - - // TODO: Add rules here. const eventStatusId: string = key; + // if this is downtime state then, include tooltip. + + if ( + (props.downtimeEventStatusIds?.filter((id: ObjectID) => { + return id.toString() === eventStatusId.toString(); + }).length || 0) > 0 + ) { + toolTipText += `, ${ + eventLabels[key] + } for ${OneUptimeDate.secondsToFormattedFriendlyTimeString( + secondsOfEvent[key] || 0 + )}`; + } + const isDowntimeEvent: boolean = Boolean( props.downtimeEventStatusIds?.find((id: ObjectID) => { return id.toString() === eventStatusId; diff --git a/Dashboard/src/Pages/Monitor/View/StatusTimeline.tsx b/Dashboard/src/Pages/Monitor/View/StatusTimeline.tsx index 4ff4d339f7..38f29073dd 100644 --- a/Dashboard/src/Pages/Monitor/View/StatusTimeline.tsx +++ b/Dashboard/src/Pages/Monitor/View/StatusTimeline.tsx @@ -22,6 +22,7 @@ import DisabledWarning from '../../../Components/Monitor/DisabledWarning'; import { ButtonStyleType } from 'CommonUI/src/Components/Button/Button'; import Modal, { ModalWidth } from 'CommonUI/src/Components/Modal/Modal'; import ConfirmModal from 'CommonUI/src/Components/Modal/ConfirmModal'; +import OneUptimeDate from 'Common/Types/Date'; const StatusTimeline: FunctionComponent = ( props: PageComponentProps @@ -170,9 +171,35 @@ const StatusTimeline: FunctionComponent = ( field: { createdAt: true, }, - title: 'Reported At', + title: 'Starts At', type: FieldType.DateTime, }, + { + field: { + endsAt: true, + }, + title: 'Ends At', + type: FieldType.DateTime, + noValueMessage: 'Currently Active', + }, + { + field: { + endsAt: true, + }, + title: 'Duration', + type: FieldType.Text, + getElement: (item: JSONObject): ReactElement => { + return ( +

+ {OneUptimeDate.differenceBetweenTwoDatesAsFromattedString( + item['createdAt'] as Date, + (item['endsAt'] as Date) || + OneUptimeDate.getCurrentDate() + )} +

+ ); + }, + }, ]} /> {showViewLogsModal ? ( diff --git a/Model/Models/MonitorStatusTimeline.ts b/Model/Models/MonitorStatusTimeline.ts index de24f69488..019e619ebf 100644 --- a/Model/Models/MonitorStatusTimeline.ts +++ b/Model/Models/MonitorStatusTimeline.ts @@ -472,4 +472,28 @@ export default class MonitorStatusTimeline extends BaseModel { nullable: true, }) public rootCause?: string = undefined; + + @Index() + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CanCreateMonitorStatusTimeline, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CanReadMonitorStatusTimeline, + ], + update: [], + }) + @TableColumn({ type: TableColumnType.Date }) + @Column({ + type: ColumnType.Date, + nullable: true, + unique: false, + }) + public endsAt?: Date = undefined; }