Compare commits

...
Author SHA1 Message Date
mrhid6 0464c540b2 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
2026-07-21 14:55:41 +01:00
mrhid6 45178d455e fix(server): SMTP dispatch dial timeout + implicit/STARTTLS handling
Server Deploy / deploy (push) Successful in 2m19s
2026-07-21 14:46:15 +01:00
mrhid6 f28ab1a741 feat(web): redesign settings page, drop legacy webhook/email alerting UI
Server Deploy / deploy (push) Successful in 1m24s
2026-07-21 14:40:36 +01:00
mrhid6 df6f8b6f62 feat(web): notification channel settings UI
Server Deploy / deploy (push) Successful in 1m24s
2026-07-21 14:24:38 +01:00
mrhid6 8f3a27100f feat(server): multi-channel monitor notifications
Server Deploy / deploy (push) Successful in 1m16s
2026-07-21 14:22:55 +01:00
24 changed files with 1251 additions and 568 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 {
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
)
func registerChannelRoutes(g *gin.RouterGroup) {
g.GET("/channels", listChannels)
g.POST("/channels", createChannel)
g.PUT("/channels/:id", updateChannel)
g.DELETE("/channels/:id", deleteChannel)
g.POST("/channels/:id/test", testChannel)
}
func listChannels(c *gin.Context) {
channels, err := services.ListChannels()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, channels)
}
func createChannel(c *gin.Context) {
var ch models.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ch.Name == "" || ch.Type == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateChannel(&ch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func updateChannel(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Type *string `json:"type"`
Config *map[string]string `json:"config"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
upd := bson.M{}
if body.Name != nil {
upd["name"] = *body.Name
}
if body.Type != nil {
upd["type"] = *body.Type
}
if body.Config != nil {
upd["config"] = *body.Config
}
if body.Enabled != nil {
upd["enabled"] = *body.Enabled
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateChannel(c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func deleteChannel(c *gin.Context) {
if err := services.DeleteChannel(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func testChannel(c *gin.Context) {
if err := services.TestChannel(c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
+1
View File
@@ -81,6 +81,7 @@ func RegisterRoutes(r *gin.Engine) {
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
}
}
+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,
})
+29
View File
@@ -0,0 +1,29 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Notification channel types.
const (
ChannelWebhook = "webhook"
ChannelSMTP = "smtp"
ChannelDiscord = "discord"
ChannelSlack = "slack"
ChannelTelegram = "telegram"
)
// NotificationChannel is an outbound alert destination. Config holds
// type-specific settings (e.g. url; or smtp host/port/username/password/from/to;
// or telegram token/chat_id).
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ChannelID string `bson:"channel_id" json:"channel_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
Config map[string]string `bson:"config" json:"config"`
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+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 {
+64
View File
@@ -0,0 +1,64 @@
// Package notify formats and delivers monitor state-change alerts to
// notification channels. It depends only on models so services can call it
// without an import cycle.
package notify
import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
// Event describes a monitor state transition worth alerting on.
type Event struct {
MonitorName string
Type string
OldStatus string
NewStatus string
Message string
Time time.Time
}
// title is a short one-line summary used by the text-based channels.
func (e Event) title() string {
verb := "recovered"
if e.NewStatus == models.StatusDown {
verb = "is DOWN"
}
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
if e.Message != "" {
s += ": " + e.Message
}
return s
}
// Dispatch delivers ev to a single channel, formatting per channel type.
func Dispatch(ch models.NotificationChannel, ev Event) error {
switch ch.Type {
case models.ChannelWebhook:
return dispatchWebhook(ch, ev)
case models.ChannelDiscord:
return dispatchDiscord(ch, ev)
case models.ChannelSlack:
return dispatchSlack(ch, ev)
case models.ChannelTelegram:
return dispatchTelegram(ch, ev)
case models.ChannelSMTP:
return dispatchSMTP(ch, ev)
default:
return fmt.Errorf("unknown channel type: %s", ch.Type)
}
}
// Test delivers a synthetic event so users can verify a channel's configuration.
func Test(ch models.NotificationChannel) error {
return Dispatch(ch, Event{
MonitorName: "Test monitor",
Type: "http",
OldStatus: models.StatusUp,
NewStatus: models.StatusDown,
Message: "this is a test alert from Vantage",
Time: time.Now(),
})
}
+71
View File
@@ -0,0 +1,71 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
var httpClient = &http.Client{Timeout: 10 * time.Second}
func postJSON(target string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(target, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, target)
}
return nil
}
// dispatchWebhook posts the full event as JSON to a user-supplied URL.
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("webhook: missing url")
}
return postJSON(target, map[string]any{
"monitor": ev.MonitorName,
"type": ev.Type,
"old_status": ev.OldStatus,
"new_status": ev.NewStatus,
"message": ev.Message,
"time": ev.Time.Format(time.RFC3339),
})
}
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("discord: missing url")
}
return postJSON(target, map[string]string{"content": ev.title()})
}
func dispatchSlack(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("slack: missing url")
}
return postJSON(target, map[string]string{"text": ev.title()})
}
func dispatchTelegram(ch models.NotificationChannel, ev Event) error {
token := ch.Config["token"]
chatID := ch.Config["chat_id"]
if token == "" || chatID == "" {
return fmt.Errorf("telegram: missing token or chat_id")
}
api := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
return postJSON(api, map[string]string{"chat_id": chatID, "text": ev.title()})
}
+106
View File
@@ -0,0 +1,106 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
const smtpTimeout = 15 * time.Second
// dispatchSMTP sends the alert as a plain-text email. Config keys: host, port,
// username, password, from, to. Auth is skipped when username is empty. Port 465
// uses implicit TLS; other ports use STARTTLS when the server advertises it.
//
// It dials with a timeout and sets a connection deadline so an unreachable or
// misconfigured SMTP host fails fast instead of hanging the request until the OS
// TCP timeout (which resets the upstream proxy connection).
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
host := ch.Config["host"]
port := ch.Config["port"]
from := ch.Config["from"]
to := ch.Config["to"]
if host == "" || port == "" || from == "" || to == "" {
return fmt.Errorf("smtp: missing host/port/from/to")
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
// Implicit TLS on 465; otherwise start plain and upgrade via STARTTLS.
if port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: host})
}
c, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer c.Close()
if port != "465" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if user := ch.Config["username"]; user != "" {
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
recipients := strings.Split(to, ",")
for i := range recipients {
recipients[i] = strings.TrimSpace(recipients[i])
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients {
if rcpt == "" {
continue
}
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
title := ev.title()
msg := strings.Join([]string{
"From: " + from,
"To: " + to,
"Subject: " + title,
"",
title,
"",
"Monitor: " + ev.MonitorName,
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
"Time: " + ev.Time.String(),
}, "\r\n")
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return c.Quit()
}
+100
View File
@@ -0,0 +1,100 @@
package services
import (
"errors"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func ListChannels() ([]models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
var out []models.NotificationChannel
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func GetChannel(channelID string) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
var ch models.NotificationChannel
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ch, nil
}
// GetChannels loads multiple channels by ID, skipping any not found.
func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) {
if len(channelIDs) == 0 {
return nil, nil
}
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"channel_id": bson.M{"$in": channelIDs}})
if err != nil {
return nil, err
}
var out []models.NotificationChannel
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
ch.ChannelID = uuid.NewString()
ch.CreatedAt = time.Now()
if ch.Config == nil {
ch.Config = map[string]string{}
}
if _, err := db.Col("notification_channels").InsertOne(ctx, ch); err != nil {
return nil, err
}
return ch, nil
}
func UpdateChannel(channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID})
return err
}
// TestChannel sends a synthetic alert to verify configuration.
func TestChannel(channelID string) error {
ch, err := GetChannel(channelID)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel not found")
}
return notify.Test(*ch)
}
+33 -4
View File
@@ -3,12 +3,14 @@ package services
import (
"context"
"errors"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -29,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,
}
}
@@ -233,9 +236,35 @@ func IngestResult(monitorID string, res checker.Result) error {
return nil
}
// notifyTransition dispatches notifications on an up<->down transition. Wired up
// in Task 13 (P3); a no-op until then.
// notifyTransition dispatches notifications on an up<->down transition to each
// enabled channel bound to the monitor. Deliveries run in the background;
// failures are logged, not fatal.
func notifyTransition(m *models.Monitor, newStatus, message string) {
// TODO(P3): resolve m.ChannelIDs and dispatch via the notify package,
// honouring a resend interval tracked on state.last_notified_at.
if len(m.ChannelIDs) == 0 {
return
}
channels, err := GetChannels(m.ChannelIDs)
if err != nil {
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
return
}
ev := notify.Event{
MonitorName: m.Name,
Type: m.Type,
OldStatus: m.State.Status,
NewStatus: newStatus,
Message: message,
Time: time.Now(),
}
for _, ch := range channels {
if !ch.Enabled {
continue
}
go func(c models.NotificationChannel) {
if err := notify.Dispatch(c, ev); err != nil {
log.Printf("notify: dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
_ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
}
+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 -160
View File
@@ -1,37 +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 { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const { mutate: create, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
@@ -40,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 });
}
return (
<div className="p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
@@ -69,120 +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>
<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>
);
+8 -3
View File
@@ -37,9 +37,14 @@ export default function MonitorsPage() {
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
</div>
<Link href="/monitors/new">
<Button variant="primary">New Monitor</Button>
</Link>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notifications</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New Monitor</Button>
</Link>
</div>
</div>
<Card padding={false}>
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import { Badge, 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";
// Config fields required per channel type.
const CONFIG_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
discord: ["url"],
telegram: ["token", "chat_id"],
smtp: ["host", "port", "username", "password", "from", "to"],
};
function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDone: () => void }) {
const queryClient = useQueryClient();
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: 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();
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
submit({ name, type, config, enabled: initial?.enabled ?? true });
}}
className="space-y-4"
>
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<label className={labelClass}>Type</label>
<select
className={inputClass}
value={type}
onChange={(e) => {
setType(e.target.value as ChannelType);
setConfig({});
}}
>
{(["webhook", "smtp", "discord", "slack", "telegram"] as const).map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{CONFIG_FIELDS[type].map((field) => (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={config[field] ?? ""}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
))}
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{initial ? "Save Changes" : "Add Channel"}
</Button>
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
</form>
);
}
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),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["channels"] }),
});
const { mutate: test, isPending: testing } = useMutation({
mutationFn: () => api.testChannel(ch.channel_id),
onSuccess: () => setTestMsg("Sent!"),
onError: (e) => setTestMsg((e as Error).message),
});
const { mutate: toggle } = useMutation({
mutationFn: (enabled: boolean) => api.updateChannel(ch.channel_id, { enabled }),
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>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{ch.name}</span>
<Badge variant="neutral">{ch.type}</Badge>
{!ch.enabled && <Badge variant="warning">disabled</Badge>}
</div>
{testMsg && <p className="mt-1 text-xs text-text-secondary">{testMsg}</p>}
</div>
<div className="flex items-center gap-2">
<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>
<Button variant="danger" size="sm" onClick={() => remove()}>
Delete
</Button>
</div>
</div>
);
}
export default function NotificationSettingsPage() {
const [showForm, setShowForm] = useState(false);
const { data: channels, isLoading } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
Monitors
</Link>
<h1 className="mt-2 text-2xl font-bold text-text-primary">Notification Channels</h1>
<p className="mt-1 text-sm text-text-secondary">Alert destinations for monitor state changes.</p>
</div>
{!showForm && (
<Button variant="primary" onClick={() => setShowForm(true)}>
New Channel
</Button>
)}
</div>
{showForm && (
<Card className="mb-6 max-w-xl">
<ChannelForm onDone={() => setShowForm(false)} />
</Card>
)}
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !channels || channels.length === 0 ? (
<div className="py-16 text-center text-sm text-text-secondary">No channels configured.</div>
) : (
channels.map((ch) => <ChannelRow key={ch.channel_id} ch={ch} />)
)}
</Card>
</div>
);
}
+221 -401
View File
@@ -2,423 +2,243 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, AlertSettings, EmailSettings } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } from "@/components/ui";
function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none ${
enabled ? "bg-accent" : "bg-surface-2 border border-border"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
);
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 ToggleRow({
label,
description,
enabled,
onChange,
}: {
label: string;
description: string;
enabled: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-secondary">{description}</p>
</div>
<Toggle enabled={enabled} onChange={onChange} />
</div>
);
}
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>
);
}
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";
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 { 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);
}
return (
<Card>
<CardHeader>
<CardTitle>Secrets Read Token (ESO)</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Kubernetes External Secrets Operator authenticates to the read endpoint with this bearer
token. 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>
{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>
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>
)}
);
}
<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>
)}
</Card>
);
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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 { 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);
}
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>
{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>
{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 });
// Webhook / offline alerting state
const [alertsEnabled, setAlertsEnabled] = useState(false);
const [webhookURL, setWebhookURL] = useState("");
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [saved, setSaved] = useState(false);
// Email state
const [emailEnabled, setEmailEnabled] = useState(false);
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
const [fromAddr, setFromAddr] = useState("");
const [toAddrs, setToAddrs] = useState(""); // comma-separated in UI
const [useTLS, setUseTLS] = useState(false);
useEffect(() => {
if (!settings) return;
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
}, [settings]);
// Workflow log retention (days). 0 = keep forever.
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!settings) return;
setAlertsEnabled(settings.alerts.enabled);
setWebhookURL(settings.alerts.webhook_url ?? "");
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setEmailEnabled(settings.email?.enabled ?? false);
setSmtpHost(settings.email?.smtp_host ?? "");
setSmtpPort(settings.email?.smtp_port || 587);
setSmtpUser(settings.email?.username ?? "");
setSmtpPass(settings.email?.password ?? "");
setFromAddr(settings.email?.from_addr ?? "");
setToAddrs((settings.email?.to_addrs ?? []).join(", "));
setUseTLS(settings.email?.use_tls ?? false);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
}, [settings]);
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: {
alerts: AlertSettings;
email: EmailSettings;
workflow_log_retention_days?: number | null;
}) => api.saveSettings(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
},
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const toList = toAddrs
.split(",")
.map((s) => s.trim())
.filter(Boolean);
save({
alerts: {
enabled: alertsEnabled,
webhook_url: webhookURL,
offline_threshold_minutes: thresholdMinutes,
},
email: {
enabled: emailEnabled,
smtp_host: smtpHost,
smtp_port: smtpPort,
username: smtpUser,
password: smtpPass,
from_addr: fromAddr,
to_addrs: toList,
use_tls: useTLS,
},
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>
);
}
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Settings</h1>
<p className="mt-1 text-sm text-text-secondary">Configure alerting and monitoring behaviour</p>
</div>
<form onSubmit={handleSubmit} className="max-w-xl space-y-6">
{/* Webhook alerting */}
<Card>
<CardHeader>
<CardTitle>Webhook Alerting</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
POST a JSON payload to a URL when a server goes offline. Compatible with Slack,
Discord, n8n, and any service that accepts JSON.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable webhook alerts"
description="Webhook fires only when this is on"
enabled={alertsEnabled}
onChange={setAlertsEnabled}
/>
<Field
label="Webhook URL"
hint={`POST body: { event, hostname, server_id, ip_address, timestamp, message }`}
>
<input
type="url"
value={webhookURL}
onChange={(e) => setWebhookURL(e.target.value)}
placeholder="https://hooks.slack.com/... or https://discord.com/api/webhooks/..."
className={inputClass}
/>
</Field>
<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>
</div>
</Card>
{/* Email alerting */}
<Card>
<CardHeader>
<CardTitle>Email Notifications</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
Send an email when a server goes offline. Uses the same offline threshold as the
webhook setting above.
</p>
<div className="space-y-4">
<ToggleRow
label="Enable email alerts"
description="Emails are only sent when this is on"
enabled={emailEnabled}
onChange={setEmailEnabled}
/>
<div className="grid grid-cols-3 gap-3">
<Field label="SMTP Host" hint="">
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.gmail.com"
className={inputClass}
/>
</Field>
<Field label="Port" hint="">
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
placeholder="587"
className={inputClass}
/>
</Field>
<div className="flex flex-col justify-center pt-5">
<ToggleRow
label="TLS (port 465)"
description="Use implicit TLS instead of STARTTLS"
enabled={useTLS}
onChange={(v) => {
setUseTLS(v);
setSmtpPort(v ? 465 : 587);
}}
/>
</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="grid grid-cols-2 gap-3">
<Field label="Username">
<input
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
className={inputClass}
autoComplete="username"
/>
</Field>
<Field label="Password">
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="App password or SMTP password"
className={inputClass}
autoComplete="new-password"
/>
</Field>
<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>
<Field label="From address">
<input
type="email"
value={fromAddr}
onChange={(e) => setFromAddr(e.target.value)}
placeholder="vantage@example.com"
className={inputClass}
/>
</Field>
<Field
label="To addresses"
hint="Separate multiple addresses with commas"
>
<input
type="text"
value={toAddrs}
onChange={(e) => setToAddrs(e.target.value)}
placeholder="admin@example.com, ops@example.com"
className={inputClass}
/>
</Field>
</div>
</Card>
{/* Workflow logs */}
<Card>
<CardHeader>
<CardTitle>Workflow Logs</CardTitle>
</CardHeader>
<p className="mb-5 text-sm text-text-secondary">
How long to keep workflow run logs on the server before they are
automatically deleted.
</p>
<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>
</Card>
<div className="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>
<div className="mt-6 max-w-xl">
<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>
);
}
+40
View File
@@ -45,6 +45,7 @@ export interface MonitorTarget {
expected_status?: number;
keyword?: string;
tls_warn_days?: number;
insecure?: boolean;
}
export interface MonitorState {
@@ -97,6 +98,24 @@ export interface Rollup {
sum_latency: number;
}
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
export interface NotificationChannel {
channel_id: string;
name: string;
type: ChannelType;
config: Record<string, string>;
enabled: boolean;
created_at: string;
}
export interface ChannelInput {
name: string;
type: ChannelType;
config: Record<string, string>;
enabled: boolean;
}
export interface ConsoleConnectRequest {
server_id: string;
protocol: string;
@@ -370,6 +389,27 @@ export const api = {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
// Notification channels
listChannels(): Promise<NotificationChannel[]> {
return request<NotificationChannel[]>("/channels");
},
createChannel(input: ChannelInput): Promise<NotificationChannel> {
return request<NotificationChannel>("/channels", { method: "POST", body: JSON.stringify(input) });
},
updateChannel(channelId: string, input: Partial<ChannelInput>): Promise<void> {
return request<void>(`/channels/${channelId}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteChannel(channelId: string): Promise<void> {
return request<void>(`/channels/${channelId}`, { method: "DELETE" });
},
testChannel(channelId: string): Promise<{ status: string }> {
return request<{ status: string }>(`/channels/${channelId}/test`, { method: "POST" });
},
getLatestAgentVersion(): Promise<{ version: string }> {
return request<{ version: string }>("/agent/latest-version");
},