482 lines
13 KiB
Go
482 lines
13 KiB
Go
package monitor
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ProbeGroup struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
Monitors []Monitor `json:"monitors,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
type ProbeGroupInput struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
MonitorIDs []int64 `json:"monitor_ids"`
|
|
}
|
|
|
|
type StatusPage struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
Status string `json:"status"`
|
|
Enabled bool `json:"enabled"`
|
|
ServiceIDs []int64 `json:"service_ids"`
|
|
Services []ProbeGroup `json:"services,omitempty"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
|
|
type StatusPageInput struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description string `json:"description"`
|
|
Enabled *bool `json:"enabled"`
|
|
ServiceIDs []int64 `json:"service_ids"`
|
|
}
|
|
|
|
var slugRx = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
|
|
|
func aggregateStatus(ms []Monitor) string {
|
|
if len(ms) == 0 {
|
|
return "unknown"
|
|
}
|
|
hasMaint, hasPending, hasPaused := false, false, false
|
|
for _, m := range ms {
|
|
switch m.Status {
|
|
case "down":
|
|
return "down"
|
|
case "maintenance":
|
|
hasMaint = true
|
|
case "pending":
|
|
hasPending = true
|
|
case "paused":
|
|
hasPaused = true
|
|
}
|
|
}
|
|
if hasMaint {
|
|
return "maintenance"
|
|
}
|
|
if hasPending {
|
|
return "pending"
|
|
}
|
|
if hasPaused {
|
|
return "paused"
|
|
}
|
|
return "up"
|
|
}
|
|
|
|
func (s *Service) ListGroups(ctx context.Context) ([]ProbeGroup, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id,name,description,created_at,updated_at FROM monitor_services ORDER BY name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []ProbeGroup{}
|
|
for rows.Next() {
|
|
var g ProbeGroup
|
|
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
|
_ = rows.Close()
|
|
return nil, err
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return nil, err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load monitors once and group in memory. This avoids an N+1 query pattern
|
|
// on the dashboard and keeps refresh cost predictable with many services.
|
|
all, err := s.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byService := make(map[int64][]Monitor, len(out))
|
|
for _, m := range all {
|
|
if m.ServiceID != nil {
|
|
byService[*m.ServiceID] = append(byService[*m.ServiceID], m)
|
|
}
|
|
}
|
|
for i := range out {
|
|
out[i].Monitors = byService[out[i].ID]
|
|
if out[i].Monitors == nil {
|
|
out[i].Monitors = []Monitor{}
|
|
}
|
|
out[i].Status = aggregateStatus(out[i].Monitors)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Service) GetGroup(ctx context.Context, id int64) (ProbeGroup, error) {
|
|
var g ProbeGroup
|
|
if err := s.db.QueryRowContext(ctx, `SELECT id,name,description,created_at,updated_at FROM monitor_services WHERE id=?`, id).Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
|
return g, err
|
|
}
|
|
ms, err := s.listGroupMonitors(ctx, id)
|
|
if err != nil {
|
|
return g, err
|
|
}
|
|
g.Monitors = ms
|
|
g.Status = aggregateStatus(ms)
|
|
return g, nil
|
|
}
|
|
|
|
func (s *Service) listGroupMonitors(ctx context.Context, id int64) ([]Monitor, error) {
|
|
rows, err := s.db.QueryContext(ctx, selectMonitor+` WHERE m.service_id=? ORDER BY m.name`, time.Now().Add(-24*time.Hour).Unix(), id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Monitor{}
|
|
for rows.Next() {
|
|
m, e := scanMonitor(rows)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func normalizeGroupInput(in *ProbeGroupInput) error {
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
in.Description = strings.TrimSpace(in.Description)
|
|
if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") {
|
|
return errors.New("valid service name required")
|
|
}
|
|
if len(in.Description) > 2000 {
|
|
return errors.New("service description too long")
|
|
}
|
|
if in.MonitorIDs != nil {
|
|
seen := map[int64]bool{}
|
|
ids := make([]int64, 0, len(in.MonitorIDs))
|
|
for _, id := range in.MonitorIDs {
|
|
if id < 1 {
|
|
return errors.New("monitor_ids must contain positive IDs")
|
|
}
|
|
if !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
in.MonitorIDs = ids
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assignGroupMonitors(ctx context.Context, tx *sql.Tx, groupID int64, monitorIDs []int64, clearExisting bool) error {
|
|
if clearExisting {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE monitors SET service_id=NULL,updated_at=? WHERE service_id=?`, time.Now().Unix(), groupID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, monitorID := range monitorIDs {
|
|
r, err := tx.ExecContext(ctx, `UPDATE monitors SET service_id=?,updated_at=? WHERE id=?`, groupID, time.Now().Unix(), monitorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("monitor %d not found", monitorID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) CreateGroup(ctx context.Context, in ProbeGroupInput) (ProbeGroup, error) {
|
|
if err := normalizeGroupInput(&in); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
now := time.Now().Unix()
|
|
r, err := tx.ExecContext(ctx, `INSERT INTO monitor_services(name,description,created_at,updated_at) VALUES(?,?,?,?)`, in.Name, in.Description, now, now)
|
|
if err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
id, _ := r.LastInsertId()
|
|
if in.MonitorIDs != nil {
|
|
if err := assignGroupMonitors(ctx, tx, id, in.MonitorIDs, false); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
return s.GetGroup(ctx, id)
|
|
}
|
|
func (s *Service) UpdateGroup(ctx context.Context, id int64, in ProbeGroupInput) (ProbeGroup, error) {
|
|
if err := normalizeGroupInput(&in); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
r, err := tx.ExecContext(ctx, `UPDATE monitor_services SET name=?,description=?,updated_at=? WHERE id=?`, in.Name, in.Description, time.Now().Unix(), id)
|
|
if err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n == 0 {
|
|
return ProbeGroup{}, sql.ErrNoRows
|
|
}
|
|
if in.MonitorIDs != nil {
|
|
if err := assignGroupMonitors(ctx, tx, id, in.MonitorIDs, true); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return ProbeGroup{}, err
|
|
}
|
|
return s.GetGroup(ctx, id)
|
|
}
|
|
func (s *Service) DeleteGroup(ctx context.Context, id int64) error {
|
|
_, e := s.db.ExecContext(ctx, `DELETE FROM monitor_services WHERE id=?`, id)
|
|
return e
|
|
}
|
|
|
|
func normalizeStatusPage(in *StatusPageInput) error {
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
in.Slug = strings.ToLower(strings.TrimSpace(in.Slug))
|
|
in.Description = strings.TrimSpace(in.Description)
|
|
if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") || !slugRx.MatchString(in.Slug) {
|
|
return errors.New("name and valid slug required (lowercase letters, numbers, hyphens)")
|
|
}
|
|
if len(in.Description) > 4000 {
|
|
return errors.New("status page description too long")
|
|
}
|
|
seen := map[int64]bool{}
|
|
var ids []int64
|
|
for _, id := range in.ServiceIDs {
|
|
if id > 0 && !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
in.ServiceIDs = ids
|
|
return nil
|
|
}
|
|
func (s *Service) ListStatusPages(ctx context.Context) ([]StatusPage, error) {
|
|
rows, e := s.db.QueryContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages ORDER BY name`)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
out := []StatusPage{}
|
|
for rows.Next() {
|
|
var p StatusPage
|
|
if e = rows.Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt); e != nil {
|
|
_ = rows.Close()
|
|
return nil, e
|
|
}
|
|
p.ServiceIDs = []int64{}
|
|
out = append(out, p)
|
|
}
|
|
if e = rows.Err(); e != nil {
|
|
_ = rows.Close()
|
|
return nil, e
|
|
}
|
|
if e = rows.Close(); e != nil {
|
|
return nil, e
|
|
}
|
|
|
|
// Resolve page memberships only after releasing the one SQLite connection.
|
|
for i := range out {
|
|
out[i].ServiceIDs, e = s.statusPageServiceIDs(ctx, out[i].ID)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *Service) GetStatusPage(ctx context.Context, id int64) (StatusPage, error) {
|
|
var p StatusPage
|
|
e := s.db.QueryRowContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages WHERE id=?`, id).Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt)
|
|
if e != nil {
|
|
return p, e
|
|
}
|
|
p.ServiceIDs, e = s.statusPageServiceIDs(ctx, id)
|
|
return p, e
|
|
}
|
|
func (s *Service) statusPageServiceIDs(ctx context.Context, id int64) ([]int64, error) {
|
|
rows, e := s.db.QueryContext(ctx, `SELECT service_id FROM status_page_services WHERE page_id=? ORDER BY sort_order,service_id`, id)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
defer rows.Close()
|
|
out := []int64{}
|
|
for rows.Next() {
|
|
var x int64
|
|
if e = rows.Scan(&x); e != nil {
|
|
return nil, e
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
func (s *Service) savePageServices(ctx context.Context, tx *sql.Tx, id int64, ids []int64) error {
|
|
if _, e := tx.ExecContext(ctx, `DELETE FROM status_page_services WHERE page_id=?`, id); e != nil {
|
|
return e
|
|
}
|
|
for i, sid := range ids {
|
|
if _, e := tx.ExecContext(ctx, `INSERT INTO status_page_services(page_id,service_id,sort_order) VALUES(?,?,?)`, id, sid, i); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) CreateStatusPage(ctx context.Context, in StatusPageInput) (StatusPage, error) {
|
|
if e := normalizeStatusPage(&in); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
en := true
|
|
if in.Enabled != nil {
|
|
en = *in.Enabled
|
|
}
|
|
tx, e := s.db.BeginTx(ctx, nil)
|
|
if e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
defer tx.Rollback()
|
|
now := time.Now().Unix()
|
|
r, e := tx.ExecContext(ctx, `INSERT INTO status_pages(name,slug,description,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)`, in.Name, in.Slug, in.Description, en, now, now)
|
|
if e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
id, _ := r.LastInsertId()
|
|
if e = s.savePageServices(ctx, tx, id, in.ServiceIDs); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
if e = tx.Commit(); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
return s.GetStatusPage(ctx, id)
|
|
}
|
|
func (s *Service) UpdateStatusPage(ctx context.Context, id int64, in StatusPageInput) (StatusPage, error) {
|
|
if e := normalizeStatusPage(&in); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
old, e := s.GetStatusPage(ctx, id)
|
|
if e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
en := old.Enabled
|
|
if in.Enabled != nil {
|
|
en = *in.Enabled
|
|
}
|
|
tx, e := s.db.BeginTx(ctx, nil)
|
|
if e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
defer tx.Rollback()
|
|
if _, e = tx.ExecContext(ctx, `UPDATE status_pages SET name=?,slug=?,description=?,enabled=?,updated_at=? WHERE id=?`, in.Name, in.Slug, in.Description, en, time.Now().Unix(), id); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
if e = s.savePageServices(ctx, tx, id, in.ServiceIDs); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
if e = tx.Commit(); e != nil {
|
|
return StatusPage{}, e
|
|
}
|
|
return s.GetStatusPage(ctx, id)
|
|
}
|
|
func (s *Service) DeleteStatusPage(ctx context.Context, id int64) error {
|
|
_, e := s.db.ExecContext(ctx, `DELETE FROM status_pages WHERE id=?`, id)
|
|
return e
|
|
}
|
|
func (s *Service) PublicStatusPage(ctx context.Context, slug string) (StatusPage, error) {
|
|
p := StatusPage{Services: []ProbeGroup{}, ServiceIDs: []int64{}}
|
|
e := s.db.QueryRowContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages WHERE slug=? AND enabled=1`, slug).Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt)
|
|
if e != nil {
|
|
return p, e
|
|
}
|
|
rows, e := s.db.QueryContext(ctx, `SELECT ms.id,ms.name,ms.description,ms.created_at,ms.updated_at FROM monitor_services ms JOIN status_page_services ps ON ps.service_id=ms.id WHERE ps.page_id=? ORDER BY ps.sort_order,ms.name`, p.ID)
|
|
if e != nil {
|
|
return p, e
|
|
}
|
|
for rows.Next() {
|
|
var g ProbeGroup
|
|
if e = rows.Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); e != nil {
|
|
_ = rows.Close()
|
|
return p, e
|
|
}
|
|
g.Monitors = []Monitor{}
|
|
p.Services = append(p.Services, g)
|
|
p.ServiceIDs = append(p.ServiceIDs, g.ID)
|
|
}
|
|
if e = rows.Err(); e != nil {
|
|
_ = rows.Close()
|
|
return p, e
|
|
}
|
|
if e = rows.Close(); e != nil {
|
|
return p, e
|
|
}
|
|
|
|
all, e := s.List(ctx)
|
|
if e != nil {
|
|
return p, e
|
|
}
|
|
byService := map[int64][]Monitor{}
|
|
for _, m := range all {
|
|
if m.ServiceID != nil {
|
|
byService[*m.ServiceID] = append(byService[*m.ServiceID], m)
|
|
}
|
|
}
|
|
for gi := range p.Services {
|
|
p.Services[gi].Monitors = byService[p.Services[gi].ID]
|
|
if p.Services[gi].Monitors == nil {
|
|
p.Services[gi].Monitors = []Monitor{}
|
|
}
|
|
p.Services[gi].Status = aggregateStatus(p.Services[gi].Monitors)
|
|
}
|
|
p.Status = aggregateServiceStatuses(p.Services)
|
|
return p, nil
|
|
}
|
|
|
|
func aggregateServiceStatuses(groups []ProbeGroup) string {
|
|
if len(groups) == 0 {
|
|
return "unknown"
|
|
}
|
|
hasMaint, hasPending, hasPaused := false, false, false
|
|
for _, g := range groups {
|
|
switch g.Status {
|
|
case "down":
|
|
return "down"
|
|
case "maintenance":
|
|
hasMaint = true
|
|
case "pending", "unknown":
|
|
hasPending = true
|
|
case "paused":
|
|
hasPaused = true
|
|
}
|
|
}
|
|
if hasMaint {
|
|
return "maintenance"
|
|
}
|
|
if hasPending {
|
|
return "pending"
|
|
}
|
|
if hasPaused {
|
|
return "paused"
|
|
}
|
|
return "up"
|
|
}
|