diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 2d53470f0..4c2f4d1bc 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -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; @@ -32,16 +39,40 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { gap={theme.spacing(LAYOUT.XS)} > - - {formatUrl(monitor?.url)} - + {linkUrl ? ( + + + {formatUrl(linkUrl)} + + + ) : ( + + {formatUrl(monitor?.url)} + + )} {monitor.type === "pagespeed" && monitor.strategy && ( <> diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 9c6200d22..167e7491b 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -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 || [], diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 96b05be71..81b82fbf0 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -620,6 +620,29 @@ const CreateMonitorPage = () => { )} /> )} + ( + + )} + /> {generalSettingsConfig.showDnsServer && ( { + try { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +}; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a31cdb919..620cb42e8 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -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 @@ -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({ @@ -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") diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d940f95fc..08427688a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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" diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 37a31fc6a..d2472808b 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -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"), @@ -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(), @@ -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(), @@ -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), @@ -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(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 446631f45..c324282b7 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -271,6 +271,11 @@ const MonitorSchema = new Schema( type: String, required: true, }, + linkUrl: { + type: String, + trim: true, + maxLength: 2048, + }, port: { type: Number, }, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index b96ec3907..673b8555e 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -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, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 865bafd76..1e87b8f14 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -86,6 +86,7 @@ export interface Monitor { expectedValue?: string; matchMethod?: MonitorMatchMethod; url: string; + linkUrl?: string; port?: number; isActive: boolean; interval: number; diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index d6998367a..0ee99eb7d 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -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(); + }); +});