Skip to content
Closed
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
8 changes: 8 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -352,6 +355,7 @@ export interface AdminMonitor {
http_method: string | null;
http_headers_json: Record<string, string> | null;
http_body: string | null;
follow_redirects: boolean;
expected_status_json: number[] | null;
response_keyword: string | null;
response_keyword_mode: HttpResponseMatchMode | null;
Expand All @@ -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;
Expand All @@ -381,6 +386,7 @@ export interface CreateMonitorInput {
http_method?: string;
http_headers_json?: Record<string, string>;
http_body?: string;
follow_redirects?: boolean;
expected_status_json?: number[];
response_keyword?: string;
response_keyword_mode?: HttpResponseMatchMode;
Expand All @@ -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;
Expand All @@ -401,6 +408,7 @@ export interface PatchMonitorInput {
http_method?: string;
http_headers_json?: Record<string, string> | null;
http_body?: string | null;
follow_redirects?: boolean;
expected_status_json?: number[] | null;
response_keyword?: string | null;
response_keyword_mode?: HttpResponseMatchMode | null;
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/components/MonitorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,24 @@ 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<
HomepageMonitorCard,
| 'id'
| 'name'
| 'type'
| 'display_url'
| 'status'
| 'is_stale'
| 'last_checked_at'
Expand Down Expand Up @@ -140,6 +150,18 @@ export function MonitorCard({
<h3 className="truncate text-base font-semibold leading-tight text-slate-900 dark:text-slate-100">
{monitor.name}
</h3>
{monitor.display_url && (
<a
href={monitor.display_url}
target="_blank"
rel="noreferrer"
onClick={(event) => 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}
</a>
)}
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<span>{monitor.type}</span>
{monitor.is_stale && (
Expand Down
70 changes: 69 additions & 1 deletion apps/web/src/components/MonitorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -227,6 +246,7 @@ export function MonitorForm(props: CreateProps | EditProps) {
const [showOnStatusPage, setShowOnStatusPage] = useState(monitor?.show_on_status_page ?? true);
const [type, setType] = useState<MonitorType>(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);

Expand Down Expand Up @@ -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 ?? '') : '',
);
Expand All @@ -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],
Expand All @@ -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 ||
Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -522,6 +556,23 @@ export function MonitorForm(props: CreateProps | EditProps) {
/>
</div>

<div>
<label className={labelClass}>{t('monitor_form.display_url_optional')}</label>
<input
type="url"
value={displayUrl}
onChange={(e) => setDisplayUrl(e.target.value)}
placeholder={t('monitor_form.display_url_placeholder')}
className={inputClass}
/>
{!displayUrlParse.ok && (
<div className="mt-1 text-xs text-red-600 dark:text-red-400">
{displayUrlParse.error}
</div>
)}
<div className={FIELD_HELP_CLASS}>{t('monitor_form.display_url_help')}</div>
</div>

{type === 'http' && (
<div>
<label className={labelClass}>{t('monitor_form.method')}</label>
Expand Down Expand Up @@ -576,6 +627,23 @@ export function MonitorForm(props: CreateProps | EditProps) {

{showAdvancedHttp && (
<div className="mt-4 space-y-4">
<label className="flex items-start gap-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={followRedirects}
onChange={(e) => setFollowRedirects(e.target.checked)}
className="mt-1"
/>
<span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{t('monitor_form.follow_redirects')}
</span>
<span className={`mt-1 block ${FIELD_HELP_CLASS}`}>
{t('monitor_form.follow_redirects_help')}
</span>
</span>
</label>

<div>
<label className={labelClass}>{t('monitor_form.headers_optional')}</label>
<textarea
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const en = {
'common.type': 'Type',
'common.title_label': 'Title',
'common.url': 'URL',
'common.display_url': 'Display URL',
'common.impact': 'Impact',
'common.schedule': 'Schedule',
'common.state': 'State',
Expand Down Expand Up @@ -319,10 +320,17 @@ const en = {
'monitor_form.target_host_port': 'Host:Port',
'monitor_form.target_url_placeholder': 'https://example.com',
'monitor_form.target_host_port_placeholder': 'example.com:443',
'monitor_form.display_url_optional': 'Display URL (optional)',
'monitor_form.display_url_placeholder': 'https://example.com',
'monitor_form.display_url_help':
'Shown in admin views and default notifications when set. Leave empty to show no URL.',
'monitor_form.method': 'Method',
'monitor_form.interval_sec': 'Interval (sec)',
'monitor_form.timeout_ms': 'Timeout (ms)',
'monitor_form.advanced_http_options': 'Advanced HTTP options',
'monitor_form.follow_redirects': 'Follow redirects',
'monitor_form.follow_redirects_help':
'When off, the check stops at the first 3xx response. Add that status code below if it should count as up.',
'monitor_form.headers_optional': 'Headers (JSON, optional)',
'monitor_form.headers_placeholder': '{"Authorization":"Bearer ..."}',
'monitor_form.headers_help': 'Tip: set Content-Type here if you use a request body.',
Expand Down Expand Up @@ -352,6 +360,8 @@ const en = {
'monitor_form.error_expected_status_json_or_list':
'Expected status codes must be a JSON array like [200,204] or a list like "200, 204"',
'monitor_form.error_expected_status_must_array': 'Expected status codes must be an array',
'monitor_form.error_display_url_invalid': 'Display URL must be a valid URL',
'monitor_form.error_display_url_protocol': 'Display URL protocol must be http or https',
'monitor_form.error_regex_invalid': 'Invalid regex: {message}',

'notification_form.name': 'Name',
Expand Down Expand Up @@ -491,6 +501,7 @@ const zhCn: LocaleMessages = {
'common.type': '类型',
'common.title_label': '标题',
'common.url': 'URL',
'common.display_url': '展现网址',
'common.impact': '影响级别',
'common.schedule': '计划',
'common.state': '状态',
Expand Down Expand Up @@ -759,10 +770,17 @@ const zhCn: LocaleMessages = {
'monitor_form.target_host_port': '主机:端口',
'monitor_form.target_url_placeholder': 'https://example.com',
'monitor_form.target_host_port_placeholder': 'example.com:443',
'monitor_form.display_url_optional': '展现网址(可选)',
'monitor_form.display_url_placeholder': 'https://example.com',
'monitor_form.display_url_help':
'设置后用于后台展示和默认通知。留空时不展示任何网址。',
'monitor_form.method': '请求方法',
'monitor_form.interval_sec': '探测间隔(秒)',
'monitor_form.timeout_ms': '超时(毫秒)',
'monitor_form.advanced_http_options': '高级 HTTP 选项',
'monitor_form.follow_redirects': '跟随跳转',
'monitor_form.follow_redirects_help':
'关闭后,探测会停在第一个 3xx 响应;如需视为成功,请在下方期望状态码中加入对应 3xx。',
'monitor_form.headers_optional': '请求头(JSON,可选)',
'monitor_form.headers_placeholder': '{"Authorization":"Bearer ..."}',
'monitor_form.headers_help': '提示:如果要发送请求体,请在此设置 Content-Type。',
Expand Down Expand Up @@ -790,6 +808,8 @@ const zhCn: LocaleMessages = {
'monitor_form.error_expected_status_json_or_list':
'期望状态码必须是 JSON 数组(如 [200,204])或列表(如 "200, 204")',
'monitor_form.error_expected_status_must_array': '期望状态码必须是数组',
'monitor_form.error_display_url_invalid': '展现网址必须是合法 URL',
'monitor_form.error_display_url_protocol': '展现网址协议必须是 http 或 https',
'monitor_form.error_regex_invalid': '正则表达式无效:{message}',

'notification_form.name': '名称',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function homepageFromStatus(status: StatusResponse): PublicHomepageResponse {
id: monitor.id,
name: monitor.name,
type: monitor.type,
display_url: monitor.display_url ?? null,
group_name: monitor.group_name,
status: monitor.status,
is_stale: monitor.is_stale,
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1523,8 +1523,19 @@ export function AdminDashboard() {
<td className="px-3 sm:px-4 py-3">
<Badge variant="info">{m.type}</Badge>
</td>
<td className="max-w-[160px] truncate px-3 py-3 text-sm text-slate-500 dark:text-slate-400 sm:max-w-[220px] sm:px-4">
{m.target}
<td className="max-w-[160px] px-3 py-3 text-sm text-slate-500 dark:text-slate-400 sm:max-w-[220px] sm:px-4">
<div className="truncate">{m.target}</div>
{m.display_url && (
<a
href={m.display_url}
target="_blank"
rel="noreferrer"
className="mt-0.5 block truncate text-xs text-slate-700 underline decoration-slate-300 underline-offset-2 hover:text-slate-950 dark:text-slate-300 dark:decoration-slate-600 dark:hover:text-slate-50"
title={m.display_url}
>
{t('common.display_url')}: {m.display_url}
</a>
)}
</td>
<td className="px-3 sm:px-4 py-3">
<div className="flex items-center gap-2">
Expand Down
9 changes: 9 additions & 0 deletions apps/worker/migrations/0013_monitor_redirects_display_url.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Phase 18: monitor redirect handling and optional display URLs
-- NOTE: Keep this file append-only.

ALTER TABLE monitors
ADD COLUMN follow_redirects INTEGER NOT NULL DEFAULT 1
CHECK (follow_redirects IN (0, 1));

ALTER TABLE monitors
ADD COLUMN display_url TEXT;
2 changes: 2 additions & 0 deletions apps/worker/src/monitor/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type HttpCheckConfig = {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
headers: Record<string, string> | null;
body: string | null;
followRedirects: boolean;
expectedStatus: number[] | null;
responseKeyword: string | null;
responseKeywordMode: HttpResponseMatchMode | null;
Expand Down Expand Up @@ -137,6 +138,7 @@ async function attemptHttpCheck(
const init: RequestInit = {
method: config.method,
headers,
redirect: config.followRedirects ? 'follow' : 'manual',
cache: 'no-store',
cf: {
cacheTtlByStatus: { '100-599': -1 },
Expand Down
Loading
Loading