Merge branch 'bluewave-labs:develop' into feat/improved-status-bar-messages
This commit is contained in:
9 files changed
+132
-11
No files matched your search
@@ -25,10 +25,7 @@ export const MonitorStatBoxes = ({
|
||||
}
|
||||
|
||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||
const timeSinceLastFailure =
|
||||
timeOfLastFailure > 0
|
||||
? Date.now() - timeOfLastFailure
|
||||
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
|
||||
const timeSinceLastFailure = timeOfLastFailure > 0 ? Date.now() - timeOfLastFailure : 0;
|
||||
|
||||
// Determine time since last check
|
||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const AddTeamMemberDialog = ({
|
||||
const roleOptions: { value: UserRole; label: string }[] = [
|
||||
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||
{ value: "user", label: t("common.auth.roles.user") },
|
||||
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||
];
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export const InviteTeamMemberDialog = ({
|
||||
const roleOptions: { value: UserRole; label: string }[] = [
|
||||
{ value: "admin", label: t("common.auth.roles.admin") },
|
||||
{ value: "user", label: t("common.auth.roles.user") },
|
||||
{ value: "demo", label: t("common.auth.roles.demo") },
|
||||
];
|
||||
|
||||
const handleGenerateToken = async (data: InviteFormData) => {
|
||||
|
||||
@@ -32,8 +32,8 @@ class AuthRoutes {
|
||||
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
|
||||
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
|
||||
|
||||
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
|
||||
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
|
||||
this.router.patch("/user", verifyJWT, upload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
|
||||
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
|
||||
@@ -200,8 +200,8 @@ export class NotificationsService implements INotificationsService {
|
||||
};
|
||||
|
||||
deleteById = async (id: string, teamId: string): Promise<Notification> => {
|
||||
const deleted = await this.notificationsRepository.deleteById(id, teamId);
|
||||
await this.monitorsRepository.removeNotificationFromMonitors(id);
|
||||
const deleted = await this.notificationsRepository.deleteById(id, teamId);
|
||||
return deleted;
|
||||
};
|
||||
}
|
||||
@@ -257,12 +257,18 @@ export class StatusService implements IStatusService {
|
||||
};
|
||||
}
|
||||
|
||||
// With a full window, a single raw check must not change monitor.status; only the sliding-window threshold can trigger a transition.
|
||||
let newStatus = monitor.status;
|
||||
// With a full window, a single raw check must not change UNLESS we are initializing. Otherwise, only the sliding-window threshold can trigger a transition.
|
||||
let newStatus: MonitorStatus;
|
||||
if (monitor.status === "initializing") {
|
||||
newStatus = status === true ? "up" : "down";
|
||||
} else {
|
||||
newStatus = monitor.status;
|
||||
}
|
||||
|
||||
let statusChanged = false;
|
||||
|
||||
// First evaluate reachability-based status changes, which apply to all monitor types and take precedence over hardware breaches.
|
||||
const reachabilityResult = this.computeReachability(monitor.status, monitor.statusWindow, monitor.statusWindowThreshold);
|
||||
const reachabilityResult = this.computeReachability(newStatus, monitor.statusWindow, monitor.statusWindowThreshold);
|
||||
if (reachabilityResult.transitioned) {
|
||||
newStatus = reachabilityResult.nextStatus;
|
||||
statusChanged = true;
|
||||
@@ -273,7 +279,7 @@ export class StatusService implements IStatusService {
|
||||
const hardwarePayload = statusResponse.payload as HardwareStatusPayload | undefined;
|
||||
if (monitor.type === "hardware" && hardwarePayload?.data) {
|
||||
const hardware = this.computeHardwareStatus({
|
||||
currentStatus: monitor.status,
|
||||
currentStatus: newStatus,
|
||||
reachabilityDown: newStatus === "down",
|
||||
metrics: hardwarePayload.data,
|
||||
thresholds: {
|
||||
|
||||
@@ -72,5 +72,31 @@ describe("Heartbeat job: down detection", () => {
|
||||
const storedMonitor = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(storedMonitor.status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
// Regression guard for #3438: a sub-threshold blip must produce NO notification
|
||||
// dispatches. Before PR #3505, a failing check below threshold silently wrote
|
||||
// status="down" and the next passing check fired a spurious "Recovered" event.
|
||||
expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires zero notifications on a sub-threshold blip (regression #3438)", async () => {
|
||||
// Dedicated end-to-end guard for the #3438 bug. Distinct from "stays up when
|
||||
// failures are below threshold" because it pins the notification-dispatch
|
||||
// contract independently of status/incident assertions, so a future refactor
|
||||
// that reintroduces the silent-down-write can't sneak past by happening to
|
||||
// leave status and incidents in the right shape.
|
||||
const monitor = makeMonitor();
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Failing check below threshold (window [t,t,t,t,f], 20%)
|
||||
h.setNextResponse(false, 503);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
// Passing check — pre-fix, this is where the spurious "Recovered" fired
|
||||
h.setNextResponse(true, 200);
|
||||
await h.heartbeatJob(monitor);
|
||||
|
||||
expect((await h.monitorsRepo.findById("mon-1", "team-1")).status).toBe("up");
|
||||
expect(h.incidentsRepo.getAll()).toHaveLength(0);
|
||||
expect(h.notificationsService.handleNotifications).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -123,6 +123,57 @@ describe("Heartbeat job: hardware threshold breach", () => {
|
||||
expect(incidents[0].resolutionType).toBe("automatic");
|
||||
});
|
||||
|
||||
it("transitions breached hardware monitor to down when reachability also fails", async () => {
|
||||
// A hardware monitor currently in "breached" state starts failing its reachability
|
||||
// checks enough to cross the statusWindow threshold. "down" must take precedence
|
||||
// over "breached" — when both conditions are true, the user wants the escalated
|
||||
// alert. Unit-covered in statusService.test.ts via computeReachability; this is
|
||||
// the end-to-end counterpart that exercises the full heartbeat path including
|
||||
// incident-lifecycle handling.
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
status: "breached",
|
||||
cpuAlertThreshold: 80,
|
||||
cpuAlertCounter: 0,
|
||||
memoryAlertThreshold: 100,
|
||||
memoryAlertCounter: 5,
|
||||
diskAlertThreshold: 100,
|
||||
diskAlertCounter: 5,
|
||||
tempAlertThreshold: 100,
|
||||
tempAlertCounter: 5,
|
||||
selectedDisks: [],
|
||||
// Window is fully healthy — the monitor is breached from hardware metrics,
|
||||
// not from reachability. The test drives the window from clean → failing.
|
||||
statusWindow: [true, true, true, true, true],
|
||||
});
|
||||
h.monitorsRepo.seed(monitor);
|
||||
|
||||
// Seed an active breached incident matching the initial state.
|
||||
await h.incidentsRepo.create({
|
||||
monitorId: "mon-1",
|
||||
teamId: "team-1",
|
||||
status: true,
|
||||
statusCode: 9999,
|
||||
message: "CPU: 90% (threshold: 80%)",
|
||||
});
|
||||
|
||||
// Drive reachability failure: 3 consecutive failing heartbeats.
|
||||
// Window evolves: [t,t,t,t,t] → [t,t,t,t,f] (20%) → [t,t,t,f,f] (40%) → [t,t,f,f,f] (60%)
|
||||
// At 60% >= threshold, and current status "breached" !== "down", reachability fires.
|
||||
// Reachability sends HTTP-shaped responses with no hardware payload, so the
|
||||
// hardware block is correctly skipped for these checks.
|
||||
h.setNextResponse(false, 503);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await h.heartbeatJob(monitor);
|
||||
}
|
||||
|
||||
const stored = await h.monitorsRepo.findById("mon-1", "team-1");
|
||||
expect(stored.status).toBe("down");
|
||||
|
||||
// Notification dispatch must have fired for the transition.
|
||||
expect(h.notificationsService.handleNotifications).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create duplicate incidents while remaining breached", async () => {
|
||||
const monitor = makeMonitor({
|
||||
type: "hardware",
|
||||
|
||||
@@ -418,6 +418,45 @@ describe("StatusService", () => {
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("resumes from 'initializing' to 'up' on a passing check with a full window (regression: pause/resume)", async () => {
|
||||
// When a monitor is paused and resumed, togglePauseById sets status='initializing'
|
||||
// but does NOT reset statusWindow. On the next check the window is already full,
|
||||
// so the warmup early-return is skipped. Pre-fix, newStatus was seeded from
|
||||
// monitor.status ('initializing') and computeReachability never transitioned
|
||||
// out of 'initializing' on a passing window, so the monitor was stuck forever.
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [true, true, true, true, true],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "initializing",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }));
|
||||
|
||||
expect(result.monitor.status).toBe("up");
|
||||
});
|
||||
|
||||
it("resumes from 'initializing' to 'down' on a failing check with a full window (regression: pause/resume mirror)", async () => {
|
||||
// Mirror of the pause/resume regression with a failing check: should flip to 'down'
|
||||
// immediately rather than stay stuck in 'initializing'.
|
||||
const monitor = makeMonitor({
|
||||
statusWindow: [false, false, false, false, false],
|
||||
statusWindowSize: 5,
|
||||
statusWindowThreshold: 80,
|
||||
status: "initializing",
|
||||
});
|
||||
const { service, monitorsRepository } = createService();
|
||||
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
|
||||
(monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m));
|
||||
|
||||
const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }));
|
||||
|
||||
expect(result.monitor.status).toBe("down");
|
||||
});
|
||||
|
||||
it("during warmup (window not full) the stored status tracks the raw check", async () => {
|
||||
// Early-return branch: monitor.status is written to reflect the current check and
|
||||
// no status-changed notification is emitted. This is the service's documented warmup behavior.
|
||||
|
||||
Reference in new issue
Block a user