From 0464c540b26386114f62400cbd8f9c2c6f1ede5d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 21 Jul 2026 14:55:41 +0100 Subject: [PATCH] feat: edit monitors + notification channels; HTTP monitor insecure-TLS option --- agent/internal/checker/checker.go | 4 + agent/internal/grpc/pb/vantage.pb.go | 1 + agent/internal/monitors/monitors.go | 1 + proto/vantage/v1/vantage.proto | 1 + server/internal/checker/checker.go | 4 + server/internal/grpc/pb/vantage.pb.go | 1 + server/internal/grpc/server.go | 1 + server/internal/models/monitor.go | 1 + server/internal/services/monitors.go | 1 + web/app/monitors/[id]/edit/page.tsx | 58 ++++ web/app/monitors/[id]/page.tsx | 3 + web/app/monitors/new/page.tsx | 197 +---------- web/app/settings/notifications/page.tsx | 29 +- web/app/settings/page.tsx | 429 +++++++++++------------- web/components/monitors/MonitorForm.tsx | 215 ++++++++++++ web/lib/api.ts | 1 + 16 files changed, 518 insertions(+), 429 deletions(-) create mode 100644 web/app/monitors/[id]/edit/page.tsx create mode 100644 web/components/monitors/MonitorForm.tsx diff --git a/agent/internal/checker/checker.go b/agent/internal/checker/checker.go index c4d49dd..547f165 100644 --- a/agent/internal/checker/checker.go +++ b/agent/internal/checker/checker.go @@ -34,6 +34,7 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int + Insecure bool // skip TLS certificate verification (HTTP checks) TimeoutSec int } @@ -79,6 +80,9 @@ func runHTTP(ctx context.Context, s Spec) Result { expect = 200 } client := &http.Client{Timeout: s.timeout()} + if s.Insecure { + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor + } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) if err != nil { diff --git a/agent/internal/grpc/pb/vantage.pb.go b/agent/internal/grpc/pb/vantage.pb.go index 723b59e..2cb692d 100644 --- a/agent/internal/grpc/pb/vantage.pb.go +++ b/agent/internal/grpc/pb/vantage.pb.go @@ -104,6 +104,7 @@ type MonitorSpec struct { ExpectedStatus int `json:"expected_status,omitempty"` Keyword string `json:"keyword,omitempty"` TLSWarnDays int `json:"tls_warn_days,omitempty"` + Insecure bool `json:"insecure,omitempty"` IntervalSec int `json:"interval_sec"` Retries int `json:"retries"` } diff --git a/agent/internal/monitors/monitors.go b/agent/internal/monitors/monitors.go index 69f76a4..242259e 100644 --- a/agent/internal/monitors/monitors.go +++ b/agent/internal/monitors/monitors.go @@ -99,6 +99,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) { ExpectedStatus: s.ExpectedStatus, Keyword: s.Keyword, TLSWarnDays: s.TLSWarnDays, + Insecure: s.Insecure, TimeoutSec: s.IntervalSec, } diff --git a/proto/vantage/v1/vantage.proto b/proto/vantage/v1/vantage.proto index e5fda50..9cf22ec 100644 --- a/proto/vantage/v1/vantage.proto +++ b/proto/vantage/v1/vantage.proto @@ -131,6 +131,7 @@ message MonitorSpec { int32 tls_warn_days = 9; int32 interval_sec = 10; int32 retries = 11; + bool insecure = 12; } message SyncMonitorsRequest { diff --git a/server/internal/checker/checker.go b/server/internal/checker/checker.go index c4d49dd..547f165 100644 --- a/server/internal/checker/checker.go +++ b/server/internal/checker/checker.go @@ -34,6 +34,7 @@ type Spec struct { ExpectedStatus int Keyword string TLSWarnDays int + Insecure bool // skip TLS certificate verification (HTTP checks) TimeoutSec int } @@ -79,6 +80,9 @@ func runHTTP(ctx context.Context, s Spec) Result { expect = 200 } client := &http.Client{Timeout: s.timeout()} + if s.Insecure { + client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor + } start := time.Now() req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) if err != nil { diff --git a/server/internal/grpc/pb/vantage.pb.go b/server/internal/grpc/pb/vantage.pb.go index 3cf3882..6dcb766 100644 --- a/server/internal/grpc/pb/vantage.pb.go +++ b/server/internal/grpc/pb/vantage.pb.go @@ -107,6 +107,7 @@ type MonitorSpec struct { ExpectedStatus int `json:"expected_status,omitempty"` Keyword string `json:"keyword,omitempty"` TLSWarnDays int `json:"tls_warn_days,omitempty"` + Insecure bool `json:"insecure,omitempty"` IntervalSec int `json:"interval_sec"` Retries int `json:"retries"` } diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index e7888b6..3351998 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -128,6 +128,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe ExpectedStatus: m.Target.ExpectedStatus, Keyword: m.Target.Keyword, TLSWarnDays: m.Target.TLSWarnDays, + Insecure: m.Target.Insecure, IntervalSec: m.IntervalSec, Retries: m.Retries, }) diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 78b9978..472df32 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -33,6 +33,7 @@ type MonitorTarget struct { ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"` Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"` TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"` + Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` // skip TLS cert verification (HTTP monitors) } type MonitorState struct { diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index 992d414..b72d146 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -31,6 +31,7 @@ func SpecFor(m *models.Monitor) checker.Spec { ExpectedStatus: m.Target.ExpectedStatus, Keyword: m.Target.Keyword, TLSWarnDays: m.Target.TLSWarnDays, + Insecure: m.Target.Insecure, TimeoutSec: m.IntervalSec, } } diff --git a/web/app/monitors/[id]/edit/page.tsx b/web/app/monitors/[id]/edit/page.tsx new file mode 100644 index 0000000..8a6b3f8 --- /dev/null +++ b/web/app/monitors/[id]/edit/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { api, MonitorInput } from "@/lib/api"; +import { Card } from "@/components/ui"; +import { MonitorForm } from "@/components/monitors/MonitorForm"; + +export default function EditMonitorPage() { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const monitorId = params.id as string; + + const { data: monitor, isLoading } = useQuery({ + queryKey: ["monitors", monitorId], + queryFn: () => api.getMonitor(monitorId), + }); + + const { mutate: update, isPending, error } = useMutation({ + mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["monitors"] }); + queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }); + router.push(`/monitors/${monitorId}`); + }, + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!monitor) { + return ( +
+
Monitor not found.
+
+ ); + } + + return ( +
+ + ← {monitor.name} + +

Edit Monitor

+ + + + +
+ ); +} diff --git a/web/app/monitors/[id]/page.tsx b/web/app/monitors/[id]/page.tsx index fd8aa31..e4e24f4 100644 --- a/web/app/monitors/[id]/page.tsx +++ b/web/app/monitors/[id]/page.tsx @@ -117,6 +117,9 @@ export default function MonitorDetailPage() { {monitor.state.message &&

{monitor.state.message}

}
+ + + diff --git a/web/app/monitors/new/page.tsx b/web/app/monitors/new/page.tsx index 1c60a1e..bc536f2 100644 --- a/web/app/monitors/new/page.tsx +++ b/web/app/monitors/new/page.tsx @@ -1,39 +1,16 @@ "use client"; -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { api, MonitorInput, MonitorType } from "@/lib/api"; -import { Button, Card } from "@/components/ui"; - -const inputClass = - "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; - -const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary"; +import { api, MonitorInput } from "@/lib/api"; +import { Card } from "@/components/ui"; +import { MonitorForm } from "@/components/monitors/MonitorForm"; export default function NewMonitorPage() { const router = useRouter(); const queryClient = useQueryClient(); - const [name, setName] = useState(""); - const [type, setType] = useState("http"); - const [url, setUrl] = useState(""); - const [host, setHost] = useState(""); - const [port, setPort] = useState(443); - const [method, setMethod] = useState("GET"); - const [expectedStatus, setExpectedStatus] = useState(200); - const [keyword, setKeyword] = useState(""); - const [tlsWarnDays, setTlsWarnDays] = useState(14); - const [intervalSec, setIntervalSec] = useState(60); - const [retries, setRetries] = useState(1); - const [runner, setRunner] = useState("server"); - const [enabled, setEnabled] = useState(true); - const [channelIds, setChannelIds] = useState([]); - - const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); - const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() }); - const { mutate: create, isPending, error } = useMutation({ mutationFn: (input: MonitorInput) => api.createMonitor(input), onSuccess: (m) => { @@ -42,27 +19,6 @@ export default function NewMonitorPage() { }, }); - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const target: MonitorInput["target"] = {}; - if (type === "http") { - target.url = url; - target.method = method; - target.expected_status = expectedStatus; - if (keyword) target.keyword = keyword; - } else if (type === "tls") { - target.host = host; - target.port = port || 443; - target.tls_warn_days = tlsWarnDays; - } else if (type === "icmp") { - target.host = host; - } else { - target.host = host; - target.port = port; - } - create({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds }); - } - return (
@@ -71,150 +27,7 @@ export default function NewMonitorPage() {

New Monitor

-
-
- - setName(e.target.value)} placeholder="e.g. API health" required /> -
- -
- -
- {(["http", "tcp", "icmp", "tls"] as const).map((t) => ( - - ))} -
-
- - {type === "http" && ( - <> -
- - setUrl(e.target.value)} placeholder="https://example.com/health" required /> -
-
-
- - -
-
- - setExpectedStatus(Number(e.target.value))} /> -
-
-
- - setKeyword(e.target.value)} placeholder="e.g. ok" /> -
- - )} - - {(type === "tcp" || type === "tls" || type === "icmp") && ( -
-
- - setHost(e.target.value)} placeholder="example.com" required /> -
- {type !== "icmp" && ( -
- - setPort(Number(e.target.value))} /> -
- )} -
- )} - - {type === "tls" && ( -
- - setTlsWarnDays(Number(e.target.value))} /> -
- )} - -
-
- - setIntervalSec(Number(e.target.value))} min={10} /> -
-
- - setRetries(Number(e.target.value))} min={1} /> -
-
- -
- - -

