feat: Removed email alert settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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"`
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user