package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "text/tabwriter" "time" ) type Target struct { Name string `json:"name"` URL string `json:"url"` } type Result struct { Target Target Up bool StatusCode int Latency time.Duration CheckedAt time.Time Err string } type Config struct { Targets []Target `json:"targets"` Interval int `json:"interval_seconds"` DiscordWebhook string `json:"discord_webhook"` LogFile string `json:"log_file"` } func check(t Target, timeout time.Duration) Result { r := Result{Target: t, CheckedAt: time.Now()} client := &http.Client{Timeout: timeout} start := time.Now() resp, err := client.Get(t.URL) r.Latency = time.Since(start) if err != nil { r.Err = err.Error() return r } defer resp.Body.Close() r.StatusCode = resp.StatusCode r.Up = resp.StatusCode >= 200 && resp.StatusCode < 400 return r } func sendDiscordAlert(webhookURL, message string) { payload, _ := json.Marshal(map[string]string{"content": message}) resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(payload)) if err != nil { fmt.Fprintf(os.Stderr, "discord alert failed: %v\n", err) return } resp.Body.Close() } func printResults(w io.Writer, results []Result) { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, r := range results { status := "DOWN" if r.Up { status = "UP" } code := "-" if r.StatusCode != 0 { code = fmt.Sprintf("%d", r.StatusCode) } fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", r.CheckedAt.Format("2006-01-02 15:04:05"), r.Target.Name, status, code, r.Latency.Round(time.Millisecond), ) } tw.Flush() } func loadConfig(path string) (Config, error) { f, err := os.Open(path) if err != nil { return Config{}, err } defer f.Close() var cfg Config if err := json.NewDecoder(f).Decode(&cfg); err != nil { return Config{}, err } if cfg.Interval <= 0 { cfg.Interval = 60 } return cfg, nil } func runOnce(cfg Config, prevState map[string]bool, out io.Writer) { results := make([]Result, len(cfg.Targets)) done := make(chan struct{}, len(cfg.Targets)) for i, t := range cfg.Targets { go func(i int, t Target) { results[i] = check(t, 10*time.Second) done <- struct{}{} }(i, t) } for range cfg.Targets { <-done } printResults(out, results) if cfg.DiscordWebhook != "" { for _, r := range results { wasUp, seen := prevState[r.Target.URL] if !r.Up && (!seen || wasUp) { msg := fmt.Sprintf("@here **%s** is DOWN (`%s`)", r.Target.Name, r.Target.URL) if r.Err != "" { msg += fmt.Sprintf("\nError: %s", r.Err) } else { msg += fmt.Sprintf("\nHTTP %d", r.StatusCode) } fmt.Fprintf(out, "%s\talert sent: %s is DOWN\n", r.CheckedAt.Format("2006-01-02 15:04:05"), r.Target.Name) sendDiscordAlert(cfg.DiscordWebhook, msg) } else if r.Up && seen && !wasUp { msg := fmt.Sprintf("**%s** is back UP (`%s`)", r.Target.Name, r.Target.URL) fmt.Fprintf(out, "%s\talert sent: %s is back UP\n", r.CheckedAt.Format("2006-01-02 15:04:05"), r.Target.Name) sendDiscordAlert(cfg.DiscordWebhook, msg) } prevState[r.Target.URL] = r.Up } } } func main() { cfgPath := "config.json" if len(os.Args) > 1 { cfgPath = os.Args[1] } cfg, err := loadConfig(cfgPath) if err != nil { fmt.Fprintf(os.Stderr, "error loading config: %v\n", err) os.Exit(1) } logPath := cfg.LogFile if logPath == "" { logPath = "uptimetracker.log" } lf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { fmt.Fprintf(os.Stderr, "error opening log file: %v\n", err) os.Exit(1) } defer lf.Close() out := io.MultiWriter(os.Stdout, lf) fmt.Fprintf(out, "%s\tstarted monitoring %d target(s) every %ds\n", time.Now().Format("2006-01-02 15:04:05"), len(cfg.Targets), cfg.Interval) prevState := make(map[string]bool) runOnce(cfg, prevState, out) ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) defer ticker.Stop() for range ticker.C { runOnce(cfg, prevState, out) } }