This commit is contained in:
201
internal/discord/discord.go
Normal file
201
internal/discord/discord.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user