init work of uptime
This commit is contained in:
343
internal/store/store.go
Normal file
343
internal/store/store.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"arclineit/arcline-uptime/internal/monitor"
|
||||
)
|
||||
|
||||
// Store wraps the SQLite database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// CheckRow is a row read back from the checks table.
|
||||
type CheckRow struct {
|
||||
ID int64
|
||||
MonitorName string
|
||||
CheckedAt time.Time
|
||||
Up bool
|
||||
StatusCode int
|
||||
ResponseMS int64
|
||||
Error string
|
||||
}
|
||||
|
||||
// AlertRow is a row from the alerts_sent table.
|
||||
type AlertRow struct {
|
||||
ID int64
|
||||
MonitorName string
|
||||
SentAt time.Time
|
||||
Kind string // "down" | "up"
|
||||
}
|
||||
|
||||
// Incident represents a period during which a monitor was down.
|
||||
type Incident struct {
|
||||
MonitorName string
|
||||
StartedAt time.Time
|
||||
ResolvedAt time.Time // zero if still ongoing
|
||||
}
|
||||
|
||||
func (i Incident) Duration() time.Duration {
|
||||
if i.ResolvedAt.IsZero() {
|
||||
return time.Since(i.StartedAt)
|
||||
}
|
||||
return i.ResolvedAt.Sub(i.StartedAt)
|
||||
}
|
||||
|
||||
func (i Incident) Ongoing() bool { return i.ResolvedAt.IsZero() }
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and migrates the schema.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
// SQLite does not support concurrent writers; serialise through one connection.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_name TEXT NOT NULL,
|
||||
checked_at INTEGER NOT NULL,
|
||||
up INTEGER NOT NULL,
|
||||
status_code INTEGER NOT NULL DEFAULT 0,
|
||||
response_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_monitor_time
|
||||
ON checks (monitor_name, checked_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_sent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
monitor_name TEXT NOT NULL,
|
||||
sent_at INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_monitor
|
||||
ON alerts_sent (monitor_name, sent_at DESC);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Writes ---
|
||||
|
||||
// SaveResult persists one check result.
|
||||
func (s *Store) SaveResult(r monitor.Result) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO checks (monitor_name, checked_at, up, status_code, response_ms, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
r.MonitorName,
|
||||
r.CheckedAt.Unix(),
|
||||
boolToInt(r.Up),
|
||||
r.StatusCode,
|
||||
r.ResponseTime.Milliseconds(),
|
||||
r.Error,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordAlertSent writes a row indicating an alert of kind ("down"|"up") was sent.
|
||||
func (s *Store) RecordAlertSent(monitorName, kind string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO alerts_sent (monitor_name, sent_at, kind) VALUES (?, ?, ?)`,
|
||||
monitorName, time.Now().Unix(), kind,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Prune deletes check and alert rows older than retentionDays.
|
||||
func (s *Store) Prune(retentionDays int) (int64, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays).Unix()
|
||||
res, err := s.db.Exec(`DELETE FROM checks WHERE checked_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
|
||||
res2, err := s.db.Exec(`DELETE FROM alerts_sent WHERE sent_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
n2, _ := res2.RowsAffected()
|
||||
return n + n2, nil
|
||||
}
|
||||
|
||||
// --- Single-monitor reads ---
|
||||
|
||||
// LatestResult returns the most recent check for the given monitor, or nil if none.
|
||||
func (s *Store) LatestResult(monitorName string) (*CheckRow, error) {
|
||||
row := s.db.QueryRow(
|
||||
`SELECT id, monitor_name, checked_at, up, status_code, response_ms, error
|
||||
FROM checks WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT 1`,
|
||||
monitorName,
|
||||
)
|
||||
return scanCheckRow(row)
|
||||
}
|
||||
|
||||
// LastAlertSent returns the time of the most recent alert of the given kind.
|
||||
// Returns zero time if no such alert exists.
|
||||
func (s *Store) LastAlertSent(monitorName, kind string) (time.Time, error) {
|
||||
var ts int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT sent_at FROM alerts_sent WHERE monitor_name = ? AND kind = ?
|
||||
ORDER BY sent_at DESC LIMIT 1`,
|
||||
monitorName, kind,
|
||||
).Scan(&ts)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.Unix(ts, 0), nil
|
||||
}
|
||||
|
||||
// RecentChecks returns the most recent limit checks for a monitor, newest first.
|
||||
func (s *Store) RecentChecks(monitorName string, limit int) ([]CheckRow, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, monitor_name, checked_at, up, status_code, response_ms, error
|
||||
FROM checks WHERE monitor_name = ? ORDER BY checked_at DESC LIMIT ?`,
|
||||
monitorName, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CheckRow
|
||||
for rows.Next() {
|
||||
r, err := scanCheckRowFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UptimePercent returns the percentage of successful checks since `since`.
|
||||
// Returns 100.0 if no checks exist yet.
|
||||
func (s *Store) UptimePercent(monitorName string, since time.Time) (float64, error) {
|
||||
var total, upCount int64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT COUNT(*), COALESCE(SUM(up), 0)
|
||||
FROM checks WHERE monitor_name = ? AND checked_at >= ?`,
|
||||
monitorName, since.Unix(),
|
||||
).Scan(&total, &upCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return 100.0, nil
|
||||
}
|
||||
return float64(upCount) / float64(total) * 100.0, nil
|
||||
}
|
||||
|
||||
// --- Multi-monitor reads ---
|
||||
|
||||
// AllMonitorNames returns all distinct monitor names that have check records.
|
||||
func (s *Store) AllMonitorNames() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT monitor_name FROM checks ORDER BY monitor_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
// AlertsSent returns all alert rows since `since`, ordered oldest-first.
|
||||
func (s *Store) AlertsSent(since time.Time) ([]AlertRow, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, monitor_name, sent_at, kind FROM alerts_sent
|
||||
WHERE sent_at >= ? ORDER BY monitor_name, sent_at ASC`,
|
||||
since.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AlertRow
|
||||
for rows.Next() {
|
||||
var a AlertRow
|
||||
var ts int64
|
||||
if err := rows.Scan(&a.ID, &a.MonitorName, &ts, &a.Kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.SentAt = time.Unix(ts, 0)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Incidents pairs down/up alert rows into Incident structs, sorted newest-first.
|
||||
// Ongoing incidents (no matching up alert) are included with a zero ResolvedAt.
|
||||
func (s *Store) Incidents(limit int) ([]Incident, error) {
|
||||
rows, err := s.AlertsSent(time.Time{}) // all time
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type openIncident struct{ start time.Time }
|
||||
open := make(map[string]*openIncident)
|
||||
var incidents []Incident
|
||||
|
||||
for _, a := range rows {
|
||||
switch a.Kind {
|
||||
case "down":
|
||||
if _, already := open[a.MonitorName]; !already {
|
||||
open[a.MonitorName] = &openIncident{start: a.SentAt}
|
||||
}
|
||||
case "up":
|
||||
if o, ok := open[a.MonitorName]; ok {
|
||||
incidents = append(incidents, Incident{
|
||||
MonitorName: a.MonitorName,
|
||||
StartedAt: o.start,
|
||||
ResolvedAt: a.SentAt,
|
||||
})
|
||||
delete(open, a.MonitorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining open entries are ongoing incidents.
|
||||
for name, o := range open {
|
||||
incidents = append(incidents, Incident{
|
||||
MonitorName: name,
|
||||
StartedAt: o.start,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort newest-first.
|
||||
sort.Slice(incidents, func(i, j int) bool {
|
||||
return incidents[i].StartedAt.After(incidents[j].StartedAt)
|
||||
})
|
||||
|
||||
if limit > 0 && len(incidents) > limit {
|
||||
incidents = incidents[:limit]
|
||||
}
|
||||
return incidents, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func scanCheckRow(row *sql.Row) (*CheckRow, error) {
|
||||
var r CheckRow
|
||||
var ts int64
|
||||
var up int64
|
||||
err := row.Scan(&r.ID, &r.MonitorName, &ts, &up, &r.StatusCode, &r.ResponseMS, &r.Error)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CheckedAt = time.Unix(ts, 0)
|
||||
r.Up = up != 0
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanCheckRowFromRows(rows *sql.Rows) (*CheckRow, error) {
|
||||
var r CheckRow
|
||||
var ts int64
|
||||
var up int64
|
||||
if err := rows.Scan(&r.ID, &r.MonitorName, &ts, &up, &r.StatusCode, &r.ResponseMS, &r.Error); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CheckedAt = time.Unix(ts, 0)
|
||||
r.Up = up != 0
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user