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()
}
+6 -15
View File
@@ -6,21 +6,13 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
// AlertSettings no longer carries a webhook URL or SMTP configuration of its
// own. Agent-offline alerts are delivered through notification channels, the
// same destinations monitors use, so there is one place to configure a
// destination and one place to test it.
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
OfflineChannelIDs []string `bson:"offline_channel_ids" json:"offline_channel_ids"`
}
type SecretsSettings struct {
@@ -33,7 +25,6 @@ type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
+59 -13
View File
@@ -130,16 +130,41 @@ export default function SettingsPage() {
enabled: isAdmin,
});
const { data: channels } = useQuery({
queryKey: ["channels"],
queryFn: api.listChannels,
enabled: isAdmin,
});
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!settings) return;
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
}, [settings]);
// The one place the in-progress form is turned into a payload. Both the
// Save button and the local-login toggle go through it, so flipping the
// toggle can never submit a stale copy of the other fields.
function currentPayload() {
return {
alerts: {
offline_threshold_minutes: thresholdMinutes,
offline_channel_ids: offlineChannelIds,
},
workflow_log_retention_days: logRetentionDays,
};
}
function toggleOfflineChannel(id: string) {
setOfflineChannelIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
}
const { mutate: save, isPending } = useMutation({
mutationFn: (payload: Parameters<typeof api.saveSettings>[0]) => api.saveSettings(payload),
onSuccess: () => {
@@ -153,11 +178,7 @@ export default function SettingsPage() {
e.preventDefault();
if (!settings) return;
save({
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
email: settings.email,
workflow_log_retention_days: logRetentionDays,
});
save(currentPayload());
}
if (!isAdmin) {
@@ -200,18 +221,13 @@ export default function SettingsPage() {
// object — otherwise an unsaved edit to the offline
// threshold or retention days is silently reverted the
// moment this toggle is flipped.
save({
alerts: { ...settings.alerts, offline_threshold_minutes: thresholdMinutes },
email: settings.email,
workflow_log_retention_days: logRetentionDays,
local_login_enabled: v,
});
save({ ...currentPayload(), local_login_enabled: v });
}}
/>
</Group>
<Group label="Monitoring">
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors." icon={<BellIcon />}>
<SectionCard title="Alerting" description="Alerts are delivered through notification channels, triggered by service monitors and by servers going offline." icon={<BellIcon />}>
<div className="flex flex-wrap gap-3">
<Link href="/settings/notifications">
<Button variant="secondary">Manage notification channels</Button>
@@ -227,10 +243,40 @@ export default function SettingsPage() {
<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 />}>
<SectionCard title="Server health" description="When to consider an agent-backed server offline, and where to say so." 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={numberInputClass} />
</Field>
<div className="mt-6">
<Field label="Offline alert channels" hint="Notification channels an agent-offline alert is sent to. None selected means the event is still audited, but nobody is notified.">
{channels && channels.length > 0 ? (
<div className="space-y-2">
{channels.map((ch) => (
<label key={ch.channel_id} className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={offlineChannelIds.includes(ch.channel_id)}
onChange={() => toggleOfflineChannel(ch.channel_id)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
<span className="text-text-primary">{ch.name}</span>
<span className="text-xs text-text-tertiary">{ch.type}</span>
{!ch.enabled && <span className="text-xs text-warning">disabled</span>}
</label>
))}
</div>
) : (
<p className="text-sm text-text-tertiary">
No notification channels yet.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Create one
</Link>
.
</p>
)}
</Field>
</div>
</SectionCard>
<SectionCard title="Workflow logs" description="How long run logs are kept before automatic deletion." icon={<DocumentIcon />}>
+1 -15
View File
@@ -164,20 +164,8 @@ export interface AuditEvent {
}
export interface AlertSettings {
enabled: boolean;
webhook_url: string;
offline_threshold_minutes: number;
}
export interface EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
username: string;
password: string;
from_addr: string;
to_addrs: string[];
use_tls: boolean;
offline_channel_ids: string[] | null;
}
export interface SecretsSettings {
@@ -187,7 +175,6 @@ export interface SecretsSettings {
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
@@ -657,7 +644,6 @@ export const api = {
saveSettings(settings: {
alerts: AlertSettings;
email: EmailSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
}): Promise<{ saved: boolean }> {
File diff suppressed because one or more lines are too long