Files
heartbeat/internal/discord/discord.go
todor 93ae9b66b3
Some checks failed
Build heartbeat / build (push) Failing after 1m18s
init
2026-05-03 21:09:59 +02:00

202 lines
6.2 KiB
Go

package discord
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"heartbeat/internal/alerts"
"heartbeat/internal/metrics"
)
type Client struct {
webhookURL string
httpClient *http.Client
serverName string
notifyRoleID string
}
type webhookPayload struct {
Content string `json:"content,omitempty"`
AllowedMentions allowedMentions `json:"allowed_mentions,omitempty"`
Embeds []embed `json:"embeds"`
}
type allowedMentions struct {
Roles []string `json:"roles,omitempty"`
}
type embed struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Color int `json:"color"`
Timestamp string `json:"timestamp,omitempty"`
Fields []embedField `json:"fields,omitempty"`
Footer *embedFooter `json:"footer,omitempty"`
}
type embedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
type embedFooter struct {
Text string `json:"text"`
}
func New(serverName string, webhookURL string, notifyRoleID string, timeout time.Duration) *Client {
return &Client{
serverName: serverName,
webhookURL: webhookURL,
httpClient: &http.Client{Timeout: timeout},
notifyRoleID: notifyRoleID,
}
}
func (c *Client) SendSummary(ctx context.Context, sample metrics.Sample, summaryInterval time.Duration) error {
fields := []embedField{
{Name: "Server", Value: c.serverName, Inline: true},
{Name: "Generated", Value: formatSummaryTime(sample.Timestamp), Inline: true},
{Name: "Uptime", Value: formatUptime(sample.UptimeSeconds), Inline: true},
{Name: "CPU", Value: fmt.Sprintf("Now: %.1f%%\n15m: %.1f%%\n12h: %.1f%%", sample.CPUCurrentPercent, sample.CPUAvg15mPercent, sample.CPUAvg12hPercent), Inline: true},
{Name: "Load", Value: fmt.Sprintf("1m: %.2f\n5m: %.2f\n15m/core: %.2f", sample.Load1, sample.Load5, sample.LoadPerCore), Inline: true},
{Name: "Memory", Value: fmt.Sprintf("RAM: %.1f%%\nSwap: %.1f%%", sample.MemoryUsedPercent, sample.SwapUsedPercent), Inline: true},
{Name: "Disk /", Value: fmt.Sprintf("Used: %.1f%%\nFree: %.1f GB\nInodes: %.1f%%", sample.RootUsedPercent, sample.RootFreeGB, sample.InodeUsedPercent), Inline: true},
{Name: "Network", Value: fmt.Sprintf("RX: %s\nTX: %s", formatRate(sample.RXBytesPerSecond), formatRate(sample.TXBytesPerSecond)), Inline: true},
{Name: "Processes", Value: fmt.Sprintf("Count: %d", sample.ProcessCount), Inline: true},
{Name: "Sites", Value: formatSiteStatuses(sample.Sites), Inline: false},
}
return c.send(ctx, webhookPayload{Embeds: []embed{{
Title: fmt.Sprintf("heartbeat (%s) - %s", formatSummaryIntervalHours(summaryInterval), c.serverName),
Description: "Scheduled server health snapshot.",
Color: 0x2D9CDB,
Fields: fields,
Footer: &embedFooter{Text: formatSummaryFooter(c.serverName)},
}}})
}
func (c *Client) SendEvent(ctx context.Context, sample metrics.Sample, event alerts.Event) error {
fields := []embedField{
{Name: "Server", Value: c.serverName, Inline: true},
{Name: "Severity", Value: string(event.Severity), Inline: true},
{Name: "Timestamp", Value: formatEventTime(sample.Timestamp), Inline: true},
}
payload := webhookPayload{Embeds: []embed{{
Title: event.Title,
Description: event.Body,
Color: colorForSeverity(event.Severity),
Timestamp: sample.Timestamp.Format(time.RFC3339),
Fields: fields,
}}}
if c.notifyRoleID != "" && (event.Severity == alerts.SeverityWarning || event.Severity == alerts.SeverityCritical) {
payload.Content = fmt.Sprintf("<@&%s>", c.notifyRoleID)
payload.AllowedMentions = allowedMentions{Roles: []string{c.notifyRoleID}}
}
return c.send(ctx, payload)
}
func formatUptime(totalSeconds uint64) string {
duration := time.Duration(totalSeconds) * time.Second
days := duration / (24 * time.Hour)
duration -= days * 24 * time.Hour
hours := duration / time.Hour
duration -= hours * time.Hour
minutes := duration / time.Minute
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
return fmt.Sprintf("%dh %dm", hours, minutes)
}
func formatSiteStatuses(sites []metrics.SiteStatus) string {
if len(sites) == 0 {
return "No site checks configured"
}
parts := make([]string, 0, len(sites))
for _, site := range sites {
status := "UP"
detail := fmt.Sprintf("%d in %s", site.StatusCode, site.Latency.Round(time.Millisecond))
if !site.Healthy {
status = "DOWN"
detail = site.ErrorMessage
}
parts = append(parts, fmt.Sprintf("%s: %s (%s)", site.Name, status, detail))
}
return strings.Join(parts, "\n")
}
func formatSummaryTime(timestamp time.Time) string {
unixSeconds := timestamp.Unix()
return fmt.Sprintf("<t:%d:f>", unixSeconds)
}
func formatEventTime(timestamp time.Time) string {
unixSeconds := timestamp.Unix()
return fmt.Sprintf("<t:%d:f> - <t:%d:R>", unixSeconds, unixSeconds)
}
func formatSummaryFooter(serverName string) string {
return fmt.Sprintf("heartbeat - %s", serverName)
}
func formatSummaryIntervalHours(interval time.Duration) string {
if interval%time.Hour == 0 {
return fmt.Sprintf("%dh", interval/time.Hour)
}
return fmt.Sprintf("%.2fh", interval.Hours())
}
func formatRate(bytesPerSecond float64) string {
if bytesPerSecond < 0 {
return "n/a"
}
if bytesPerSecond < 1024 {
return fmt.Sprintf("%.0f B/s", bytesPerSecond)
}
kib := bytesPerSecond / 1024
if kib < 1024 {
return fmt.Sprintf("%.2f KB/s", kib)
}
mib := kib / 1024
return fmt.Sprintf("%.2f MB/s", mib)
}
func (c *Client) send(ctx context.Context, payload webhookPayload) error {
buffer := &bytes.Buffer{}
if err := json.NewEncoder(buffer).Encode(payload); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("discord webhook returned %s", resp.Status)
}
return nil
}
func colorForSeverity(severity alerts.Severity) int {
switch severity {
case alerts.SeverityCritical:
return 0xE74C3C
case alerts.SeverityWarning:
return 0xF39C12
default:
return 0x27AE60
}
}