Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions client/src/Components/design-elements/MonitorStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type { Monitor } from "@/Types/Monitor";
import Link from "@mui/material/Link";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements";
import { PulseDot, Dot, StrategyBadge, Tooltip } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import { isHttpUrl } from "@/Utils/UrlUtils";

export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const linkUrl =
monitor.linkUrl && isHttpUrl(monitor.linkUrl) ? monitor.linkUrl : undefined;

if (!monitor) {
return null;
Expand All @@ -32,16 +39,40 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
gap={theme.spacing(LAYOUT.XS)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={theme.typography.fontFamilyMonospace}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
>
{formatUrl(monitor?.url)}
</Typography>
{linkUrl ? (
<Tooltip
title={t("pages.createMonitor.form.general.option.linkUrl.openLinkTooltip")}
>
<Link
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
color="inherit"
underline="hover"
fontSize={typographyLevels.l}
fontWeight="bolder"
sx={{
fontFamily: theme.typography.fontFamilyMonospace,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatUrl(linkUrl)}
</Link>
</Tooltip>
) : (
<Typography
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={theme.typography.fontFamilyMonospace}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
>
{formatUrl(monitor?.url)}
</Typography>
)}
{monitor.type === "pagespeed" && monitor.strategy && (
<>
<Dot />
Expand Down
1 change: 1 addition & 0 deletions client/src/Hooks/useMonitorForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface UseMonitorFormOptions {
const getBaseDefaults = (data?: Monitor | null) => ({
name: data?.name || "",
description: data?.description || "",
linkUrl: data?.linkUrl || "",
interval: data?.interval || 60000,
notifications: data?.notifications || [],
tags: data?.tags || [],
Expand Down
23 changes: 23 additions & 0 deletions client/src/Pages/CreateMonitor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,29 @@ const CreateMonitorPage = () => {
)}
/>
)}
<Controller
name="linkUrl"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="url"
fieldLabel={t(
"pages.createMonitor.form.general.option.linkUrl.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.linkUrl.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={
fieldState.error?.message ??
t("pages.createMonitor.form.general.option.linkUrl.helperText")
}
/>
)}
/>
{generalSettingsConfig.showDnsServer && (
<Controller
name="dnsServer"
Expand Down
1 change: 1 addition & 0 deletions client/src/Types/Monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface Monitor {
matchMethod?: MonitorMatchMethod;
method?: HttpMethod;
url: string;
linkUrl?: string;
port?: number;
isActive: boolean;
interval: number;
Expand Down
8 changes: 8 additions & 0 deletions client/src/Utils/UrlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const isHttpUrl = (value: string): boolean => {
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
};
9 changes: 9 additions & 0 deletions client/src/Validation/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
import { isHttpUrl } from "@/Utils/UrlUtils";

// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
Expand All @@ -16,6 +17,13 @@ export const monitorStepRegistry = z.registry<{ step: number }>();

// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
const MAX_LINK_URL_LENGTH = 2048;
const httpLinkUrlSchema = z
.url({ message: "Please enter a valid application URL" })
.max(MAX_LINK_URL_LENGTH, {
message: "Application URL must be at most 2048 characters",
})
.refine(isHttpUrl, { message: "Application URL must use HTTP or HTTPS" });

// Common base schema for all monitor types
const baseSchema = z.object({
Expand All @@ -24,6 +32,7 @@ const baseSchema = z.object({
.min(1, "Monitor name is required")
.max(50, "Monitor name must be at most 50 characters"),
description: z.string().optional(),
linkUrl: z.union([httpLinkUrlSchema, z.literal("")]).optional(),
interval: z
.number()
.min(15000, "Interval must be at least 15 seconds")
Expand Down
6 changes: 6 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,12 @@
"label": "URL",
"placeholder": "https://www.google.com"
},
"linkUrl": {
"label": "Application URL (optional)",
"placeholder": "https://app.example.com",
"helperText": "Opens the monitored application from its details page. Only HTTP and HTTPS URLs are allowed.",
"openLinkTooltip": "Open application in a new tab"
},
"game": {
"label": "Choose game",
"placeholder": "Select a game"
Expand Down
18 changes: 18 additions & 0 deletions server/src/api/validation/monitorValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ import {
import { DateRanges, SortOrders } from "@/types/query.js";

const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" });
const MAX_LINK_URL_LENGTH = 2048;
const isHttpUrl = (value: string): boolean => {
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
};
const httpLinkUrl = z
.url({ message: "Application link must be a valid URL" })
.max(MAX_LINK_URL_LENGTH, { message: "Application link must be at most 2048 characters" })
.refine(isHttpUrl, { message: "Application link must use HTTP or HTTPS" });
const optionalLinkUrl = z.union([httpLinkUrl, z.literal("")]).optional();

export const getMonitorByIdParamValidation = z.object({
monitorId: z.string().min(1, "Monitor ID is required"),
Expand Down Expand Up @@ -106,6 +120,7 @@ export const createMonitorBodyValidation = z
statusWindowSize: z.number().min(1).max(20).default(5),
statusWindowThreshold: z.number().min(1).max(100).default(60),
url: z.string().min(1, "URL is required"),
linkUrl: optionalLinkUrl,
ignoreTlsErrors: z.boolean().default(false),
useAdvancedMatching: z.boolean().default(false),
port: z.number().optional(),
Expand Down Expand Up @@ -144,6 +159,7 @@ export const editMonitorBodyValidation = z
name: z.string().optional(),
type: z.enum(MonitorTypes).optional(),
url: z.string().optional(),
linkUrl: optionalLinkUrl,
statusWindowSize: z.number().min(1).max(20).default(5),
statusWindowThreshold: z.number().min(1).max(100).default(60),
description: z.union([z.string(), z.literal("")]).optional(),
Expand Down Expand Up @@ -219,6 +235,7 @@ const importedMonitorSchema = z
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
method: z.enum(HttpMethods).optional().default("GET"),
url: z.string().min(1, "URL is required"),
linkUrl: optionalLinkUrl,
port: z.number().optional(),
isActive: z.boolean().default(true),
interval: z.number().default(60000),
Expand Down Expand Up @@ -276,6 +293,7 @@ export const monitorResponseSchema = z
description: z.string().optional(),
type: z.enum(MonitorTypes),
url: z.string(),
linkUrl: z.string().optional(),
port: z.number().optional(),
isActive: z.boolean(),
interval: z.number(),
Expand Down
5 changes: 5 additions & 0 deletions server/src/domain/monitors/monitor.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ const MonitorSchema = new Schema<MonitorDocument>(
type: String,
required: true,
},
linkUrl: {
type: String,
trim: true,
maxLength: 2048,
},
port: {
type: Number,
},
Expand Down
1 change: 1 addition & 0 deletions server/src/domain/monitors/monitor.repository.mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
expectedValue: doc.expectedValue ?? undefined,
matchMethod: doc.matchMethod ?? undefined,
url: doc.url,
linkUrl: doc.linkUrl || undefined,
port: doc.port ?? undefined,
isActive: doc.isActive,
interval: doc.interval,
Expand Down
1 change: 1 addition & 0 deletions server/src/domain/monitors/monitor.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface Monitor {
expectedValue?: string;
matchMethod?: MonitorMatchMethod;
url: string;
linkUrl?: string;
port?: number;
isActive: boolean;
interval: number;
Expand Down
49 changes: 49 additions & 0 deletions server/test/unit/validation/monitorValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,52 @@ describe("monitorValidation — customUpCodes", () => {
});
});
});

describe("monitorValidation — application link URLs", () => {
const baseMonitor = {
name: "Linked monitor",
type: "http" as const,
url: "https://example.com/health",
};

it("accepts HTTP and HTTPS links, including local addresses with ports and paths", () => {
for (const linkUrl of ["https://app.example.com/login", "http://192.168.1.10:8080/admin"]) {
const parsed = createMonitorBodyValidation.parse({ ...baseMonitor, linkUrl });
expect(parsed.linkUrl).toBe(linkUrl);
}
});

it("allows the optional link to be omitted or cleared", () => {
expect(createMonitorBodyValidation.parse(baseMonitor).linkUrl).toBeUndefined();
expect(editMonitorBodyValidation.parse({ linkUrl: "" }).linkUrl).toBe("");
});

it("rejects unsafe protocols and malformed links on create and edit", () => {
for (const linkUrl of ["javascript:alert(1)", "file:///etc/passwd", "ftp://example.com", "not-a-url"]) {
expect(() => createMonitorBodyValidation.parse({ ...baseMonitor, linkUrl })).toThrow();
expect(() => editMonitorBodyValidation.parse({ linkUrl })).toThrow();
}
});

it("rejects application links longer than 2048 characters", () => {
const linkUrl = `https://example.com/${"a".repeat(2049)}`;
expect(() => createMonitorBodyValidation.parse({ ...baseMonitor, linkUrl })).toThrow();
});

it("preserves a valid application link when importing monitors", () => {
const linkUrl = "https://app.example.com";
const parsed = importMonitorsBodyValidation.parse({
monitors: [{ ...baseMonitor, linkUrl }],
});

expect(parsed.monitors[0].linkUrl).toBe(linkUrl);
});

it("rejects unsafe application links when importing monitors", () => {
expect(() =>
importMonitorsBodyValidation.parse({
monitors: [{ ...baseMonitor, linkUrl: "javascript:alert(1)" }],
})
).toThrow();
});
});
Loading