feat: Removed email alert settings
Chart Release / chart (push) Successful in 10s
Server Deploy / deploy (push) Successful in 5m15s

This commit is contained in:
2026-08-03 14:40:15 +01:00
parent 6881d92d0a
commit 1f08e90009
9 changed files with 123 additions and 168 deletions
+1 -2
View File
@@ -481,7 +481,6 @@ func getSettings(c *gin.Context) {
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
}
@@ -489,7 +488,7 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
-1
View File
@@ -5,6 +5,5 @@ import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
AlertSettings = shared.AlertSettings
EmailSettings = shared.EmailSettings
SecretsSettings = shared.SecretsSettings
)
+15 -1
View File
@@ -7,6 +7,10 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
// TypeServer marks an event whose subject is a managed server rather than a
// monitor check. MonitorName carries the hostname in that case.
const TypeServer = "server"
type Event struct {
MonitorName string
Type string
@@ -21,7 +25,17 @@ func (e Event) title() string {
if e.NewStatus == models.StatusDown {
verb = "is DOWN"
}
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
var s string
if e.Type == TypeServer {
if e.NewStatus == models.StatusDown {
verb = "went offline"
} else {
verb = "is back online"
}
s = fmt.Sprintf("[Vantage] Server %s %s", e.MonitorName, verb)
} else {
s = fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
}
if e.Message != "" {
s += ": " + e.Message
}
+36 -5
View File
@@ -12,6 +12,7 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/notify"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -327,11 +328,8 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
for _, s := range goingOffline {
LogEvent(s.InstanceID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
if settings != nil {
notifyServerOffline(s.InstanceID, settings.Alerts.OfflineChannelIDs, s)
}
}
@@ -340,3 +338,36 @@ func markOfflineForFilter(scope bson.M, instanceID string) error {
)
return err
}
// notifyServerOffline delivers an agent-offline alert over the instance's
// chosen notification channels — the same destinations monitors dispatch to,
// so a webhook or SMTP destination is configured and tested in exactly one
// place. No channels selected means the alert is audited but not sent.
func notifyServerOffline(instanceID string, channelIDs []string, s models.Server) {
if len(channelIDs) == 0 {
return
}
channels, err := GetChannels(instanceID, channelIDs)
if err != nil {
log.Printf("notify: load offline channels for %s: %v", instanceID, err)
return
}
ev := notify.Event{
MonitorName: s.Hostname,
Type: notify.TypeServer,
OldStatus: models.StatusUp,
NewStatus: models.StatusDown,
Message: fmt.Sprintf("agent has not checked in (%s)", s.IPAddress),
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: offline dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
}
+4 -115
View File
@@ -1,19 +1,11 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
@@ -25,13 +17,8 @@ import (
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func EnsureSettingsIndexes() error {
@@ -132,12 +119,12 @@ func ResolveSecretsReadToken(token string) (string, bool) {
return s.InstanceID, true
}
func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int, localLoginEnabled *bool) error {
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool) error {
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
if alerts.OfflineChannelIDs == nil {
alerts.OfflineChannelIDs = []string{}
}
// The guard lives here rather than in the handler so the settings path and
@@ -155,7 +142,7 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, email models.E
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"alerts": alerts, "email": email}
set := bson.M{"alerts": alerts}
if retentionDays != nil {
set["workflow_log_retention_days"] = *retentionDays
}
@@ -180,101 +167,3 @@ func GetWorkflowLogRetentionDays(instanceID string) (int, error) {
}
return *s.WorkflowLogRetentionDays, nil
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}