Agent-run monitors require the agent monitor scheduler (P2).

-
- -
- - {!channels || channels.length === 0 ? ( -

- No channels yet.{" "} - - Add one - - . -

- ) : ( -
- {channels.map((ch) => ( - - ))} -
- )} -
- - - - {error &&

{(error as Error).message}

} - -
- - - - -
-
+
); diff --git a/web/app/settings/notifications/page.tsx b/web/app/settings/notifications/page.tsx index 6ba84c3..5915f2a 100644 --- a/web/app/settings/notifications/page.tsx +++ b/web/app/settings/notifications/page.tsx @@ -19,14 +19,15 @@ const CONFIG_FIELDS: Record = { smtp: ["host", "port", "username", "password", "from", "to"], }; -function ChannelForm({ onDone }: { onDone: () => void }) { +function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) { const queryClient = useQueryClient(); - const [name, setName] = useState(""); - const [type, setType] = useState("webhook"); - const [config, setConfig] = useState>({}); + const [name, setName] = useState(initial?.name ?? ""); + const [type, setType] = useState(initial?.type ?? "webhook"); + const [config, setConfig] = useState>(initial?.config ?? {}); - const { mutate: create, isPending, error } = useMutation({ - mutationFn: (input: ChannelInput) => api.createChannel(input), + const { mutate: submit, isPending, error } = useMutation({ + mutationFn: (input: ChannelInput) => + initial ? api.updateChannel(initial.channel_id, input) : api.createChannel(input).then(() => undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["channels"] }); onDone(); @@ -37,7 +38,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) {
{ e.preventDefault(); - create({ name, type, config, enabled: true }); + submit({ name, type, config, enabled: initial?.enabled ?? true }); }} className="space-y-4" > @@ -76,7 +77,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) { {error &&

{(error as Error).message}

}
+ diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx index 5eb07e6..4fd7cc6 100644 --- a/web/app/settings/page.tsx +++ b/web/app/settings/page.tsx @@ -6,268 +6,239 @@ import Link from "next/link"; import { api } from "@/lib/api"; import { Button, Card } from "@/components/ui"; -function SectionCard({ - title, - description, - icon, - children, - className, -}: { - title: string; - description?: string; - icon: React.ReactNode; - children: React.ReactNode; - className?: string; -}) { - return ( - -
-
- {icon} -
-
-

{title}

- {description &&

{description}

} -
-
- {children} -
- ); +function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) { + return ( + +
+
{icon}
+
+

{title}

+ {description &&

{description}

} +
+
+ {children} +
+ ); } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); } function BellIcon() { - return ( - - - - ); + return ( + + + + ); } function ServerIcon() { - return ( - - - - ); + return ( + + + + ); } function DocumentIcon() { - return ( - - - - ); + return ( + + + + ); } function KeyIcon() { - return ( - - - - ); + return ( + + + + ); } function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) { - const queryClient = useQueryClient(); - const [token, setToken] = useState(null); - const [copied, setCopied] = useState(false); - const readUrl = - typeof window !== "undefined" ? `${window.location.origin}/api/secrets//values` : "/api/secrets//values"; + const queryClient = useQueryClient(); + const [token, setToken] = useState(null); + const [copied, setCopied] = useState(false); + const readUrl = typeof window !== "undefined" ? `${window.location.origin}/api/secrets//values` : "/api/secrets//values"; - const { mutate: rotate, isPending } = useMutation({ - mutationFn: api.rotateSecretsToken, - onSuccess: (res) => { - setToken(res.token); - queryClient.invalidateQueries({ queryKey: ["settings"] }); - }, - }); + const { mutate: rotate, isPending } = useMutation({ + mutationFn: api.rotateSecretsToken, + onSuccess: (res) => { + setToken(res.token); + queryClient.invalidateQueries({ queryKey: ["settings"] }); + }, + }); - async function copy() { - if (!token) return; - await navigator.clipboard.writeText(token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } + async function copy() { + if (!token) return; + await navigator.clipboard.writeText(token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } - return ( - } - > -

- Point your ClusterSecretStore at{" "} - {readUrl}. -

+ return ( + }> +

+ Point your ClusterSecretStore at {readUrl}. +

-
- - - {tokenSet ? "A read token is configured" : "No read token configured yet"} - {tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`} - -
+
+ + + {tokenSet ? "A read token is configured" : "No read token configured yet"} + {tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`} + +
- {token && ( -
-

Copy this token now — it will not be shown again.

-
- - {token} - - +
+
+ )} + + -
-
- )} - - - {tokenSet && ( -

- Rotating invalidates the previous token. Update the Kubernetes secret afterwards. -

- )} - - ); + {tokenSet &&

Rotating invalidates the previous token. Update the Kubernetes secret afterwards.

} + + ); } export default function SettingsPage() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient(); - const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings }); + const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings }); - const [thresholdMinutes, setThresholdMinutes] = useState(5); - const [logRetentionDays, setLogRetentionDays] = useState(30); - const [saved, setSaved] = useState(false); + const [thresholdMinutes, setThresholdMinutes] = useState(5); + const [logRetentionDays, setLogRetentionDays] = useState(30); + const [saved, setSaved] = useState(false); - useEffect(() => { - if (!settings) return; - setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5); - setLogRetentionDays(settings.workflow_log_retention_days ?? 30); - }, [settings]); + useEffect(() => { + if (!settings) return; + setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5); + setLogRetentionDays(settings.workflow_log_retention_days ?? 30); + }, [settings]); - const { mutate: save, isPending } = useMutation({ - mutationFn: (payload: Parameters[0]) => api.saveSettings(payload), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["settings"] }); - setSaved(true); - setTimeout(() => setSaved(false), 3000); - }, - }); - - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!settings) return; - // Preserve legacy alert/email values (managed via Notification Channels now); - // only the offline threshold and log retention are edited here. - save({ - alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes }, - email: settings.email, - workflow_log_retention_days: logRetentionDays, + const { mutate: save, isPending } = useMutation({ + mutationFn: (payload: Parameters[0]) => api.saveSettings(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings"] }); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }, }); - } - if (isLoading) { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!settings) return; + // Preserve legacy alert/email values (managed via Notification Channels now); + // only the offline threshold and log retention are edited here. + save({ + alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes }, + email: settings.email, + workflow_log_retention_days: logRetentionDays, + }); + } + + if (isLoading) { + return ( +
+
+
+ ); + } + return ( -
-
-
+
+
+

Settings

+

Configure monitoring, alerting, and integrations.

+
+ +
+ {/* Alerting — replaces the legacy webhook/email settings */} + }> +
+ + + + + + +
+

+ Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor. +

+
+ + +
+ }> + + setThresholdMinutes(Number(e.target.value))} + className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> + + + + }> + + setLogRetentionDays(Number(e.target.value))} + className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> + + +
+ +
+ + {saved && Settings saved successfully.} +
+ + + +
+
); - } - - return ( -
-
-

Settings

-

Configure monitoring, alerting, and integrations.

-
- -
- {/* Alerting — replaces the legacy webhook/email settings */} - } - > -
- - - - - - -
-

- Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under - Notification Channels and attached per monitor. -

-
- -
-
- } - > - - setThresholdMinutes(Number(e.target.value))} - className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" - /> - - - - } - > - - setLogRetentionDays(Number(e.target.value))} - className="w-32 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" - /> - - -
- -
- - {saved && Settings saved successfully.} -
-
- - -
-
- ); } diff --git a/web/components/monitors/MonitorForm.tsx b/web/components/monitors/MonitorForm.tsx new file mode 100644 index 0000000..3530f01 --- /dev/null +++ b/web/components/monitors/MonitorForm.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { api, Monitor, MonitorInput, MonitorType } from "@/lib/api"; +import { Button } from "@/components/ui"; + +const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; +const labelClass = "mb-1.5 block text-sm font-medium text-text-secondary"; + +export function MonitorForm({ + initial, + submitLabel, + onSubmit, + isPending, + error, +}: { + initial?: Monitor; + submitLabel: string; + onSubmit: (input: MonitorInput) => void; + isPending: boolean; + error?: Error | null; +}) { + const [name, setName] = useState(initial?.name ?? ""); + const [type, setType] = useState(initial?.type ?? "http"); + const [url, setUrl] = useState(initial?.target.url ?? ""); + const [host, setHost] = useState(initial?.target.host ?? ""); + const [port, setPort] = useState(initial?.target.port ?? 443); + const [method, setMethod] = useState(initial?.target.method ?? "GET"); + const [expectedStatus, setExpectedStatus] = useState(initial?.target.expected_status ?? 200); + const [keyword, setKeyword] = useState(initial?.target.keyword ?? ""); + const [tlsWarnDays, setTlsWarnDays] = useState(initial?.target.tls_warn_days ?? 14); + const [insecure, setInsecure] = useState(initial?.target.insecure ?? false); + const [intervalSec, setIntervalSec] = useState(initial?.interval_sec ?? 60); + const [retries, setRetries] = useState(initial?.retries ?? 1); + const [runner, setRunner] = useState(initial?.runner ?? "server"); + const [enabled, setEnabled] = useState(initial?.enabled ?? true); + const [channelIds, setChannelIds] = useState(initial?.channel_ids ?? []); + + const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() }); + const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const target: MonitorInput["target"] = {}; + if (type === "http") { + target.url = url; + target.method = method; + target.expected_status = expectedStatus; + if (keyword) target.keyword = keyword; + target.insecure = insecure; + } else if (type === "tls") { + target.host = host; + target.port = port || 443; + target.tls_warn_days = tlsWarnDays; + } else if (type === "icmp") { + target.host = host; + } else { + target.host = host; + target.port = port; + } + onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds }); + } + + return ( +
+
+ + setName(e.target.value)} placeholder="e.g. API health" required /> +
+ +
+ +
+ {(["http", "tcp", "icmp", "tls"] as const).map((t) => ( + + ))} +
+
+ + {type === "http" && ( + <> +
+ + setUrl(e.target.value)} placeholder="https://example.com/health" required /> +
+
+
+ + +
+
+ + setExpectedStatus(Number(e.target.value))} /> +
+
+
+ + setKeyword(e.target.value)} placeholder="e.g. ok" /> +
+ + + )} + + {(type === "tcp" || type === "tls" || type === "icmp") && ( +
+
+ + setHost(e.target.value)} placeholder="example.com" required /> +
+ {type !== "icmp" && ( +
+ + setPort(Number(e.target.value))} /> +
+ )} +
+ )} + + {type === "tls" && ( +
+ + setTlsWarnDays(Number(e.target.value))} /> +
+ )} + +
+
+ + setIntervalSec(Number(e.target.value))} min={10} /> +
+
+ + setRetries(Number(e.target.value))} min={1} /> +
+
+ +
+ + +

Agent-run monitors require the agent monitor scheduler (P2).

+
+ +
+ + {!channels || channels.length === 0 ? ( +

+ No channels yet.{" "} + + Add one + + . +

+ ) : ( +
+ {channels.map((ch) => ( + + ))} +
+ )} +
+ + + + {error &&

{error.message}

} + +
+ + + + +
+
+ ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 0d75145..b57f50d 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -45,6 +45,7 @@ export interface MonitorTarget { expected_status?: number; keyword?: string; tls_warn_days?: number; + insecure?: boolean; } export interface MonitorState {