init work of uptime
This commit is contained in:
94
internal/alert/alerter.go
Normal file
94
internal/alert/alerter.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"arclineit/arcline-uptime/internal/config"
|
||||
"arclineit/arcline-uptime/internal/monitor"
|
||||
)
|
||||
|
||||
// Alerter sends a notification with a subject and body.
|
||||
type Alerter interface {
|
||||
Send(subject, body string) error
|
||||
}
|
||||
|
||||
// NamedAlerter wraps an Alerter with its configured name for per-monitor routing.
|
||||
type NamedAlerter struct {
|
||||
Name string
|
||||
Alerter Alerter
|
||||
}
|
||||
|
||||
// BuildNamedAlerters constructs one NamedAlerter per AlertConfig entry.
|
||||
func BuildNamedAlerters(alerts []config.AlertConfig) ([]NamedAlerter, error) {
|
||||
out := make([]NamedAlerter, 0, len(alerts))
|
||||
for _, a := range alerts {
|
||||
var al Alerter
|
||||
switch a.Type {
|
||||
case "discord", "slack":
|
||||
al = NewDiscordAlerter(a.WebhookURL)
|
||||
case "email":
|
||||
al = NewEmailAlerter(a)
|
||||
case "ntfy":
|
||||
al = NewNtfyAlerter(a.URL, a.Token)
|
||||
case "gotify":
|
||||
al = NewGotifyAlerter(a.URL, a.Token, a.Priority)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown alerter type %q", a.Type)
|
||||
}
|
||||
out = append(out, NamedAlerter{Name: a.Name, Alerter: al})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BuildAlerters is a convenience shim for callers that don't need routing.
|
||||
func BuildAlerters(alerts []config.AlertConfig) ([]Alerter, error) {
|
||||
named, err := BuildNamedAlerters(alerts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Alerter, len(named))
|
||||
for i, na := range named {
|
||||
out[i] = na.Alerter
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormatDownMessage returns the subject and body for a failed check.
|
||||
func FormatDownMessage(r monitor.Result) (subject, body string) {
|
||||
subject = fmt.Sprintf("[DOWN] %s", r.MonitorName)
|
||||
body = subject + "\n"
|
||||
if r.Error != "" {
|
||||
body += r.Error + "\n"
|
||||
}
|
||||
if r.StatusCode != 0 {
|
||||
body += fmt.Sprintf("Status code: %d\n", r.StatusCode)
|
||||
}
|
||||
body += fmt.Sprintf("Checked at %s UTC\n", r.CheckedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||
body += fmt.Sprintf("Response time: %dms", r.ResponseTime.Milliseconds())
|
||||
return
|
||||
}
|
||||
|
||||
// FormatUpMessage returns the subject and body for a recovery alert.
|
||||
// downSince is the time the last DOWN alert was sent.
|
||||
func FormatUpMessage(r monitor.Result, downSince time.Time) (subject, body string) {
|
||||
subject = fmt.Sprintf("[UP] %s is back up", r.MonitorName)
|
||||
body = subject + "\n"
|
||||
body += fmt.Sprintf("Was down for %s\n", FormatDuration(time.Since(downSince)))
|
||||
body += fmt.Sprintf("Recovered at %s UTC", r.CheckedAt.UTC().Format("2006-01-02 15:04:05"))
|
||||
return
|
||||
}
|
||||
|
||||
// FormatDuration renders a duration as human-readable text, omitting leading zero units.
|
||||
func FormatDuration(d time.Duration) string {
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
s := int(d.Seconds()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||
}
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm %ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
40
internal/alert/discord.go
Normal file
40
internal/alert/discord.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DiscordAlerter struct {
|
||||
webhookURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewDiscordAlerter(webhookURL string) *DiscordAlerter {
|
||||
return &DiscordAlerter{
|
||||
webhookURL: webhookURL,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DiscordAlerter) Send(subject, body string) error {
|
||||
payload, err := json.Marshal(map[string]string{"content": body})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := d.client.Post(d.webhookURL, "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("post webhook: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Discord returns 204 No Content on success.
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("webhook returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
internal/alert/email.go
Normal file
34
internal/alert/email.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"arclineit/arcline-uptime/internal/config"
|
||||
)
|
||||
|
||||
type EmailAlerter struct {
|
||||
cfg config.AlertConfig
|
||||
}
|
||||
|
||||
func NewEmailAlerter(cfg config.AlertConfig) *EmailAlerter {
|
||||
return &EmailAlerter{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send delivers an email via STARTTLS on the configured SMTP server.
|
||||
// Port 587 with STARTTLS is expected; port 465 (SMTPS) is not supported.
|
||||
func (e *EmailAlerter) Send(subject, body string) error {
|
||||
if len(e.cfg.To) == 0 {
|
||||
return fmt.Errorf("email alerter: no recipients configured")
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.SMTPHost)
|
||||
toHeader := strings.Join(e.cfg.To, ", ")
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
e.cfg.From, toHeader, subject, body,
|
||||
)
|
||||
addr := fmt.Sprintf("%s:%d", e.cfg.SMTPHost, e.cfg.SMTPPort)
|
||||
return smtp.SendMail(addr, auth, e.cfg.From, []string(e.cfg.To), []byte(msg))
|
||||
}
|
||||
59
internal/alert/gotify.go
Normal file
59
internal/alert/gotify.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GotifyAlerter sends notifications to a Gotify server.
|
||||
type GotifyAlerter struct {
|
||||
baseURL string
|
||||
token string
|
||||
priority int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewGotifyAlerter(baseURL, token string, priority int) *GotifyAlerter {
|
||||
if priority == 0 {
|
||||
priority = 5
|
||||
}
|
||||
return &GotifyAlerter{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
priority: priority,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type gotifyPayload struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
func (g *GotifyAlerter) Send(subject, body string) error {
|
||||
payload, err := json.Marshal(gotifyPayload{
|
||||
Title: subject,
|
||||
Message: body,
|
||||
Priority: g.priority,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal gotify payload: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/message?token=%s", g.baseURL, g.token)
|
||||
resp, err := g.client.Post(url, "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("post gotify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("gotify returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
47
internal/alert/ntfy.go
Normal file
47
internal/alert/ntfy.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NtfyAlerter sends notifications via ntfy.sh or a self-hosted ntfy server.
|
||||
// The URL should be the full topic URL, e.g. "https://ntfy.sh/my-topic".
|
||||
type NtfyAlerter struct {
|
||||
url string
|
||||
token string // optional Bearer token
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewNtfyAlerter(url, token string) *NtfyAlerter {
|
||||
return &NtfyAlerter{
|
||||
url: url,
|
||||
token: token,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NtfyAlerter) Send(subject, body string) error {
|
||||
req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build ntfy request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.Header.Set("Title", subject)
|
||||
if n.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+n.token)
|
||||
}
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("post ntfy: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("ntfy returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user