Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a672f7604d | ||
|
|
25a7ac4877 | ||
|
|
f74d1d53f6 |
No files matched your search
@@ -1,22 +0,0 @@
|
||||
name: Lockfile Check (Client & Server)
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-lockfiles:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Validate with the same node image the production build uses, so a lock
|
||||
# that passes here cannot fail the image build's npm ci.
|
||||
- name: Resolve node image from Dockerfile
|
||||
id: node-image
|
||||
run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check server lockfile
|
||||
run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||
|
||||
- name: Check client lockfile
|
||||
run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run
|
||||
@@ -32,13 +32,12 @@
|
||||
|
||||
<h1 align="center"><a href="https://bluewavelabs.ca" target="_blank">Checkmate</a></h1>
|
||||
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong>
|
||||
<p align="center"><strong>An open source uptime and infrastructure monitoring application</strong></p>
|
||||
|
||||
[](https://www.pikapods.com/pods?run=checkmate)
|
||||
|
||||
|
||||
<img width="1703" height="1041" alt="image" src="https://github.com/user-attachments/assets/0f4dcf38-9b42-4b84-8633-ff34778df1a8" />
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
|
||||
@@ -92,10 +91,6 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d
|
||||
|
||||
Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345.
|
||||
|
||||
There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate),
|
||||
[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate).
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The image is configured entirely through environment variables on the server container:
|
||||
@@ -212,9 +207,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you!
|
||||
<a id="contributing"></a>
|
||||
## Contributing
|
||||
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers.
|
||||
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
|
||||
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe.
|
||||
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
|
||||
|
||||
Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us!
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "3.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build-dev": "tsc -b && vite build --mode development",
|
||||
|
||||
@@ -113,7 +113,6 @@ const StatusPageView = () => {
|
||||
apiUrl,
|
||||
{},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
refreshInterval: range === "latest" ? 10000 : 60000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -34,9 +34,9 @@ export interface BoldStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell, severity?: number) => SxProps<Theme>;
|
||||
heatmapCell: (kind: BoldHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
bar: (kind: BoldBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -327,12 +327,9 @@ export const boldStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 48,
|
||||
},
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
heatmapCell: (kind) => ({
|
||||
borderRadius: "3px",
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
background: heatCellBg[kind],
|
||||
boxShadow: heatCellShadow[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
@@ -347,11 +344,8 @@ export const boldStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 48,
|
||||
},
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import {
|
||||
EDITORIAL_SECONDARY_SANS_STACK,
|
||||
@@ -38,9 +38,9 @@ export interface EditorialStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell, severity?: number) => SxProps<Theme>;
|
||||
heatmapCell: (kind: EditorialHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
bar: (kind: EditorialBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -272,11 +272,8 @@ export const editorialStyles = (
|
||||
gap: { xs: "1px", md: "2px" },
|
||||
height: 40,
|
||||
},
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
heatmapCell: (kind) => ({
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
}),
|
||||
|
||||
@@ -288,11 +285,8 @@ export const editorialStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 40,
|
||||
},
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.5 : 1,
|
||||
height: `${heightPct}%`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { keyframes } from "@mui/system";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
|
||||
@@ -37,9 +37,9 @@ export interface ModernStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell, severity?: number) => SxProps<Theme>;
|
||||
heatmapCell: (kind: ModernHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
bar: (kind: ModernBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -83,14 +83,13 @@ export const modernStyles = (
|
||||
): ModernStyles => {
|
||||
const cardShadow = isDark ? darkShadow : lightShadow;
|
||||
const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
|
||||
const darken = (color: string) => `color-mix(in srgb, ${color} 70%, #000000 30%)`;
|
||||
|
||||
const heatCellBg: Record<ModernHeatCell, string> = {
|
||||
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
|
||||
slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, ${darken(tokens.degraded)})`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`,
|
||||
down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
empty: tokens.border,
|
||||
};
|
||||
|
||||
@@ -104,7 +103,7 @@ export const modernStyles = (
|
||||
const gaugeFillBg: Record<ModernGaugeFill, string> = {
|
||||
ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
|
||||
warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down}, ${darken(tokens.down)})`,
|
||||
hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -355,19 +354,13 @@ export const modernStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 46,
|
||||
},
|
||||
heatmapCell: (kind, severity = 1) => {
|
||||
const mixed = mixSeverityColor(tokens.up, tokens.degraded, severity);
|
||||
return {
|
||||
borderRadius: "3px",
|
||||
background:
|
||||
kind === "degraded"
|
||||
? `linear-gradient(180deg, ${mixed}, ${darken(mixed)})`
|
||||
: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
};
|
||||
},
|
||||
heatmapCell: (kind) => ({
|
||||
borderRadius: "3px",
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.2)" },
|
||||
}),
|
||||
|
||||
histogram: {
|
||||
padding: { xs: "0 12px", md: "0 24px" },
|
||||
@@ -377,11 +370,8 @@ export const modernStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 46,
|
||||
},
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
borderRadius: "3px",
|
||||
minHeight: 4,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
import { mixSeverityColor, type StatusPageThemeTokens } from "../tokens";
|
||||
import type { StatusPageThemeTokens } from "../tokens";
|
||||
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
|
||||
import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
|
||||
|
||||
@@ -37,9 +37,9 @@ export interface RefinedStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell, severity?: number) => SxProps<Theme>;
|
||||
heatmapCell: (kind: RefinedHeatCell) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
bar: (kind: RefinedBarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
@@ -296,12 +296,9 @@ export const refinedStyles = (
|
||||
gap: { xs: "1px", md: "3px" },
|
||||
height: 42,
|
||||
},
|
||||
heatmapCell: (kind, severity = 1) => ({
|
||||
heatmapCell: (kind) => ({
|
||||
borderRadius: "2px",
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: heatCellBg[kind],
|
||||
background: heatCellBg[kind],
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
transition: "transform 0.15s",
|
||||
"&:hover": { transform: "scaleY(1.15)" },
|
||||
@@ -315,11 +312,8 @@ export const refinedStyles = (
|
||||
alignItems: "flex-end",
|
||||
height: 42,
|
||||
},
|
||||
bar: (kind, heightPct, severity = 1) => ({
|
||||
background:
|
||||
kind === "degraded"
|
||||
? mixSeverityColor(tokens.up, tokens.degraded, severity)
|
||||
: barBg[kind],
|
||||
bar: (kind, heightPct) => ({
|
||||
background: barBg[kind],
|
||||
borderRadius: "2px",
|
||||
minHeight: 3,
|
||||
opacity: kind === "empty" ? 0.4 : 1,
|
||||
|
||||
@@ -59,9 +59,9 @@ export interface BaseStyles {
|
||||
monitorUrl: SxProps<Theme>;
|
||||
badge: (tone: OverallTone) => SxProps<Theme>;
|
||||
heatmap: SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
heatmapCell: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
histogram: SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
bar: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
chartStats: SxProps<Theme>;
|
||||
infra: SxProps<Theme>;
|
||||
infraEmpty: SxProps<Theme>;
|
||||
|
||||
@@ -18,7 +18,6 @@ export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empt
|
||||
|
||||
export interface ChartCell {
|
||||
key: string;
|
||||
severity: number;
|
||||
barKind: BarKind;
|
||||
heatKind: HeatCellKind;
|
||||
heightPct: number; // histogram bar height
|
||||
@@ -29,22 +28,9 @@ export interface ChartCell {
|
||||
|
||||
const DAY_DOWN_THRESHOLD = 0.5;
|
||||
|
||||
// Color interpolation
|
||||
// 0 = perfect
|
||||
// 1 = at or below the down threshold
|
||||
const SEVERITY_FLOOR = 0.15; // A degraded server should never be green
|
||||
const SEVERITY_GAMMA = 2; // >1 biases toward green so high-uptime days stay visibly distinct from low ones
|
||||
|
||||
export const severityFromUpRatio = (upRatio: number): number => {
|
||||
const failed = 1 - upRatio;
|
||||
if (failed <= 0) return 0;
|
||||
const scaled = (Math.log10(failed) + 3) / (Math.log10(1 - DAY_DOWN_THRESHOLD) + 3);
|
||||
const clamped = Math.min(1, Math.max(0, scaled));
|
||||
return Math.max(SEVERITY_FLOOR, clamped ** SEVERITY_GAMMA);
|
||||
};
|
||||
|
||||
const classifyDay = (bucket: DailyCheckBucket, upRatio: number): BarKind => {
|
||||
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
|
||||
if (bucket.totalChecks === 0) return "empty";
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
if (upRatio >= 1) return "up";
|
||||
if (upRatio < DAY_DOWN_THRESHOLD) return "down";
|
||||
return "degraded";
|
||||
@@ -69,7 +55,6 @@ const emptyCell = (key: string): ChartCell => ({
|
||||
responseTime: 0,
|
||||
tooltip: null,
|
||||
ariaLabel: "",
|
||||
severity: 0,
|
||||
});
|
||||
|
||||
const padCells = (cells: ChartCell[], length: number): ChartCell[] => [
|
||||
@@ -91,7 +76,6 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => {
|
||||
responseTime: check.responseTime ?? 0,
|
||||
tooltip: <ThemedChartTooltip check={check} />,
|
||||
ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`,
|
||||
severity: 0,
|
||||
})
|
||||
);
|
||||
return padCells(cells, MAX_RECENT_CHECKS);
|
||||
@@ -109,17 +93,9 @@ export const dailyBucketsToCells = (
|
||||
return enumerated.map((date) => {
|
||||
const bucket = byDate.get(date);
|
||||
if (!bucket) return emptyCell(`day-${date}`);
|
||||
const upRatio = bucket.upChecks / bucket.totalChecks;
|
||||
|
||||
const barKind = classifyDay(bucket, upRatio);
|
||||
const barKind = classifyDay(bucket);
|
||||
return {
|
||||
key: `day-${date}`,
|
||||
severity:
|
||||
barKind === "degraded"
|
||||
? severityFromUpRatio(upRatio)
|
||||
: barKind === "down"
|
||||
? 1
|
||||
: 0,
|
||||
barKind,
|
||||
heatKind:
|
||||
barKind === "up"
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
|
||||
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
|
||||
}
|
||||
|
||||
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
@@ -42,7 +42,7 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={cellSx(cell.heatKind, cell.severity)}
|
||||
sx={cellSx(cell.heatKind)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
interface Props {
|
||||
cells: ChartCell[];
|
||||
containerSx: SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
|
||||
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
|
||||
statsSx: SxProps<Theme>;
|
||||
statsGap?: number;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export const ThemedHistogram = ({
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
|
||||
sx={barSx(cell.barKind, cell.heightPct)}
|
||||
aria-label={cell.ariaLabel}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -243,10 +243,3 @@ export const themeTokens: Record<StatusPageTheme, ThemeVariants> = {
|
||||
editorial,
|
||||
minimal,
|
||||
};
|
||||
|
||||
// Interpolate between a theme's up and degraded colors by severity (0..1).
|
||||
export const mixSeverityColor = (
|
||||
up: string,
|
||||
degraded: string,
|
||||
severity: number
|
||||
): string => `color-mix(in oklch, ${degraded} ${Math.round(severity * 100)}%, ${up})`;
|
||||
@@ -34,6 +34,17 @@ import { Typography } from "@mui/material";
|
||||
|
||||
const certificateDateFormat = "MMM D, YYYY h A";
|
||||
|
||||
const isHttpsUrl = (url?: string): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
interface CertificateResponse {
|
||||
certificateDate: string;
|
||||
}
|
||||
@@ -80,13 +91,15 @@ const UptimeDetailsPage = () => {
|
||||
const monitor = monitorData?.monitor;
|
||||
const monitorStats = monitorDetailsData?.monitorStats ?? null;
|
||||
|
||||
// Certificate fetch - only for HTTP monitors
|
||||
// Certificate fetch - only for monitors served over HTTPS. A "http" type
|
||||
// monitor may still have an http:// URL, which serves no certificate, so the
|
||||
// scheme is gated here to match the server rather than requesting a 400.
|
||||
const certificateUrl = useMemo(() => {
|
||||
if (!monitorId || monitor?.type !== "http") {
|
||||
if (!monitorId || monitor?.type !== "http" || !isHttpsUrl(monitor?.url)) {
|
||||
return null;
|
||||
}
|
||||
return `/monitors/certificate/${monitorId}`;
|
||||
}, [monitorId, monitor?.type]);
|
||||
}, [monitorId, monitor?.type, monitor?.url]);
|
||||
|
||||
const { data: certificateData } = useGet<CertificateResponse>(
|
||||
certificateUrl,
|
||||
|
||||
@@ -69,12 +69,7 @@ const httpSchema = baseSchema.extend({
|
||||
type: z.literal("http"),
|
||||
url: urlSchema,
|
||||
proxyMode: z.enum(ProxyModes),
|
||||
// The form uses "" for "no proxy selected". drop it from the payload so the
|
||||
// server never receives an empty ObjectId
|
||||
proxyId: z
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.optional(),
|
||||
proxyId: z.string().optional(),
|
||||
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
|
||||
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
|
||||
|
||||
Generated
+19
-24
@@ -194,6 +194,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -828,6 +829,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -874,6 +876,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -889,29 +892,6 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
@@ -919,7 +899,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -3144,6 +3123,7 @@
|
||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -3297,6 +3277,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
|
||||
"integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
@@ -3512,6 +3493,7 @@
|
||||
"integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.68.0",
|
||||
"@typescript-eslint/types": "8.68.0",
|
||||
@@ -4101,6 +4083,7 @@
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4465,6 +4448,7 @@
|
||||
"integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -4743,6 +4727,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -5655,6 +5640,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz",
|
||||
"integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssnano-preset-default": "^7.0.17",
|
||||
"lilconfig": "^3.1.3"
|
||||
@@ -6439,6 +6425,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6883,6 +6870,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -8377,6 +8365,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -8399,6 +8388,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -8695,6 +8685,7 @@
|
||||
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.4.2",
|
||||
"@jest/types": "30.4.1",
|
||||
@@ -11907,6 +11898,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -14211,6 +14203,7 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -14438,6 +14431,7 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15210,6 +15204,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
||||
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit",
|
||||
"test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications",
|
||||
|
||||
@@ -7,10 +7,23 @@ import { parse as parseDomain } from "tldts";
|
||||
type SSLCheckerType = typeof sslChecker;
|
||||
type WhoisModule = typeof whoiser;
|
||||
|
||||
const DEFAULT_HTTPS_PORT = 443;
|
||||
// Ports are user-defined, so a probe can dial something that accepts the
|
||||
// connection and never completes the handshake. Bound the wait explicitly.
|
||||
const CERTIFICATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
|
||||
const monitorUrl = new URL(monitor.url);
|
||||
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
|
||||
// caller gets this instead of a raw OpenSSL handshake error from the socket.
|
||||
if (monitorUrl.protocol !== "https:") {
|
||||
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
|
||||
}
|
||||
const hostname = monitorUrl.hostname;
|
||||
const cert = await checker(hostname);
|
||||
// URL.port is "" for the scheme's default port, so fall back to 443; without
|
||||
// this the certificate of a monitor on a non-standard port was probed on 443.
|
||||
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
|
||||
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
|
||||
if (cert?.validTo === null || cert?.validTo === undefined) {
|
||||
throw new Error("Certificate not found");
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ import { DateRanges, SortOrders } from "@/types/query.js";
|
||||
|
||||
const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
|
||||
|
||||
// The client form submits proxyId: "" when no proxy is selected, set it to undefined
|
||||
const proxyIdValidation = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.refine((value) => value === undefined || /^[0-9a-f]{24}$/i.test(value), { message: "Invalid proxy ID" });
|
||||
|
||||
export const getMonitorByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
@@ -129,7 +122,7 @@ export const createMonitorBodyValidation = z
|
||||
url: z.string().min(1, "URL is required"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
proxyMode: z.enum(ProxyModes).default("inherit"),
|
||||
proxyId: proxyIdValidation,
|
||||
proxyId: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
port: z.number().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
@@ -178,7 +171,7 @@ export const editMonitorBodyValidation = z
|
||||
secret: z.string().optional(),
|
||||
ignoreTlsErrors: z.boolean().optional(),
|
||||
proxyMode: z.enum(ProxyModes).optional(),
|
||||
proxyId: proxyIdValidation,
|
||||
proxyId: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().optional(),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -240,7 +233,7 @@ const importedMonitorSchema = z
|
||||
type: z.enum(MonitorTypes, "Invalid monitor type"),
|
||||
ignoreTlsErrors: z.boolean().default(false),
|
||||
proxyMode: z.enum(ProxyModes).default("inherit"),
|
||||
proxyId: proxyIdValidation,
|
||||
proxyId: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean().default(false),
|
||||
jsonPath: z.union([z.string(), z.literal("")]).optional(),
|
||||
expectedValue: z.union([z.string(), z.literal("")]).optional(),
|
||||
@@ -313,7 +306,7 @@ export const monitorResponseSchema = z
|
||||
statusWindowThreshold: z.number(),
|
||||
ignoreTlsErrors: z.boolean(),
|
||||
proxyMode: z.enum(ProxyModes),
|
||||
proxyId: proxyIdValidation,
|
||||
proxyId: z.string().optional(),
|
||||
useAdvancedMatching: z.boolean(),
|
||||
jsonPath: z.string().optional(),
|
||||
expectedValue: z.string().optional(),
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface IMonitorsRepository {
|
||||
findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise<Monitor[]>;
|
||||
|
||||
// update
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>, options?: { unsetProxyId?: boolean }): Promise<Monitor>;
|
||||
updateById(monitorId: string, teamId: string, updates: Partial<Monitor>): Promise<Monitor>;
|
||||
updateByIds(monitorIds: string[], teamId: string, updates: Partial<Monitor>, excludeStatuses?: MonitorStatus[]): Promise<number>;
|
||||
updateStatusWindowAndChecks(
|
||||
monitorId: string,
|
||||
|
||||
@@ -177,14 +177,16 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return count;
|
||||
};
|
||||
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>, options?: { unsetProxyId?: boolean }) => {
|
||||
const fields: Partial<Monitor> = { ...patch };
|
||||
const update: Record<string, unknown> = { $set: fields };
|
||||
if (options?.unsetProxyId) {
|
||||
delete fields.proxyId;
|
||||
update.$unset = { proxyId: "" };
|
||||
}
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate({ _id: monitorId, teamId }, update, { new: true, runValidators: true });
|
||||
updateById = async (monitorId: string, teamId: string, patch: Partial<Monitor>) => {
|
||||
const updatedMonitor = await MonitorModel.findOneAndUpdate(
|
||||
{ _id: monitorId, teamId },
|
||||
{
|
||||
$set: {
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
if (!updatedMonitor) {
|
||||
throw new AppError({ message: `Failed to update monitor with id ${monitorId}`, status: 500 });
|
||||
}
|
||||
|
||||
@@ -133,10 +133,6 @@ export class MonitorService implements IMonitorService {
|
||||
}
|
||||
|
||||
createMonitor = async (teamId: string, userId: string, body: Monitor): Promise<void> => {
|
||||
// proxyId is only needed in custom mode
|
||||
if (body.proxyMode !== "custom") {
|
||||
delete body.proxyId;
|
||||
}
|
||||
const monitor = await this.monitorsRepository.create(body, teamId, userId);
|
||||
if (!monitor) {
|
||||
throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" });
|
||||
@@ -379,12 +375,7 @@ export class MonitorService implements IMonitorService {
|
||||
};
|
||||
|
||||
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
|
||||
// Moving off custom mode orphans the stored proxyId. Unset it so the stale reference doesn't block deleting that proxy
|
||||
const unsetProxyId = body.proxyMode !== undefined && body.proxyMode !== "custom";
|
||||
if (unsetProxyId) {
|
||||
delete body.proxyId;
|
||||
}
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body, { unsetProxyId });
|
||||
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
|
||||
await this.scheduler.updateJob(editedMonitor);
|
||||
return editedMonitor;
|
||||
};
|
||||
|
||||
Vendored
+3
@@ -12,6 +12,9 @@ declare module "ssl-checker" {
|
||||
export interface SSLOptions {
|
||||
method?: "GET" | "HEAD";
|
||||
port?: number;
|
||||
// Inherited from https.RequestOptions; ssl-checker rejects with "Timed Out"
|
||||
// when the socket exceeds it.
|
||||
timeout?: number;
|
||||
agent?: Agent;
|
||||
rejectUnauthorized?: boolean;
|
||||
validateSubjectAltName?: boolean;
|
||||
|
||||
@@ -124,38 +124,6 @@ describe("MonitorService", () => {
|
||||
expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor);
|
||||
});
|
||||
|
||||
it("strips a stray proxyId when proxyMode is not custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.createMonitor(TEAM_ID, USER_ID, {
|
||||
...monitor,
|
||||
proxyMode: "inherit",
|
||||
proxyId: "64b7a1f2c9e77a001a2b3c4d",
|
||||
} as any);
|
||||
|
||||
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(createdBody.proxyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps proxyId when proxyMode is custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(monitor);
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.createMonitor(TEAM_ID, USER_ID, {
|
||||
...monitor,
|
||||
proxyMode: "custom",
|
||||
proxyId: "64b7a1f2c9e77a001a2b3c4d",
|
||||
} as any);
|
||||
|
||||
const createdBody = (monitorsRepository.create as jest.Mock).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(createdBody.proxyId).toBe("64b7a1f2c9e77a001a2b3c4d");
|
||||
});
|
||||
|
||||
it("throws when repository returns null", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.create as jest.Mock).mockResolvedValue(null);
|
||||
@@ -782,42 +750,9 @@ describe("MonitorService", () => {
|
||||
const result = await service.editMonitor({ teamId: TEAM_ID, monitorId: MONITOR_ID, body: { name: "Updated" } });
|
||||
|
||||
expect(result).toMatchObject({ name: "Updated" });
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" }, { unsetProxyId: false });
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { name: "Updated" });
|
||||
expect(jobQueue.updateJob).toHaveBeenCalledWith(updatedMonitor);
|
||||
});
|
||||
|
||||
it("keeps proxyId and does not unset it when proxyMode is custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.editMonitor({
|
||||
teamId: TEAM_ID,
|
||||
monitorId: MONITOR_ID,
|
||||
body: { proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(
|
||||
MONITOR_ID,
|
||||
TEAM_ID,
|
||||
{ proxyMode: "custom", proxyId: "64b7a1f2c9e77a001a2b3c4d" },
|
||||
{ unsetProxyId: false }
|
||||
);
|
||||
});
|
||||
|
||||
it("strips proxyId and unsets it when proxyMode moves off custom", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.updateById as jest.Mock).mockResolvedValue(makeMonitor());
|
||||
const { service } = createService({ monitorsRepository });
|
||||
|
||||
await service.editMonitor({
|
||||
teamId: TEAM_ID,
|
||||
monitorId: MONITOR_ID,
|
||||
body: { proxyMode: "inherit", proxyId: "64b7a1f2c9e77a001a2b3c4d" } as any,
|
||||
});
|
||||
|
||||
expect(monitorsRepository.updateById).toHaveBeenCalledWith(MONITOR_ID, TEAM_ID, { proxyMode: "inherit" }, { unsetProxyId: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateNotifications", () => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { fetchMonitorCertificate } from "../../../src/api/controllers/controllerUtils.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
|
||||
import type { SSLDetails, SSLOptions } from "ssl-checker";
|
||||
|
||||
const VALID_CERT: SSLDetails = {
|
||||
daysRemaining: 30,
|
||||
valid: true,
|
||||
validFrom: "2026-01-01T00:00:00.000Z",
|
||||
validTo: "2026-12-31T00:00:00.000Z",
|
||||
validFor: ["example.com"],
|
||||
};
|
||||
|
||||
/** Records the arguments the checker was called with. */
|
||||
const spyChecker = (cert: SSLDetails = VALID_CERT) => {
|
||||
const calls: Array<{ hostname: string; options?: SSLOptions }> = [];
|
||||
const checker = (async (hostname: string, options?: SSLOptions) => {
|
||||
calls.push({ hostname, options });
|
||||
return cert;
|
||||
}) as unknown as typeof import("ssl-checker").default;
|
||||
return { checker, calls };
|
||||
};
|
||||
|
||||
const monitorWithUrl = (url: string) => ({ url }) as Monitor;
|
||||
|
||||
describe("fetchMonitorCertificate", () => {
|
||||
it("probes the port named in the monitor URL", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].hostname).toBe("example.com");
|
||||
expect(calls[0].options?.port).toBe(54321);
|
||||
});
|
||||
|
||||
it("falls back to 443 when the URL names no port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("passes an explicit 443 through unchanged", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:443"));
|
||||
|
||||
expect(calls[0].options?.port).toBe(443);
|
||||
});
|
||||
|
||||
it("bounds the probe with a timeout so a user-defined port cannot hang it", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:54321"));
|
||||
|
||||
expect(calls[0].options?.timeout).toBe(10_000);
|
||||
});
|
||||
|
||||
it("never passes NaN as the port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com"));
|
||||
|
||||
expect(Number.isNaN(calls[0].options?.port)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor without opening a TLS connection", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
// Probing a non-TLS port would surface a raw OpenSSL handshake error.
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com:8080"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a plain-HTTP monitor on the default port", async () => {
|
||||
const { checker, calls } = spyChecker();
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("http://example.com"))).rejects.toThrow(
|
||||
"Certificate is only available for HTTPS monitors"
|
||||
);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns the certificate details it received", async () => {
|
||||
const { checker } = spyChecker();
|
||||
|
||||
const cert = await fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"));
|
||||
|
||||
expect(cert.validTo).toBe(VALID_CERT.validTo);
|
||||
});
|
||||
|
||||
it("throws when the certificate has no expiry", async () => {
|
||||
const { checker } = spyChecker({ ...VALID_CERT, validTo: undefined as unknown as string });
|
||||
|
||||
await expect(fetchMonitorCertificate(checker, monitorWithUrl("https://example.com:8443"))).rejects.toThrow("Certificate not found");
|
||||
});
|
||||
});
|
||||
@@ -477,7 +477,6 @@ describe("monitorValidation — proxy fields", () => {
|
||||
type: "http" as const,
|
||||
url: "https://example.com",
|
||||
};
|
||||
const PROXY_ID = "64b7a1f2c9e77a001a2b3c4d";
|
||||
|
||||
describe("createMonitorBodyValidation", () => {
|
||||
it("defaults proxyMode to inherit", () => {
|
||||
@@ -488,38 +487,23 @@ describe("monitorValidation — proxy fields", () => {
|
||||
});
|
||||
|
||||
it("accepts custom mode with a proxyId", () => {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: PROXY_ID });
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" });
|
||||
|
||||
expect(parsed.proxyMode).toBe("custom");
|
||||
expect(parsed.proxyId).toBe(PROXY_ID);
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
});
|
||||
|
||||
it("rejects custom mode without a proxyId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects custom mode with an empty-string proxyId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "" })).toThrow();
|
||||
});
|
||||
|
||||
it("normalizes an empty-string proxyId to undefined on non-custom modes", () => {
|
||||
for (const proxyMode of ["inherit", "none"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "" });
|
||||
expect(parsed.proxyId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a proxyId that is not a valid ObjectId", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown proxyMode", () => {
|
||||
expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow();
|
||||
});
|
||||
|
||||
it("allows a stray proxyId on non-custom modes", () => {
|
||||
for (const proxyMode of ["inherit", "none"] as const) {
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: PROXY_ID });
|
||||
const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" });
|
||||
expect(parsed.proxyMode).toBe(proxyMode);
|
||||
}
|
||||
});
|
||||
@@ -537,19 +521,9 @@ describe("monitorValidation — proxy fields", () => {
|
||||
});
|
||||
|
||||
it("accepts custom mode with a proxyId", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: PROXY_ID });
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" });
|
||||
|
||||
expect(parsed.proxyId).toBe(PROXY_ID);
|
||||
});
|
||||
|
||||
it("normalizes an empty-string proxyId to undefined", () => {
|
||||
const parsed = editMonitorBodyValidation.parse({ proxyMode: "inherit", proxyId: "" });
|
||||
|
||||
expect(parsed.proxyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a proxyId that is not a valid ObjectId", () => {
|
||||
expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "not-an-object-id" })).toThrow();
|
||||
expect(parsed.proxyId).toBe("proxy-1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user