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
+4
View File
@@ -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 {
+1
View File
@@ -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"`
}
+1
View File
@@ -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,
}
+1
View File
@@ -131,6 +131,7 @@ message MonitorSpec {
int32 tls_warn_days = 9;
int32 interval_sec = 10;
int32 retries = 11;
bool insecure = 12;
}
message SyncMonitorsRequest {
+4
View File
@@ -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 {
+1
View File
@@ -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"`
}
+1
View File
@@ -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,
})
+1
View File
@@ -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 {
+1
View File
@@ -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,
}
}
+58
View File
@@ -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 (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!monitor) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
}
return (
<div className="p-8">
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
{monitor.name}
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+3
View File
@@ -117,6 +117,9 @@ export default function MonitorDetailPage() {
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Disable" : "Enable"}
</Button>
+5 -192
View File
@@ -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<MonitorType>("http");
const [url, setUrl] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState<number>(443);
const [method, setMethod] = useState("GET");
const [expectedStatus, setExpectedStatus] = useState<number>(200);
const [keyword, setKeyword] = useState("");
const [tlsWarnDays, setTlsWarnDays] = useState<number>(14);
const [intervalSec, setIntervalSec] = useState<number>(60);
const [retries, setRetries] = useState<number>(1);
const [runner, setRunner] = useState("server");
const [enabled, setEnabled] = useState(true);
const [channelIds, setChannelIds] = useState<string[]>([]);
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 (
<div className="p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
@@ -71,150 +27,7 @@ export default function NewMonitorPage() {
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
<Card className="max-w-2xl">
<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>
</>
)}
{(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 as Error).message}</p>}
<div className="flex gap-3 pt-1">
<Button type="submit" variant="primary" loading={isPending}>
Create Monitor
</Button>
<Link href="/monitors">
<Button type="button" variant="ghost">
Cancel
</Button>
</Link>
</div>
</form>
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
+21 -8
View File
@@ -19,14 +19,15 @@ const CONFIG_FIELDS: Record<ChannelType, string[]> = {
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<ChannelType>("webhook");
const [config, setConfig] = useState<Record<string, string>>({});
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<ChannelType>(initial?.type ?? "webhook");
const [config, setConfig] = useState<Record<string, string>>(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 }) {
<form
onSubmit={(e) => {
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 && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
Add Channel
{initial ? "Save Changes" : "Add Channel"}
</Button>
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
@@ -89,6 +90,7 @@ function ChannelForm({ onDone }: { onDone: () => void }) {
function ChannelRow({ ch }: { ch: NotificationChannel }) {
const queryClient = useQueryClient();
const [testMsg, setTestMsg] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const { mutate: remove } = useMutation({
mutationFn: () => api.deleteChannel(ch.channel_id),
@@ -106,6 +108,14 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
if (editing) {
return (
<div className="border-b border-border p-4 last:border-0">
<ChannelForm initial={ch} onDone={() => setEditing(false)} />
</div>
);
}
return (
<div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-0">
<div>
@@ -120,6 +130,9 @@ function ChannelRow({ ch }: { ch: NotificationChannel }) {
<Button variant="ghost" size="sm" loading={testing} onClick={() => test()}>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => toggle(!ch.enabled)}>
{ch.enabled ? "Disable" : "Enable"}
</Button>
+200 -229
View File
@@ -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 (
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">
{icon}
</div>
<div>
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
</div>
{children}
</Card>
);
function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) {
return (
<Card className={className}>
<div className="mb-4 flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2 text-accent">{icon}</div>
<div>
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
{description && <p className="mt-0.5 text-sm text-text-secondary">{description}</p>}
</div>
</div>
{children}
</Card>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
return (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
function BellIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
);
}
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
);
}
function DocumentIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
);
}
function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedAt?: string }) {
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl =
typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/values";
const queryClient = useQueryClient();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const readUrl = typeof window !== "undefined" ? `${window.location.origin}/api/secrets/<group>/values` : "/api/secrets/<group>/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 (
<SectionCard
title="Secrets Read Token (ESO)"
description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token."
icon={<KeyIcon />}
>
<p className="mb-4 text-sm text-text-secondary">
Point your <span className="font-mono">ClusterSecretStore</span> at{" "}
<span className="font-mono text-text-primary">{readUrl}</span>.
</p>
return (
<SectionCard title="Secrets Read Token (ESO)" description="Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer token." icon={<KeyIcon />}>
<p className="mb-4 text-sm text-text-secondary">
Point your <span className="font-mono">ClusterSecretStore</span> at <span className="font-mono text-text-primary">{readUrl}</span>.
</p>
<div className="mb-4 flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
<div className="mb-4 flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${tokenSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{tokenSet ? "A read token is configured" : "No read token configured yet"}
{tokenSet && rotatedAt && ` · rotated ${new Date(rotatedAt).toLocaleString()}`}
</span>
</div>
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">Copy this token now it will not be shown again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
{token}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
{token && (
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 p-3">
<p className="mb-2 text-xs font-medium text-warning">Copy this token now it will not be shown again.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{token}</code>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
</div>
</div>
)}
<Button type="button" variant="primary" loading={isPending} onClick={() => rotate()}>
{tokenSet ? "Rotate Token" : "Generate Token"}
</Button>
{tokenSet && (
<p className="mt-2 text-xs text-text-tertiary">
Rotating invalidates the previous token. Update the Kubernetes secret afterwards.
</p>
)}
</SectionCard>
);
{tokenSet && <p className="mt-2 text-xs text-text-tertiary">Rotating invalidates the previous token. Update the Kubernetes secret afterwards.</p>}
</SectionCard>
);
}
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<typeof api.saveSettings>[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<typeof api.saveSettings>[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 (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</p>
</div>
<div className="space-y-6">
{/* Alerting — replaces the legacy webhook/email settings */}
<SectionCard title="Alerting" description="Alerts are now delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage Notification Channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View Monitors</Button>
</Link>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under Notification Channels and attached per monitor.
</p>
</SectionCard>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard title="Server Health" description="When to consider an agent-backed server offline." icon={<ServerIcon />}>
<Field label="Offline threshold (minutes)" hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum.">
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
<SectionCard title="Workflow Logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
</div>
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<SecretsTokenCard tokenSet={settings?.secrets?.read_token_set ?? false} rotatedAt={settings?.secrets?.rotated_at} />
</div>
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure monitoring, alerting, and integrations.</p>
</div>
<div className="max-w-5xl space-y-6">
{/* Alerting — replaces the legacy webhook/email settings */}
<SectionCard
title="Alerting"
description="Alerts are now delivered through notification channels, triggered by service monitors."
icon={<BellIcon />}
>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage Notification Channels</Button>
</Link>
<Link href="/monitors">
<Button variant="ghost">View Monitors</Button>
</Link>
</div>
<p className="mt-4 text-xs text-text-tertiary">
Webhook, email (SMTP), Discord, Slack, and Telegram destinations are configured under
Notification Channels and attached per monitor.
</p>
</SectionCard>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SectionCard
title="Server Health"
description="When to consider an agent-backed server offline."
icon={<ServerIcon />}
>
<Field
label="Offline threshold (minutes)"
hint="How long a server must be silent before being marked offline. Agents poll every 30s, so 5 minutes is a safe minimum."
>
<input
type="number"
min={1}
max={60}
value={thresholdMinutes}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
<SectionCard
title="Workflow Logs"
description="How long run logs are kept before automatic deletion."
icon={<DocumentIcon />}
>
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input
type="number"
min={0}
value={logRetentionDays}
onChange={(e) => 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"
/>
</Field>
</SectionCard>
</div>
<div className="mt-6 flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save Settings"}
</Button>
{saved && <span className="text-sm text-success">Settings saved successfully.</span>}
</div>
</form>
<SecretsTokenCard
tokenSet={settings?.secrets?.read_token_set ?? false}
rotatedAt={settings?.secrets?.rotated_at}
/>
</div>
</div>
);
}
+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>
);
}
+1
View File
@@ -45,6 +45,7 @@ export interface MonitorTarget {
expected_status?: number;
keyword?: string;
tls_warn_days?: number;
insecure?: boolean;
}
export interface MonitorState {