From e5f21eda72a898cd5db31b9d2a34f71ac013b2c0 Mon Sep 17 00:00:00 2001 From: VrianCao <45995071+VrianCao@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:07:02 +0800 Subject: [PATCH] Add monitor redirect and display URL controls --- apps/web/src/api/types.ts | 8 ++ apps/web/src/components/MonitorCard.tsx | 24 ++++- apps/web/src/components/MonitorForm.tsx | 70 +++++++++++++- apps/web/src/i18n/messages.ts | 20 ++++ apps/web/src/main.tsx | 1 + apps/web/src/pages/AdminDashboard.tsx | 15 ++- .../0013_monitor_redirects_display_url.sql | 9 ++ apps/worker/src/monitor/http.ts | 2 + apps/worker/src/notify/template.ts | 8 +- apps/worker/src/public/data.ts | 3 + apps/worker/src/public/homepage.ts | 5 + apps/worker/src/routes/admin.ts | 9 ++ apps/worker/src/scheduler/notifications.ts | 2 + apps/worker/src/scheduler/scheduled.ts | 11 +++ apps/worker/src/schemas/monitors.ts | 25 +++++ apps/worker/src/schemas/public-homepage.ts | 3 + apps/worker/src/schemas/public-status.ts | 4 + .../src/snapshots/public-monitor-fragments.ts | 22 ++++- .../admin-monitor-response-assertions.test.ts | 96 +++++++++++++++---- apps/worker/test/monitor-http.test.ts | 34 +++++++ apps/worker/test/notify-template.test.ts | 12 ++- .../test/public-homepage-compute.test.ts | 2 + apps/worker/test/public-status.test.ts | 2 + apps/worker/test/scheduled.test.ts | 43 +++++++++ .../test/snapshots-public-homepage.test.ts | 1 + ...snapshots-public-monitor-fragments.test.ts | 2 + packages/db/src/schema.ts | 2 + 27 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 apps/worker/migrations/0013_monitor_redirects_display_url.sql diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index e1377489..abed281b 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -106,6 +106,7 @@ export interface PublicMonitor { id: number; name: string; type: MonitorType; + display_url: string | null; group_name: string | null; group_sort_order: number; sort_order: number; @@ -175,6 +176,7 @@ export interface HomepageMonitorCard { id: number; name: string; type: MonitorType; + display_url: string | null; group_name: string | null; status: MonitorStatus; is_stale: boolean; @@ -343,6 +345,7 @@ export interface AdminMonitor { name: string; type: MonitorType; target: string; + display_url: string | null; group_name: string | null; group_sort_order: number; sort_order: number; @@ -352,6 +355,7 @@ export interface AdminMonitor { http_method: string | null; http_headers_json: Record | null; http_body: string | null; + follow_redirects: boolean; expected_status_json: number[] | null; response_keyword: string | null; response_keyword_mode: HttpResponseMatchMode | null; @@ -372,6 +376,7 @@ export interface CreateMonitorInput { name: string; type: MonitorType; target: string; + display_url?: string | null; group_name?: string; group_sort_order?: number; sort_order?: number; @@ -381,6 +386,7 @@ export interface CreateMonitorInput { http_method?: string; http_headers_json?: Record; http_body?: string; + follow_redirects?: boolean; expected_status_json?: number[]; response_keyword?: string; response_keyword_mode?: HttpResponseMatchMode; @@ -392,6 +398,7 @@ export interface CreateMonitorInput { export interface PatchMonitorInput { name?: string; target?: string; + display_url?: string | null; group_name?: string | null; group_sort_order?: number; sort_order?: number; @@ -401,6 +408,7 @@ export interface PatchMonitorInput { http_method?: string; http_headers_json?: Record | null; http_body?: string | null; + follow_redirects?: boolean; expected_status_json?: number[] | null; response_keyword?: string | null; response_keyword_mode?: HttpResponseMatchMode | null; diff --git a/apps/web/src/components/MonitorCard.tsx b/apps/web/src/components/MonitorCard.tsx index fd9389aa..8426196b 100644 --- a/apps/web/src/components/MonitorCard.tsx +++ b/apps/web/src/components/MonitorCard.tsx @@ -25,7 +25,16 @@ const AVAILABILITY_BARS = 60; type PublicMonitorLike = Pick< PublicMonitor, - 'id' | 'name' | 'type' | 'status' | 'is_stale' | 'last_checked_at' | 'heartbeats' | 'uptime_30d' | 'uptime_days' + | 'id' + | 'name' + | 'type' + | 'display_url' + | 'status' + | 'is_stale' + | 'last_checked_at' + | 'heartbeats' + | 'uptime_30d' + | 'uptime_days' >; type HomepageMonitorLike = Pick< @@ -33,6 +42,7 @@ type HomepageMonitorLike = Pick< | 'id' | 'name' | 'type' + | 'display_url' | 'status' | 'is_stale' | 'last_checked_at' @@ -140,6 +150,18 @@ export function MonitorCard({

{monitor.name}

+ {monitor.display_url && ( + event.stopPropagation()} + className="mt-0.5 block truncate text-xs text-slate-500 underline decoration-slate-300 underline-offset-2 hover:text-slate-900 dark:text-slate-400 dark:decoration-slate-600 dark:hover:text-slate-100" + title={monitor.display_url} + > + {monitor.display_url} + + )}
{monitor.type} {monitor.is_stale && ( diff --git a/apps/web/src/components/MonitorForm.tsx b/apps/web/src/components/MonitorForm.tsx index 9ffe588d..d43bde08 100644 --- a/apps/web/src/components/MonitorForm.tsx +++ b/apps/web/src/components/MonitorForm.tsx @@ -77,11 +77,12 @@ function hasAdvancedHttpConfig(monitor: AdminMonitor | undefined): boolean { !!monitor.http_headers_json && Object.keys(monitor.http_headers_json).length > 0; const hasExpected = !!monitor.expected_status_json && monitor.expected_status_json.length > 0; const hasBody = !!monitor.http_body && monitor.http_body.trim().length > 0; + const hasRedirectOverride = monitor.follow_redirects === false; const hasKw = !!monitor.response_keyword && monitor.response_keyword.trim().length > 0; const hasForbiddenKw = !!monitor.response_forbidden_keyword && monitor.response_forbidden_keyword.trim().length > 0; - return hasHeaders || hasExpected || hasBody || hasKw || hasForbiddenKw; + return hasHeaders || hasExpected || hasBody || hasRedirectOverride || hasKw || hasForbiddenKw; } function parseHeadersJson( @@ -182,6 +183,24 @@ function parseOptionalSortOrderInput( return { ok: true, value: n }; } +function parseOptionalDisplayUrlInput( + text: string, + t: TranslateFn, +): { ok: true; value: string | null } | { ok: false; error: string } { + const trimmed = text.trim(); + if (!trimmed) return { ok: true, value: null }; + + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { ok: false, error: t('monitor_form.error_display_url_protocol') }; + } + return { ok: true, value: trimmed }; + } catch { + return { ok: false, error: t('monitor_form.error_display_url_invalid') }; + } +} + function parseRegexPatternInput( pattern: string, mode: HttpResponseMatchMode, @@ -227,6 +246,7 @@ export function MonitorForm(props: CreateProps | EditProps) { const [showOnStatusPage, setShowOnStatusPage] = useState(monitor?.show_on_status_page ?? true); const [type, setType] = useState(monitor?.type ?? 'http'); const [target, setTarget] = useState(monitor?.target ?? ''); + const [displayUrl, setDisplayUrl] = useState(monitor?.display_url ?? ''); const [intervalSec, setIntervalSec] = useState(monitor?.interval_sec ?? 60); const [timeoutMs, setTimeoutMs] = useState(monitor?.timeout_ms ?? 10000); @@ -254,6 +274,9 @@ export function MonitorForm(props: CreateProps | EditProps) { const [httpBody, setHttpBody] = useState(() => monitor?.type === 'http' ? (monitor.http_body ?? '') : '', ); + const [followRedirects, setFollowRedirects] = useState(() => + monitor?.type === 'http' ? monitor.follow_redirects : true, + ); const [responseKeyword, setResponseKeyword] = useState(() => monitor?.type === 'http' ? (monitor.response_keyword ?? '') : '', ); @@ -271,6 +294,10 @@ export function MonitorForm(props: CreateProps | EditProps) { ); const headersParse = useMemo(() => parseHeadersJson(httpHeadersJson, t), [httpHeadersJson, t]); + const displayUrlParse = useMemo( + () => parseOptionalDisplayUrlInput(displayUrl, t), + [displayUrl, t], + ); const expectedStatusParse = useMemo( () => parseExpectedStatusInput(expectedStatusInput, t), [expectedStatusInput, t], @@ -291,6 +318,7 @@ export function MonitorForm(props: CreateProps | EditProps) { const canSubmit = name.trim().length > 0 && target.trim().length > 0 && + displayUrlParse.ok && groupSortOrderParse.ok && (type !== 'http' || !showAdvancedHttp || @@ -311,6 +339,7 @@ export function MonitorForm(props: CreateProps | EditProps) { show_on_status_page: showOnStatusPage, interval_sec: intervalSec, timeout_ms: timeoutMs, + display_url: displayUrlParse.ok ? displayUrlParse.value : null, }; if (monitor) { @@ -326,6 +355,8 @@ export function MonitorForm(props: CreateProps | EditProps) { data.http_method = httpMethod; if (showAdvancedHttp) { + data.follow_redirects = followRedirects; + if (headersParse.ok) { data.http_headers_json = Object.keys(headersParse.value).length > 0 ? headersParse.value : null; @@ -348,6 +379,7 @@ export function MonitorForm(props: CreateProps | EditProps) { data.http_headers_json = null; data.expected_status_json = null; data.http_body = null; + data.follow_redirects = true; data.response_keyword = null; data.response_keyword_mode = null; data.response_forbidden_keyword = null; @@ -369,6 +401,8 @@ export function MonitorForm(props: CreateProps | EditProps) { data.http_method = httpMethod; if (showAdvancedHttp) { + data.follow_redirects = followRedirects; + if (headersParse.ok && Object.keys(headersParse.value).length > 0) { data.http_headers_json = headersParse.value; } @@ -522,6 +556,23 @@ export function MonitorForm(props: CreateProps | EditProps) { />
+
+ + setDisplayUrl(e.target.value)} + placeholder={t('monitor_form.display_url_placeholder')} + className={inputClass} + /> + {!displayUrlParse.ok && ( +
+ {displayUrlParse.error} +
+ )} +
{t('monitor_form.display_url_help')}
+
+ {type === 'http' && (
@@ -576,6 +627,23 @@ export function MonitorForm(props: CreateProps | EditProps) { {showAdvancedHttp && (
+ +