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();
+ });
+});