feat: edit monitors + notification channels; HTTP monitor insecure-TLS option
Server Deploy / deploy (push) Successful in 2m9s
Agent Release / build (push) Successful in 10m39s
Agent Release / msi (push) Successful in 35s

This commit is contained in:
2026-07-21 14:55:41 +01:00
parent 45178d455e
commit 0464c540b2
16 changed files with 518 additions and 429 deletions
+215
View File
@@ -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<MonitorType>(initial?.type ?? "http");
const [url, setUrl] = useState(initial?.target.url ?? "");
const [host, setHost] = useState(initial?.target.host ?? "");
const [port, setPort] = useState<number>(initial?.target.port ?? 443);
const [method, setMethod] = useState(initial?.target.method ?? "GET");
const [expectedStatus, setExpectedStatus] = useState<number>(initial?.target.expected_status ?? 200);
const [keyword, setKeyword] = useState(initial?.target.keyword ?? "");
const [tlsWarnDays, setTlsWarnDays] = useState<number>(initial?.target.tls_warn_days ?? 14);
const [insecure, setInsecure] = useState<boolean>(initial?.target.insecure ?? false);
const [intervalSec, setIntervalSec] = useState<number>(initial?.interval_sec ?? 60);
const [retries, setRetries] = useState<number>(initial?.retries ?? 1);
const [runner, setRunner] = useState(initial?.runner ?? "server");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [channelIds, setChannelIds] = useState<string[]>(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 (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. API health" required />
</div>
<div>
<label className={labelClass}>Type</label>
<div className="grid grid-cols-4 gap-2">
{(["http", "tcp", "icmp", "tls"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
type === t ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{t.toUpperCase()}
</button>
))}
</div>
</div>
{type === "http" && (
<>
<div>
<label className={labelClass}>URL</label>
<input className={inputClass} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/health" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Method</label>
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
<option>GET</option>
<option>HEAD</option>
</select>
</div>
<div>
<label className={labelClass}>Expected status</label>
<input type="number" className={inputClass} value={expectedStatus} onChange={(e) => setExpectedStatus(Number(e.target.value))} />
</div>
</div>
<div>
<label className={labelClass}>Keyword (optional, body must contain)</label>
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="e.g. ok" />
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={insecure} onChange={(e) => setInsecure(e.target.checked)} />
Ignore TLS certificate errors (self-signed / expired)
</label>
</>
)}
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Host</label>
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} placeholder="example.com" required />
</div>
{type !== "icmp" && (
<div>
<label className={labelClass}>Port</label>
<input type="number" className={inputClass} value={port} onChange={(e) => setPort(Number(e.target.value))} />
</div>
)}
</div>
)}
{type === "tls" && (
<div>
<label className={labelClass}>Warn days before expiry</label>
<input type="number" className={inputClass} value={tlsWarnDays} onChange={(e) => setTlsWarnDays(Number(e.target.value))} />
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Interval (seconds)</label>
<input type="number" className={inputClass} value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} min={10} />
</div>
<div>
<label className={labelClass}>Retries before down</label>
<input type="number" className={inputClass} value={retries} onChange={(e) => setRetries(Number(e.target.value))} min={1} />
</div>
</div>
<div>
<label className={labelClass}>Runner</label>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Server (central)</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
<p className="mt-1 text-xs text-text-tertiary">Agent-run monitors require the agent monitor scheduler (P2).</p>
</div>
<div>
<label className={labelClass}>Notification channels</label>
{!channels || channels.length === 0 ? (
<p className="text-xs text-text-tertiary">
No channels yet.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Add one
</Link>
.
</p>
) : (
<div className="space-y-1.5">
{channels.map((ch) => (
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={channelIds.includes(ch.channel_id)}
onChange={(e) =>
setChannelIds((prev) => (e.target.checked ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
}
/>
{ch.name} <span className="text-text-tertiary">({ch.type})</span>
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Enabled
</label>
{error && <p className="text-sm text-danger">{error.message}</p>}
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending}>
{submitLabel}
</Button>
<Link href="/monitors">
<Button type="button" variant="ghost">
Cancel
</Button>
</Link>
</div>
</form>
);
}