From 059408f098a9307884cc34c7bc4d9d29296b7a7b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 23 Sep 2025 14:40:57 -0700 Subject: [PATCH] add v2 models --- server/src/db/v2/models/auth/Role.ts | 50 +++ server/src/db/v2/models/auth/User.ts | 108 +++++++ server/src/db/v2/models/checks/Check.ts | 297 ++++++++++++++++++ server/src/db/v2/models/index.ts | 33 ++ server/src/db/v2/models/invite/Invite.ts | 43 +++ .../db/v2/models/maintenance/Maintenance.ts | 39 +++ server/src/db/v2/models/monitors/Monitor.ts | 100 ++++++ .../src/db/v2/models/monitors/MonitorStats.ts | 77 +++++ .../NotificationChannel.ts | 50 +++ 9 files changed, 797 insertions(+) create mode 100644 server/src/db/v2/models/auth/Role.ts create mode 100644 server/src/db/v2/models/auth/User.ts create mode 100644 server/src/db/v2/models/checks/Check.ts create mode 100644 server/src/db/v2/models/index.ts create mode 100644 server/src/db/v2/models/invite/Invite.ts create mode 100644 server/src/db/v2/models/maintenance/Maintenance.ts create mode 100644 server/src/db/v2/models/monitors/Monitor.ts create mode 100644 server/src/db/v2/models/monitors/MonitorStats.ts create mode 100644 server/src/db/v2/models/notification-channel/NotificationChannel.ts diff --git a/server/src/db/v2/models/auth/Role.ts b/server/src/db/v2/models/auth/Role.ts new file mode 100644 index 000000000..1031de8b2 --- /dev/null +++ b/server/src/db/v2/models/auth/Role.ts @@ -0,0 +1,50 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IRole extends Document { + _id: Types.ObjectId; + name: string; + description?: string; + permissions: string[]; + isSystem: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +const roleSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + maxlength: 50, + }, + description: { + type: String, + trim: true, + maxlength: 200, + }, + + permissions: [ + { + type: String, + required: true, + }, + ], + isSystem: { + type: Boolean, + default: false, + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { + timestamps: true, + } +); + +roleSchema.index({ name: 1 }, { unique: true }); + +export const Role = mongoose.model("Role_v2", roleSchema); diff --git a/server/src/db/v2/models/auth/User.ts b/server/src/db/v2/models/auth/User.ts new file mode 100644 index 000000000..b72a29e70 --- /dev/null +++ b/server/src/db/v2/models/auth/User.ts @@ -0,0 +1,108 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface ITokenizedUser { + sub: string; + roles: string[]; +} +export interface IUser extends Document { + _id: Types.ObjectId; + email: string; + firstName: string; + lastName: string; + passwordHash: string; + roles: Types.ObjectId[]; + profile: { + avatar?: string; + bio?: string; + timezone: string; + locale: string; + }; + preferences: { + theme: "light" | "dark" | "system"; + }; + lastLoginAt?: Date; + isActive: boolean; + isVerified: boolean; + createdAt: Date; + updatedAt: Date; +} + +const userSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + firstName: { + type: String, + required: true, + trim: true, + maxlength: 50, + }, + lastName: { + type: String, + required: true, + trim: true, + maxlength: 50, + }, + passwordHash: { + type: String, + required: true, + }, + roles: [ + { + type: Schema.Types.ObjectId, + ref: "Role_v2", + }, + ], + profile: { + avatar: { + type: String, + }, + bio: { + type: String, + maxlength: 200, + }, + timezone: { + type: String, + default: "UTC", + }, + locale: { + type: String, + default: "en", + }, + }, + preferences: { + theme: { + type: String, + enum: ["light", "dark", "system"], + default: "system", + }, + }, + lastLoginAt: { + type: Date, + }, + isActive: { + type: Boolean, + default: true, + }, + isVerified: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, + toJSON: { + transform: function (_doc, ret) { + delete ret.passwordHash; + return ret; + }, + }, + } +); + +export const User = mongoose.model("User_v2", userSchema); diff --git a/server/src/db/v2/models/checks/Check.ts b/server/src/db/v2/models/checks/Check.ts new file mode 100644 index 000000000..83bbc9f91 --- /dev/null +++ b/server/src/db/v2/models/checks/Check.ts @@ -0,0 +1,297 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +import { MonitorType, MonitorTypes, MonitorStatus, MonitorStatuses } from "../monitors/Monitor.js"; +import type { Response } from "got"; +export type GotTimings = Response["timings"]; +export interface ITimingPhases { + wait: number; + dns: number; + tcp: number; + tls: number; + request: number; + firstByte: number; + download: number; + total: number; +} + +export interface ICpuInfo { + physical_core: number; + logical_core: number; + frequency: number; + current_frequency: number; + temperature: number[]; // per-core temps + free_percent: number; + usage_percent: number; +} + +export interface IMemoryInfo { + total_bytes: number; + available_bytes: number; + used_bytes: number; + usage_percent: number; +} + +export interface IHostInfo { + os?: string; + platform?: string; + kernel_version?: string; + pretty_name?: string; +} + +export interface IDiskInfo { + device: string; + total_bytes: number; + free_bytes: number; + used_bytes: number; + usage_percent: number; + total_inodes?: number; + free_inodes?: number; + used_inodes?: number; + inodes_usage_percent?: number; + read_bytes?: number; + write_bytes?: number; + read_time?: number; + write_time?: number; +} + +export interface INetInfo { + name: string; + bytes_sent: number; + bytes_recv: number; + packets_sent: number; + packets_recv: number; + err_in: number; + err_out: number; + drop_in: number; + drop_out: number; + fifo_in: number; + fifo_out: number; +} + +export interface ICaptureInfo { + version?: string; + mode?: string; +} + +export interface ISystemInfo { + cpu: ICpuInfo; + memory: IMemoryInfo; + disk: IDiskInfo[]; + host: IHostInfo; + net: INetInfo[]; +} + +export interface ILighthouseAudit { + id?: string; + title?: string; + score?: number | null; + displayValue?: string; + numericValue?: number; + numericUnit?: string; +} +export interface ILighthouseCategories { + accessibility?: { score?: number | null }; + "best-practices"?: { score?: number | null }; + seo?: { score?: number | null }; + performance?: { score?: number | null }; +} + +export interface ILighthouseResult { + categories?: ILighthouseCategories; + audits?: Record; +} + +export interface ICheckLighthouseFields { + accessibility: number; + bestPractices: number; + seo: number; + performance: number; + audits: { + cls: ILighthouseAudit; + si: ILighthouseAudit; + fcp: ILighthouseAudit; + lcp: ILighthouseAudit; + tbt: ILighthouseAudit; + }; +} + +export interface ICheck extends Document { + _id: Types.ObjectId; + monitorId: Types.ObjectId; + type: MonitorType; + status: MonitorStatus; + message: string; + responseTime?: number; // in ms + timings?: GotTimings; + httpStatusCode?: number; + errorMessage?: string; + ack: boolean; + ackAt?: Date; + ackBy?: Types.ObjectId; + expiry: Date; + createdAt: Date; + updatedAt: Date; + system?: ISystemInfo; + capture?: ICaptureInfo; + lighthouse?: ICheckLighthouseFields; +} + +const CheckSchema = new Schema( + { + monitorId: { type: Schema.Types.ObjectId, ref: "Monitor_v2", required: true }, + + type: { + type: String, + required: true, + enum: MonitorTypes, + }, + status: { + type: String, + required: true, + enum: MonitorStatuses, + }, + message: { type: String, trim: true }, + responseTime: { type: Number }, + timings: { + start: { type: Date }, + socket: { type: Date }, + lookup: { type: Date }, + connect: { type: Date }, + secureConnect: { type: Date }, + response: { type: Date }, + end: { type: Date }, + phases: { + wait: { type: Number }, + dns: { type: Number }, + tcp: { type: Number }, + tls: { type: Number }, + request: { type: Number }, + firstByte: { type: Number }, + download: { type: Number }, + total: { type: Number }, + }, + }, + system: { + type: { + cpu: { + physical_core: { type: Number }, + logical_core: { type: Number }, + frequency: { type: Number }, + current_frequency: { type: Number }, + temperature: [{ type: Number }], + free_percent: { type: Number }, + usage_percent: { type: Number }, + }, + memory: { + total_bytes: { type: Number }, + available_bytes: { type: Number }, + used_bytes: { type: Number }, + usage_percent: { type: Number }, + }, + disk: [ + { + device: { type: String }, + total_bytes: { type: Number }, + free_bytes: { type: Number }, + used_bytes: { type: Number }, + usage_percent: { type: Number }, + total_inodes: { type: Number }, + free_inodes: { type: Number }, + used_inodes: { type: Number }, + inodes_usage_percent: { type: Number }, + read_bytes: { type: Number }, + write_bytes: { type: Number }, + read_time: { type: Number }, + write_time: { type: Number }, + }, + ], + host: { + os: { type: String }, + platform: { type: String }, + kernel_version: { type: String }, + pretty_name: { type: String }, + }, + net: [ + { + name: { type: String }, + bytes_sent: { type: Number }, + bytes_recv: { type: Number }, + packets_sent: { type: Number }, + packets_recv: { type: Number }, + err_in: { type: Number }, + err_out: { type: Number }, + drop_in: { type: Number }, + drop_out: { type: Number }, + fifo_in: { type: Number }, + fifo_out: { type: Number }, + }, + ], + }, + required: false, + }, + + capture: { + type: { + version: { type: String }, + mode: { type: String }, + }, + required: false, + }, + lighthouse: { + accessibility: { type: Number, required: false }, + bestPractices: { type: Number, required: false }, + seo: { type: Number, required: false }, + performance: { type: Number, required: false }, + audits: { + cls: { + type: Object, + }, + si: { + type: Object, + }, + fcp: { + type: Object, + }, + lcp: { + type: Object, + }, + tbt: { + type: Object, + }, + }, + type: { + accessibility: { type: Number }, + bestPractices: { type: Number }, + seo: { type: Number }, + performance: { type: Number }, + audits: { + cls: { type: Object }, + si: { type: Object }, + fcp: { type: Object }, + lcp: { type: Object }, + tbt: { type: Object }, + }, + }, + required: false, + }, + + httpStatusCode: { type: Number }, + errorMessage: { type: String, trim: true }, + ack: { type: Boolean, required: true, default: false }, + ackAt: { type: Date }, + ackBy: { type: Schema.Types.ObjectId, ref: "User_v2" }, + expiry: { + type: Date, + default: Date.now, + expires: 60 * 60 * 24 * 30, + }, + }, + { timestamps: true } +); + +CheckSchema.index({ monitorId: 1, createdAt: -1 }); +CheckSchema.index({ status: 1 }); +CheckSchema.index({ status: 1, ack: 1 }); +CheckSchema.index({ ack: 1, ackAt: 1 }); +CheckSchema.index({ createdAt: -1 }); + +export const Check = mongoose.model("Check_v2", CheckSchema); diff --git a/server/src/db/v2/models/index.ts b/server/src/db/v2/models/index.ts new file mode 100644 index 000000000..fbc05c10f --- /dev/null +++ b/server/src/db/v2/models/index.ts @@ -0,0 +1,33 @@ +export { User } from "./auth/User.js"; +export type { IUser } from "./auth/User.js"; +export type { ITokenizedUser } from "./auth/User.js"; +export { Role } from "./auth/Role.js"; +export type { IRole } from "./auth/Role.js"; +export { connectDatabase, disconnectDatabase } from "../index.js"; +export { Monitor } from "./monitors/Monitor.js"; +export { MonitorStatuses } from "./monitors/Monitor.js"; +export type { IMonitor } from "./monitors/Monitor.js"; +export { Check } from "./checks/Check.js"; +export type { + ICheck, + ISystemInfo, + ICaptureInfo, + INetInfo, + IDiskInfo, + IHostInfo, + IMemoryInfo, + ICpuInfo, + ILighthouseAudit, + ITimingPhases, + ILighthouseCategories, + ILighthouseResult, + ICheckLighthouseFields, +} from "./checks/Check.js"; +export type { IMonitorStats } from "./monitors/MonitorStats.js"; +export { MonitorStats } from "./monitors/MonitorStats.js"; +export type { INotificationChannel } from "./notification-channel/NotificationChannel.js"; +export { NotificationChannel } from "./notification-channel/NotificationChannel.js"; +export type { IMaintenance } from "./maintenance/Maintenance.js"; +export { Maintenance } from "./maintenance/Maintenance.js"; +export type { IInvite } from "./invite/Invite.js"; +export { Invite } from "./invite/Invite.js"; diff --git a/server/src/db/v2/models/invite/Invite.ts b/server/src/db/v2/models/invite/Invite.ts new file mode 100644 index 000000000..2bc569900 --- /dev/null +++ b/server/src/db/v2/models/invite/Invite.ts @@ -0,0 +1,43 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IInvite extends Document { + _id: Types.ObjectId; + email: string; + tokenHash: string; + roles: Types.ObjectId[]; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; + expiry: Date; + createdAt: Date; + updatedAt: Date; +} + +const InviteSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + roles: [ + { + type: Schema.Types.ObjectId, + ref: "Role_v2", + required: true, + }, + ], + tokenHash: { type: String, required: true, unique: true }, + expiry: { + type: Date, + default: Date.now, + expires: 60 * 60 * 24, + }, + createdBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + }, + { timestamps: true } +); + +export const Invite = mongoose.model("Invite_v2", InviteSchema); diff --git a/server/src/db/v2/models/maintenance/Maintenance.ts b/server/src/db/v2/models/maintenance/Maintenance.ts new file mode 100644 index 000000000..c2ce33a2b --- /dev/null +++ b/server/src/db/v2/models/maintenance/Maintenance.ts @@ -0,0 +1,39 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; + +export interface IMaintenance extends Document { + _id: Types.ObjectId; + name: string; + isActive: boolean; + monitors: Types.ObjectId[]; + startTime: Date; + endTime: Date; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const MaintenanceSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + isActive: { type: Boolean, required: true, default: true }, + monitors: [ + { + type: Schema.Types.ObjectId, + ref: "Monitor_v2", + required: true, + }, + ], + startTime: { type: Date, required: true }, + endTime: { type: Date, required: true }, + createdBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + }, + { timestamps: true } +); + +MaintenanceSchema.index({ isActive: 1 }); +MaintenanceSchema.index({ startTime: 1 }); +MaintenanceSchema.index({ endTime: 1 }); + +export const Maintenance = mongoose.model("Maintenance_v2", MaintenanceSchema); diff --git a/server/src/db/v2/models/monitors/Monitor.ts b/server/src/db/v2/models/monitors/Monitor.ts new file mode 100644 index 000000000..055d36a1b --- /dev/null +++ b/server/src/db/v2/models/monitors/Monitor.ts @@ -0,0 +1,100 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +import { Check, MonitorStats } from "../index.js"; + +export const MonitorTypes = ["http", "https", "ping", "infrastructure", "pagespeed"] as const; +export type MonitorType = (typeof MonitorTypes)[number]; + +export const MonitorStatuses = ["up", "down", "paused", "initializing"] as const; +export type MonitorStatus = (typeof MonitorStatuses)[number]; +export interface IMonitor extends Document { + _id: Types.ObjectId; + name: string; + url: string; + secret?: string; + type: MonitorType; + interval: number; // in ms + isActive: boolean; + status: MonitorStatus; + n: number; // Number of consecutive successes required to change status + lastCheckedAt?: Date; + latestChecks: { + status: MonitorStatus; + responseTime: number; + checkedAt: Date; + }[]; + notificationChannels?: Types.ObjectId[]; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const MonitorSchema = new Schema( + { + name: { type: String, required: true, trim: true, maxlength: 100 }, + url: { type: String, required: true, trim: true }, + secret: { type: String, required: false }, + type: { + type: String, + required: true, + enum: MonitorTypes, + }, + interval: { type: Number, required: true, default: 60000 }, + isActive: { type: Boolean, required: true, default: true }, + status: { + type: String, + required: true, + default: "initializing", + enum: MonitorStatuses, + }, + n: { type: Number, required: true, default: 1 }, + lastCheckedAt: { type: Date }, + latestChecks: { + type: [ + { + status: { + type: String, + required: true, + enum: MonitorStatuses, + }, + responseTime: { type: Number, required: true }, + checkedAt: { type: Date, required: true }, + }, + ], + default: [], + }, + notificationChannels: [ + { + type: Schema.Types.ObjectId, + ref: "NotificationChannel_v2", + required: false, + default: [], + }, + ], + + createdBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + }, + { timestamps: true } +); + +MonitorSchema.pre("deleteOne", { document: true, query: false }, async function (next) { + try { + const monitorId = this._id; + await Check.deleteMany({ monitorId }); + await MonitorStats.deleteMany({ monitorId }); + next(); + } catch (error: any) { + next(error); + } +}); + +MonitorSchema.index({ isActive: 1 }); +MonitorSchema.index({ status: 1 }); +MonitorSchema.index({ type: 1 }); +MonitorSchema.index({ lastCheckedAt: 1 }); +MonitorSchema.index({ isActive: 1, status: 1 }); +MonitorSchema.index({ createdBy: 1 }); +MonitorSchema.index({ updatedBy: 1 }); + +export const Monitor = mongoose.model("Monitor_v2", MonitorSchema); diff --git a/server/src/db/v2/models/monitors/MonitorStats.ts b/server/src/db/v2/models/monitors/MonitorStats.ts new file mode 100644 index 000000000..56e76f9ab --- /dev/null +++ b/server/src/db/v2/models/monitors/MonitorStats.ts @@ -0,0 +1,77 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +import { MonitorStatus, MonitorStatuses } from "./Monitor.js"; + +export interface IMonitorStats extends mongoose.Document { + monitorId: mongoose.Types.ObjectId; + avgResponseTime: number; + maxResponseTime: number; + totalChecks: number; + totalUpChecks: number; + totalDownChecks: number; + uptimePercentage: number; + lastCheckTimestamp: number; + lastResponseTime: number; + timeOfLastFailure: number; + currentStreak: number; + currentStreakStatus: MonitorStatus; + currentStreakStartedAt: number; + createdAt: Date; + updatedAt: Date; +} + +const MonitorStatsSchema = new Schema( + { + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor_v2", + immutable: true, + index: true, + }, + avgResponseTime: { + type: Number, + default: 0, + }, + maxResponseTime: { + type: Number, + default: 0, + }, + lastResponseTime: { + type: Number, + default: 0, + }, + totalChecks: { + type: Number, + default: 0, + }, + totalUpChecks: { + type: Number, + default: 0, + }, + totalDownChecks: { + type: Number, + default: 0, + }, + uptimePercentage: { + type: Number, + default: 0, + }, + lastCheckTimestamp: { + type: Number, + default: 0, + }, + timeOfLastFailure: { + type: Number, + default: 0, + }, + currentStreak: { type: Number, required: false, default: 0 }, + currentStreakStatus: { + type: String, + required: false, + enum: MonitorStatuses, + }, + currentStreakStartedAt: { type: Number, required: false }, + }, + { timestamps: true } +); + +export const MonitorStats = mongoose.model("MonitorStats_v2", MonitorStatsSchema); diff --git a/server/src/db/v2/models/notification-channel/NotificationChannel.ts b/server/src/db/v2/models/notification-channel/NotificationChannel.ts new file mode 100644 index 000000000..e77639608 --- /dev/null +++ b/server/src/db/v2/models/notification-channel/NotificationChannel.ts @@ -0,0 +1,50 @@ +import mongoose, { Schema, Document, Types } from "mongoose"; +export const ChannelTypes = ["email", "slack", "discord", "webhook"] as const; +export type ChannelType = (typeof ChannelTypes)[number]; + +export interface INotificationChannelConfig { + url?: string; // For webhook, slack, discord + emailAddress?: string; // For email +} + +export interface INotificationChannel { + _id: Types.ObjectId; + name: string; + type: ChannelType; + config: INotificationChannelConfig; + isActive: boolean; + createdBy: Types.ObjectId; + updatedBy: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const NotificationChannelConfigSchema = new Schema( + { + url: { type: String, required: false }, + emailAddress: { type: String, required: false }, + }, + { _id: false, strict: "throw" } +); + +const NotificationChannelSchema = new Schema( + { + name: { type: String, required: true, trim: true }, + type: { + type: String, + required: true, + enum: ChannelTypes, + }, + config: { type: NotificationChannelConfigSchema, required: true }, + isActive: { type: Boolean, required: true, default: true }, + createdBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + updatedBy: { type: Schema.Types.ObjectId, ref: "User_v2", required: true }, + }, + { timestamps: true } +); + +NotificationChannelSchema.index({ isActive: 1 }); +NotificationChannelSchema.index({ type: 1 }); +NotificationChannelSchema.index({ type: 1, isActive: 1 }); + +export const NotificationChannel = mongoose.model("NotificationChannel_v2", NotificationChannelSchema);