mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: webhook alerts for monitor + domain events (merge plan M2)
Generic webhook notification delivery configured via the notify_webhook settings key — no new schema. Fires on monitor up/down transitions (pending stays silent), domain expiry, domain expiring, and TLS cert expiring; dispatch is async and failures are logged, never fatal. POST /api/notify/test verifies configuration; Settings dialog gains a webhook URL field with save + send-test. Payload carries event/name/text/content/message so Slack, Discord, ntfy, and custom receivers all work. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f9fd1d9cf2
commit
2f3e8a1879
7 files changed
+255
-4
No files matched your search
@@ -90,6 +90,7 @@ func (s *Server) routes() *gin.Engine {
|
|||||||
v1.DELETE("/domains/:id", s.deleteDomain)
|
v1.DELETE("/domains/:id", s.deleteDomain)
|
||||||
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
v1.POST("/domains/:id/refresh", s.refreshDomainH)
|
||||||
v1.GET("/domains/:id/checks", s.domainChecks)
|
v1.GET("/domains/:id/checks", s.domainChecks)
|
||||||
|
v1.POST("/notify/test", s.notifyTest)
|
||||||
|
|
||||||
v1.GET("/status", s.getStatus)
|
v1.GET("/status", s.getStatus)
|
||||||
v1.GET("/widgets/types", s.widgetTypes)
|
v1.GET("/widgets/types", s.widgetTypes)
|
||||||
|
|||||||
@@ -476,6 +476,69 @@ func TestDomainWidget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotifyWebhook(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Receiver captures the webhook payload.
|
||||||
|
got := make(chan map[string]any, 1)
|
||||||
|
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
got <- body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer hook.Close()
|
||||||
|
|
||||||
|
// Unconfigured -> 400.
|
||||||
|
res, _ := do(t, "POST", srv.URL+"/api/notify/test", "")
|
||||||
|
if res.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 without webhook, got %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure + test.
|
||||||
|
if res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`); res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("put settings: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
res, _ = do(t, "POST", srv.URL+"/api/notify/test", "")
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("notify test: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case body := <-got:
|
||||||
|
if body["event"] != "test" || body["text"] == "" {
|
||||||
|
t.Fatalf("bad payload: %v", body)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("webhook never received the test notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor transition fires monitor.down through the same webhook.
|
||||||
|
res, mv := do(t, "POST", srv.URL+"/api/monitors", `{"name":"Flip","type":"http","url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||||
|
if res.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %v", mv)
|
||||||
|
}
|
||||||
|
id := mv["id"].(string)
|
||||||
|
// pending -> down (no fire), then up -> down needs an up first.
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||||
|
defer up.Close()
|
||||||
|
res, _ = do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"`+up.URL+`"}`)
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("patch: %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
|
||||||
|
do(t, "PATCH", srv.URL+"/api/monitors/"+id, `{"url":"http://127.0.0.1:1","timeoutS":1}`)
|
||||||
|
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> down, fires webhook
|
||||||
|
select {
|
||||||
|
case body := <-got:
|
||||||
|
if body["event"] != "monitor.down" {
|
||||||
|
t.Fatalf("expected monitor.down, got %v", body)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("webhook never received monitor.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMidpoint(t *testing.T) {
|
func TestMidpoint(t *testing.T) {
|
||||||
f := func(v float64) *float64 { return &v }
|
f := func(v float64) *float64 { return &v }
|
||||||
if got := midpoint(nil, nil); got != 1024 {
|
if got := midpoint(nil, nil); got != 1024 {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -318,16 +319,36 @@ func (s *Server) deleteDomain(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshDomain re-runs the full lookup and writes a snapshot row.
|
// refreshDomain re-runs the full lookup and writes a snapshot row. Expiry
|
||||||
|
// threshold crossings fire notify events on the edge (only when a state
|
||||||
|
// newly becomes true, not on every refresh).
|
||||||
func (s *Server) refreshDomain(id string) {
|
func (s *Server) refreshDomain(id string) {
|
||||||
row, err := s.loadDomain(id)
|
row, err := s.loadDomain(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
prev := viewOf(row)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
r := s.lookup(ctx, row.Name)
|
r := s.lookup(ctx, row.Name)
|
||||||
s.applyResult(id, r)
|
s.applyResult(id, r)
|
||||||
|
|
||||||
|
next, err := s.loadDomain(id)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nv := viewOf(next)
|
||||||
|
switch {
|
||||||
|
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
|
||||||
|
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
|
||||||
|
case nv.Expiring && !prev.Expiring:
|
||||||
|
s.notify("domain.expiring", next.Name,
|
||||||
|
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
|
||||||
|
}
|
||||||
|
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
|
||||||
|
s.notify("domain.sslExpiring", next.Name,
|
||||||
|
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) refreshDomainH(c *gin.Context) {
|
func (s *Server) refreshDomainH(c *gin.Context) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -491,10 +492,17 @@ func (s *Server) writeHeartbeat(m *monitor.Monitor, res *monitor.Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// onMonitorTransition is the alert seam — M2 wires notifications here.
|
// onMonitorTransition fires on real state flips (pending excluded upstream)
|
||||||
|
// — the notification seam for up/down events.
|
||||||
func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
|
func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
|
||||||
s.log.Info("monitor transition",
|
s.log.Info("monitor transition",
|
||||||
zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next))
|
zap.String("monitor", m.Name), zap.String("from", prev), zap.String("to", next))
|
||||||
|
target := m.URL
|
||||||
|
if target == "" {
|
||||||
|
target = m.Hostname
|
||||||
|
}
|
||||||
|
s.notify("monitor."+next, m.Name,
|
||||||
|
fmt.Sprintf("Monitor %q is %s (%s)", m.Name, next, target))
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
func truncate(s string, n int) string {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifications: one outbound webhook, configured via the notify_webhook
|
||||||
|
// settings key. Payload carries event/name/text/content/message so the same
|
||||||
|
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
|
||||||
|
// receivers. SMTP and per-service channels are deliberately out of scope —
|
||||||
|
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
|
||||||
|
|
||||||
|
type notifyPayload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Text string `json:"text"` // Slack-style
|
||||||
|
Content string `json:"content"` // Discord-style
|
||||||
|
Message string `json:"message"` // ntfy/generic
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) webhookURL() string {
|
||||||
|
var raw string
|
||||||
|
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Settings values are stored as JSON — unwrap the quoted string.
|
||||||
|
var u string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &u); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
|
||||||
|
func (s *Server) notify(event, name, msg string) {
|
||||||
|
url := s.webhookURL()
|
||||||
|
if url == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u := parseHTTPURL(url); u == "" {
|
||||||
|
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
|
||||||
|
At: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
go s.deliver(url, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deliver(url string, p notifyPayload) {
|
||||||
|
body, _ := json.Marshal(p)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Title", p.Text) // ntfy reads the title header
|
||||||
|
req.Header.Set("X-Dash-Event", p.Event)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
|
||||||
|
// posture as monitor targets (user-configured, but no file:// or friends).
|
||||||
|
func parseHTTPURL(raw string) string {
|
||||||
|
if !validURL(raw) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyTest lets the settings UI verify the configured webhook.
|
||||||
|
func (s *Server) notifyTest(c *gin.Context) {
|
||||||
|
if s.webhookURL() == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "notify_webhook not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify("test", "dash", "Dash test notification")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Download, Upload } from "lucide-react";
|
import { Bell, Download, Upload } from "lucide-react";
|
||||||
import { useBoard } from "@/board/store";
|
import { useBoard } from "@/board/store";
|
||||||
|
import { api } from "@/api";
|
||||||
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
@@ -14,6 +16,23 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
const [webhook, setWebhook] = useState("");
|
||||||
|
const [webhookMsg, setWebhookMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void api.GET("/api/settings").then(({ data }) => {
|
||||||
|
const v = data?.notify_webhook;
|
||||||
|
setWebhook(typeof v === "string" ? v : "");
|
||||||
|
setWebhookMsg("");
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function saveWebhook() {
|
||||||
|
setWebhookMsg("");
|
||||||
|
await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } });
|
||||||
|
setWebhookMsg("saved");
|
||||||
|
}
|
||||||
|
|
||||||
async function exportJson() {
|
async function exportJson() {
|
||||||
const res = await fetch("/api/export");
|
const res = await fetch("/api/export");
|
||||||
@@ -93,6 +112,38 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="notify-webhook">Notification webhook</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="notify-webhook"
|
||||||
|
value={webhook}
|
||||||
|
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
|
||||||
|
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
|
||||||
|
className="font-mono text-[12px]"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button" variant="outline" size="icon" aria-label="Send test notification"
|
||||||
|
disabled={!webhook.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
setWebhookMsg("");
|
||||||
|
void api.POST("/api/notify/test").then(({ error }) =>
|
||||||
|
setWebhookMsg(error ? "failed" : "sent"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bell size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-text-faint">
|
||||||
|
Monitor up/down flips and domain/certificate expiry post JSON here.
|
||||||
|
Works with Slack, Discord, ntfy, or any JSON receiver.
|
||||||
|
</p>
|
||||||
|
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
|
||||||
<Download size={12} /> Export JSON
|
<Download size={12} /> Export JSON
|
||||||
|
|||||||
@@ -540,6 +540,17 @@ paths:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
$ref: "#/components/schemas/Status"
|
$ref: "#/components/schemas/Status"
|
||||||
|
|
||||||
|
/api/notify/test:
|
||||||
|
post:
|
||||||
|
operationId: notifyTest
|
||||||
|
summary: Send a test notification to the configured webhook
|
||||||
|
description: Requires the `notify_webhook` settings key to hold an http(s) URL.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Test notification dispatched
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
|
||||||
/api/settings:
|
/api/settings:
|
||||||
get:
|
get:
|
||||||
operationId: getSettings
|
operationId: getSettings
|
||||||
|
|||||||
Reference in new issue
Block a user