Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import type { RmmMaintenanceWindowRow } from './contracts';
|
|
|
|
export interface WindowMatchTarget {
|
|
integrationId: string;
|
|
clientId?: string | null;
|
|
assetId?: string | null;
|
|
/** ISO timestamp the alert occurred at. */
|
|
occurredAt: string;
|
|
}
|
|
|
|
/**
|
|
* Returns the first active window matching the alert, or null. A window
|
|
* matches when every non-null scope (integration, client, asset) equals the
|
|
* alert's value and the occurrence instant falls inside the window's one-off
|
|
* range or weekly recurrence.
|
|
*/
|
|
export function findMatchingWindow(
|
|
windows: RmmMaintenanceWindowRow[],
|
|
target: WindowMatchTarget
|
|
): RmmMaintenanceWindowRow | null {
|
|
const occurredAt = new Date(target.occurredAt);
|
|
if (Number.isNaN(occurredAt.getTime())) return null;
|
|
|
|
for (const window of windows) {
|
|
if (!window.is_active) continue;
|
|
if (window.integration_id && window.integration_id !== target.integrationId) continue;
|
|
if (window.client_id && window.client_id !== (target.clientId ?? null)) continue;
|
|
if (window.asset_id && window.asset_id !== (target.assetId ?? null)) continue;
|
|
if (isInstantInWindow(window, occurredAt)) return window;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function isInstantInWindow(window: RmmMaintenanceWindowRow, instant: Date): boolean {
|
|
if (window.recurrence?.type === 'weekly') {
|
|
return isInWeeklyRecurrence(window.recurrence, instant);
|
|
}
|
|
if (window.starts_at && window.ends_at) {
|
|
const start = new Date(window.starts_at);
|
|
const end = new Date(window.ends_at);
|
|
return instant >= start && instant < end;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Weekly recurrence, evaluated in the window's timezone. A window whose
|
|
* endTime <= startTime crosses midnight: it starts on each listed day and
|
|
* ends the following day, so an instant matches either the pre-midnight part
|
|
* (its own day is listed) or the post-midnight part (the previous day is
|
|
* listed).
|
|
*/
|
|
function isInWeeklyRecurrence(
|
|
recurrence: NonNullable<RmmMaintenanceWindowRow['recurrence']>,
|
|
instant: Date
|
|
): boolean {
|
|
const { dayOfWeek, minutes } = localDayAndMinutes(instant, recurrence.timezone);
|
|
if (dayOfWeek === null) return false;
|
|
|
|
const start = parseMinutes(recurrence.startTime);
|
|
const end = parseMinutes(recurrence.endTime);
|
|
const days = new Set(recurrence.days);
|
|
|
|
if (start < end) {
|
|
return days.has(dayOfWeek) && minutes >= start && minutes < end;
|
|
}
|
|
// Crosses midnight.
|
|
if (days.has(dayOfWeek) && minutes >= start) return true;
|
|
const previousDay = (dayOfWeek + 6) % 7;
|
|
return days.has(previousDay) && minutes < end;
|
|
}
|
|
|
|
function parseMinutes(time: string): number {
|
|
const [h, m] = time.split(':').map(Number);
|
|
return h * 60 + m;
|
|
}
|
|
|
|
const WEEKDAY_INDEX: Record<string, number> = {
|
|
Sun: 0,
|
|
Mon: 1,
|
|
Tue: 2,
|
|
Wed: 3,
|
|
Thu: 4,
|
|
Fri: 5,
|
|
Sat: 6,
|
|
};
|
|
|
|
function localDayAndMinutes(
|
|
instant: Date,
|
|
timezone: string
|
|
): { dayOfWeek: number | null; minutes: number } {
|
|
try {
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: timezone,
|
|
weekday: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hourCycle: 'h23',
|
|
}).formatToParts(instant);
|
|
const get = (type: string) => parts.find((p) => p.type === type)?.value;
|
|
const weekday = get('weekday');
|
|
const hour = Number(get('hour'));
|
|
const minute = Number(get('minute'));
|
|
const dayOfWeek = weekday !== undefined ? WEEKDAY_INDEX[weekday] ?? null : null;
|
|
if (dayOfWeek === null || Number.isNaN(hour) || Number.isNaN(minute)) {
|
|
return { dayOfWeek: null, minutes: 0 };
|
|
}
|
|
return { dayOfWeek, minutes: hour * 60 + minute };
|
|
} catch {
|
|
// Unknown timezone: fail closed (no suppression) rather than swallow alerts.
|
|
return { dayOfWeek: null, minutes: 0 };
|
|
}
|
|
}
|