Simple homelab monitoring service
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial rough commit

Adds:

- Basic database schema and pragmas for the service,
- Basic HTTP API for adding services to monitor, and getting service statuses,
- Basic service structure

Some files are currently unused, and there is some commented out code. This will get used in future commits.

Chris Walker (May 30, 2026, 10:02 AM +0100) bb99ea07

+1095
+3
.gitignore
··· 1 + .DS_Store 2 + bin/ 3 + tmp/
+1
README.md
··· 1 + # mon
+26
db/config.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + 7 + "tangled.org/chrismwalker.net/mon/models" 8 + ) 9 + 10 + func (d *DB) GetConfig() (*models.Config, error) { 11 + // This returns a single row. 12 + row := d.readDB.QueryRow("SELECT * FROM config") 13 + 14 + var cfg models.Config 15 + 16 + if err := row.Scan(&cfg.PollingInterval); err != nil { 17 + if err == sql.ErrNoRows { 18 + return nil, fmt.Errorf("no config row found") 19 + } 20 + 21 + return nil, err 22 + } 23 + 24 + return &cfg, nil 25 + 26 + }
+91
db/database.go
··· 1 + /* 2 + Package db handles all database operations (currently against SQLite). 3 + */ 4 + package db 5 + 6 + import ( 7 + "database/sql" 8 + _ "embed" 9 + "runtime" 10 + 11 + "log/slog" 12 + 13 + _ "modernc.org/sqlite" 14 + ) 15 + 16 + // DB holds the created SQLite DB structure. 17 + type DB struct { 18 + logger *slog.Logger 19 + writeDB *sql.DB 20 + readDB *sql.DB 21 + } 22 + 23 + //go:embed sql/schema.sql 24 + var schema string 25 + 26 + //go:embed sql/pragmas.sql 27 + var pragmas string 28 + 29 + // New opens the supplied SQLite database, and applies the base 30 + // schema if needed. 31 + func New(path string, logger *slog.Logger) (*DB, error) { 32 + // Create the write DB first. 33 + writeDB, err := openDB(path) 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + // Modify SQLite defaults via pragmas. 39 + if _, err := writeDB.Exec(pragmas); err != nil { 40 + return nil, err 41 + } 42 + 43 + // Check if any tables exist and create if not. 44 + rows, err := writeDB.Query("SELECT name FROM sqlite_master WHERE type='table'") 45 + if err != nil { 46 + return nil, err 47 + } 48 + defer rows.Close() 49 + if !rows.Next() { 50 + if _, err := writeDB.Exec(schema); err != nil { 51 + return nil, err 52 + } 53 + } 54 + 55 + writeDB.SetMaxOpenConns(1) 56 + 57 + readDB, err := openDB(path) 58 + if err != nil { 59 + return nil, err 60 + } 61 + // More open connections for reading. 62 + readDB.SetMaxOpenConns(max(4, runtime.NumCPU())) 63 + 64 + db := &DB{ 65 + logger: logger, 66 + writeDB: writeDB, 67 + readDB: readDB, 68 + } 69 + 70 + return db, nil 71 + } 72 + 73 + // openDB tries to open the supplied database. 74 + func openDB(path string) (*sql.DB, error) { 75 + db, err := sql.Open("sqlite", path) 76 + if err != nil { 77 + return nil, err 78 + } 79 + if err := db.Ping(); err != nil { 80 + return nil, err 81 + } 82 + 83 + return db, nil 84 + } 85 + 86 + // Close closes the read and write database instances. It should 87 + // be called on a graceful server shutdown. 88 + func (db DB) Close() { 89 + db.writeDB.Close() 90 + db.readDB.Close() 91 + }
+91
db/database_test.go
··· 1 + package db 2 + 3 + import ( 4 + "io" 5 + "os" 6 + "path" 7 + "strings" 8 + "testing" 9 + 10 + "log/slog" 11 + ) 12 + 13 + func TestNew(t *testing.T) { 14 + testCases := map[string]struct { 15 + path string 16 + err string 17 + cleanup bool 18 + }{ 19 + "can_create": { 20 + path: path.Join(os.TempDir(), "shouldwork.db"), 21 + cleanup: true, 22 + }, 23 + "cannot_create": { 24 + path: "/etc/shouldfail.db", 25 + err: "unable to open database file", 26 + }, 27 + } 28 + 29 + logger := slog.New(slog.DiscardHandler) 30 + for name, tc := range testCases { 31 + t.Run(name, func(t *testing.T) { 32 + db, err := New(tc.path, logger) 33 + if tc.err != "" { 34 + if err == nil { 35 + t.Errorf("expected an error, got nil") 36 + return 37 + } 38 + if !strings.Contains(err.Error(), tc.err) { 39 + t.Errorf("got error '%s', want'%s'", err, tc.err) 40 + } 41 + return 42 + } 43 + 44 + if tc.cleanup { 45 + db.Close() 46 + err := os.Remove(tc.path) 47 + if err != nil { 48 + t.Fatalf("could not delete test file: %q", err) 49 + } 50 + } 51 + }) 52 + } 53 + } 54 + 55 + // newDB is a helper function that creates the new test database, 56 + // and provides teardown for it between tests. Given the amount 57 + // of data we deal with in tests, and the speed of SQLite, we just 58 + // use the in-memory database directly in tests. 59 + // TODO - will get used in other tests. 60 + func newDB(dbpath string, testfile string, t *testing.T) *DB { 61 + logger := slog.New(slog.DiscardHandler) 62 + db, err := New(dbpath, logger) 63 + if err != nil { 64 + t.Error(err) 65 + } 66 + 67 + // Mild hack for tests - using in-memory DB means we get two 68 + // separate databases for read/write, not one as for file-based 69 + // databases - so overwrite the default initialised readDB with 70 + // the write one. 71 + db.readDB = db.writeDB 72 + 73 + if len(testfile) > 0 { 74 + f, err := os.Open(path.Join("./testdata", testfile)) 75 + if err != nil { 76 + t.Fatalf("could not open test file '%s': %v", testfile, err) 77 + } 78 + defer f.Close() 79 + 80 + b, err := io.ReadAll(f) 81 + if err != nil { 82 + t.Fatalf("unable to read test file '%s': %v", testfile, err) 83 + } 84 + 85 + if _, err := db.writeDB.Exec(string(b)); err != nil { 86 + t.Fatalf("unable to populate database with test data: %v", err) 87 + } 88 + } 89 + 90 + return db 91 + }
+98
db/services.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + 9 + "tangled.org/chrismwalker.net/mon/models" 10 + ) 11 + 12 + func (d *DB) SaveServiceList(services []models.Service) error { 13 + tx, err := d.writeDB.BeginTx(context.Background(), nil) 14 + if err != nil { 15 + return err 16 + } 17 + defer tx.Rollback() 18 + 19 + values := make([]string, len(services)) 20 + for i, s := range services { 21 + var headers []string 22 + for hdr, val := range s.Headers { 23 + headers = append(headers, fmt.Sprintf("%s,%s", hdr, val)) 24 + } 25 + values[i] = fmt.Sprintf("('%s','%s','%s')", 26 + s.Name, 27 + s.URL, 28 + strings.Join(headers, "|")) 29 + } 30 + 31 + stmt := fmt.Sprintf("INSERT INTO services (NAME, URL, HEADERS) VALUES %s", 32 + strings.Join(values, ",")) 33 + _, err = tx.Exec(stmt) 34 + if err != nil { 35 + return fmt.Errorf("error saving services: %v", err) 36 + } 37 + 38 + if err := tx.Commit(); err != nil { 39 + return err 40 + } 41 + 42 + return nil 43 + } 44 + 45 + func (d *DB) ListServices() ([]models.Service, error) { 46 + rows, err := d.readDB.Query("SELECT * FROM services") 47 + if err != nil { 48 + return nil, err 49 + } 50 + defer rows.Close() 51 + 52 + var svcList []models.Service 53 + for rows.Next() { 54 + var svc models.Service 55 + 56 + var headers string 57 + err := rows.Scan(&svc.ID, &svc.Name, &svc.URL, &headers) 58 + if err != nil { 59 + return nil, err 60 + } 61 + if len(headers) > 0 { 62 + svc.Headers = parseHeaders(headers) 63 + } 64 + svcList = append(svcList, svc) 65 + } 66 + 67 + return svcList, nil 68 + } 69 + 70 + func (d *DB) GetService(id int) (*models.Service, error) { 71 + row := d.readDB.QueryRow("SELECT * FROM services WHERE id=?", id) 72 + 73 + var svc models.Service 74 + 75 + var headers string 76 + if err := row.Scan(&svc.ID, &svc.Name, &svc.URL, &headers); err != nil { 77 + if err == sql.ErrNoRows { 78 + return nil, fmt.Errorf("no post with id [%d]", id) 79 + } 80 + 81 + return nil, err 82 + } 83 + 84 + if len(headers) > 0 { 85 + svc.Headers = parseHeaders(headers) 86 + } 87 + 88 + return &svc, nil 89 + } 90 + 91 + func parseHeaders(headers string) map[string]string { 92 + m := make(map[string]string) 93 + for h := range strings.SplitSeq(headers, "|") { 94 + hdrElems := strings.Split(h, ",") 95 + m[hdrElems[0]] = hdrElems[1] 96 + } 97 + return m 98 + }
+23
db/sql/pragmas.sql
··· 1 + -- 2 + -- Change some SQLite defaults for better performance. 3 + -- 4 + 5 + -- Use the Write-Ahead Log; allows concurrent reads and writes. 6 + PRAGMA journal_mode = wal; 7 + 8 + -- Set synchronising to NORMAL alonside use of WAL. 9 + PRAGMA synchronous = normal; 10 + 11 + -- Set a longer busy timeout (in ms). 12 + PRAGMA busy_timeout = 3000; 13 + 14 + -- Enforce foreign keys. 15 + PRAGMA foreign_keys = true; 16 + 17 + -- Set memory cache size. 18 + PRAGMA cache_size = -20000; 19 + 20 + -- Store temporary tables/data in-memory. 21 + PRAGMA temp_store = memory; 22 + 23 +
+23
db/sql/schema.sql
··· 1 + -- config represents the application configuration. 2 + CREATE TABLE IF NOT EXISTS config ( 3 + POLLING_INTERVAL INTEGER NOT NULL DEFAULT 300 4 + ); 5 + 6 + -- services represents the list of services mon will check. 7 + CREATE TABLE IF NOT EXISTS services ( 8 + ID INTEGER PRIMARY KEY, 9 + NAME TEXT NOT NULL, 10 + URL TEXT NOT NULL, 11 + HEADERS TEXT 12 + ); 13 + 14 + -- service_status stores historical status checks. 15 + CREATE TABLE IF NOT EXISTS service_status ( 16 + ID INTEGER PRIMARY KEY, 17 + -- Make this primary key alongside ID? 18 + SERVICE_ID INTEGER NOT NULL, 19 + DURATION INTEGER NOT NULL, 20 + HTTP_STATUS TEXT NOT NULL, 21 + TIMESTAMP INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 22 + ); 23 +
+84
db/status.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "tangled.org/chrismwalker.net/mon/models" 10 + ) 11 + 12 + // TODO: Prepared statement for this? 13 + func (d *DB) SaveStatusList(statuses []models.Status) error { 14 + tx, err := d.writeDB.BeginTx(context.Background(), nil) 15 + if err != nil { 16 + return err 17 + } 18 + defer tx.Rollback() 19 + 20 + values := make([]string, len(statuses)) 21 + for i, s := range statuses { 22 + values[i] = fmt.Sprintf("(%d,%d,%d,%d)", 23 + s.ServiceID, 24 + s.Duration.Milliseconds(), 25 + s.HTTPStatus, 26 + s.Timestamp.UnixMilli()) 27 + } 28 + 29 + stmt := fmt.Sprintf("INSERT INTO service_status (SERVICE_ID, DURATION, HTTP_STATUS, TIMESTAMP) VALUES %s", 30 + strings.Join(values, ",")) 31 + _, err = tx.Exec(stmt) 32 + if err != nil { 33 + return fmt.Errorf("error saving status: %v", err) 34 + } 35 + 36 + if err := tx.Commit(); err != nil { 37 + return err 38 + } 39 + 40 + return nil 41 + } 42 + 43 + func (d *DB) GetStatusList(limit int) ([]models.ServiceStatus, error) { 44 + stmt := ` 45 + SELECT s.ID, s.NAME, s.URL, st.ID AS STATUS_ID, st.HTTP_STATUS, st.DURATION, st.TIMESTAMP 46 + FROM services AS s 47 + INNER JOIN service_status AS st 48 + ON s.ID = st.SERVICE_ID; 49 + ` 50 + 51 + rows, err := d.readDB.Query(stmt) 52 + if err != nil { 53 + return nil, err 54 + } 55 + defer rows.Close() 56 + 57 + var svcStatusList []models.ServiceStatus 58 + for rows.Next() { 59 + var svcStatus models.ServiceStatus 60 + 61 + var duration int 62 + var timestamp int64 63 + err := rows.Scan(&svcStatus.ServiceID, 64 + &svcStatus.Name, 65 + &svcStatus.URL, 66 + &svcStatus.StatusID, 67 + &svcStatus.HTTPStatus, 68 + &duration, 69 + &timestamp, 70 + ) 71 + if err != nil { 72 + return nil, err 73 + } 74 + 75 + // Duration - TODO - might be wrong! 76 + svcStatus.Duration = time.Duration(duration) 77 + // Timestamp 78 + svcStatus.Timestamp = time.UnixMilli(timestamp).UTC() 79 + 80 + svcStatusList = append(svcStatusList, svcStatus) 81 + } 82 + 83 + return svcStatusList, nil 84 + }
+16
go.mod
··· 1 + module tangled.org/chrismwalker.net/mon 2 + 3 + go 1.26.3 4 + 5 + require ( 6 + github.com/dustin/go-humanize v1.0.1 // indirect 7 + github.com/google/uuid v1.6.0 // indirect 8 + github.com/mattn/go-isatty v0.0.20 // indirect 9 + github.com/ncruces/go-strftime v1.0.0 // indirect 10 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 11 + golang.org/x/sys v0.42.0 // indirect 12 + modernc.org/libc v1.72.3 // indirect 13 + modernc.org/mathutil v1.7.1 // indirect 14 + modernc.org/memory v1.11.0 // indirect 15 + modernc.org/sqlite v1.50.1 // indirect 16 + )
+21
go.sum
··· 1 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 2 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 4 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 5 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 6 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 7 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 8 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 9 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 10 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 11 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 12 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 13 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 14 + modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= 15 + modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 16 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 17 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 18 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 19 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 20 + modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= 21 + modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+36
models/models.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Config struct { 6 + PollingInterval int `json:"polling_interval"` 7 + } 8 + 9 + type Service struct { 10 + ID int `json:"id"` 11 + Name string `json:"name"` 12 + URL string `json:"url"` 13 + Headers map[string]string `json:"headers,omitempty"` 14 + } 15 + 16 + // Status repesents a single service status being written 17 + // to the database. 18 + type Status struct { 19 + ID int 20 + ServiceID int 21 + Duration time.Duration 22 + HTTPStatus int 23 + Timestamp time.Time 24 + } 25 + 26 + // ServiceStatus represents a join between the SERVICE and 27 + // SERVICE_STATUS tables. 28 + type ServiceStatus struct { 29 + ServiceID int `json:"service_id"` 30 + StatusID int `json:"status_id"` 31 + Name string `json:"name"` 32 + URL string `json:"url"` 33 + Duration time.Duration `json:"duration"` 34 + HTTPStatus int `json:"http_status"` 35 + Timestamp time.Time `json:"timestamp"` 36 + }
+33
mon.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + 8 + "tangled.org/chrismwalker.net/mon/web" 9 + ) 10 + 11 + func main() { 12 + 13 + dir, err := os.UserConfigDir() 14 + if err != nil { 15 + fmt.Fprintf(os.Stderr, "unable to get user configuration directory: %v", err) 16 + os.Exit(1) 17 + } 18 + fullPath := filepath.Join(dir, "mon") 19 + _, err = os.Stat(fullPath) 20 + if os.IsNotExist(err) { 21 + if err := os.Mkdir(fullPath, 0700); err != nil { 22 + fmt.Fprintf(os.Stderr, 23 + "unable to create configuraton folder at '%s': %v\n", fullPath, err) 24 + os.Exit(1) 25 + } 26 + } 27 + dbPath := filepath.Join(fullPath, "mon.db") 28 + 29 + if err := web.Start(dbPath); err != nil { 30 + fmt.Fprintf(os.Stderr, "mon: unable to start server: %v\n", err) 31 + os.Exit(1) 32 + } 33 + }
+51
run.sh
··· 1 + #!/usr/bin/env bash 2 + 3 + lint() { 4 + golangci-lint run ./... 5 + } 6 + 7 + build() { 8 + go build -o bin/mon . 9 + } 10 + 11 + test() { 12 + go test -test.count=1 -cover ./... 13 + } 14 + 15 + coverage() { 16 + local tmp=/tmp/domus-profile.out 17 + go test -coverprofile="${tmp}" ./... >/dev/null 18 + go tool cover -func="${tmp}" 19 + rm -f "${tmp}" 20 + } 21 + 22 + run() { 23 + ./bin/mon 24 + } 25 + 26 + # From a fresh database, seeds some test configuration and 27 + # services. 28 + setup() { 29 + curl -XPOST http://localhost:8080/services -d @tmp/services.json 30 + } 31 + 32 + help() { 33 + printf "%s <task> <args>\n" "$0" 34 + printf "Tasks:\n" 35 + compgen -A function | cat -n 36 + } 37 + 38 + die() { 39 + printf "%s\n" "$@" 40 + exit 1 41 + } 42 + 43 + action="$1" 44 + case $action in 45 + lint | build | build-server | run | setup | test | coverage | help) 46 + "$@" 47 + ;; 48 + *) 49 + die "invalid action '${action}'" 50 + ;; 51 + esac
+161
services/monitor.go
··· 1 + // Package service contains the primary business logic for the 2 + // Domus server. 3 + package services 4 + 5 + import ( 6 + "log/slog" 7 + "net/http" 8 + "sync" 9 + "time" 10 + 11 + "tangled.org/chrismwalker.net/mon/db" 12 + "tangled.org/chrismwalker.net/mon/models" 13 + ) 14 + 15 + // storageReader specifies all functions required for retrieving 16 + // data from the database. This will probably devolve into data- 17 + // specific interfaces (e.g. postGetter etc). 18 + type storageReader interface { 19 + ListServices() ([]models.Service, error) 20 + GetService(id int) (*models.Service, error) 21 + GetConfig() (*models.Config, error) 22 + GetStatusList(limit int) ([]models.ServiceStatus, error) 23 + } 24 + 25 + // storageWriter specifies all functions required for writing data 26 + // to the database. This will also probably devolve into data-specific 27 + // interfaces. 28 + type storageWriter interface { 29 + // CreateService(models.Service) error 30 + SaveServiceList([]models.Service) error 31 + SaveStatusList([]models.Status) error 32 + } 33 + 34 + // storageReadWriter is a compound interface wrapping the reader 35 + // and writer interfaces. 36 + type storageReadWriter interface { 37 + storageReader 38 + storageWriter 39 + } 40 + 41 + // Monitor encapsulates the business logic of the Monitoring 42 + // application. 43 + type Monitor struct { 44 + db storageReadWriter 45 + logger *slog.Logger 46 + } 47 + 48 + // New creates a new service object, complete 49 + // with configured SQLite database. 50 + func New(dbPath string, logger *slog.Logger) (*Monitor, error) { 51 + s := &Monitor{ 52 + logger: logger, 53 + } 54 + d, err := db.New(dbPath, logger) 55 + if err != nil { 56 + return nil, err 57 + } 58 + s.db = d 59 + 60 + return s, nil 61 + } 62 + 63 + // ListServices lists the configured services for monitoring. 64 + func (s *Monitor) ListServices() ([]models.Service, error) { 65 + return s.db.ListServices() 66 + } 67 + 68 + // GetService retrieves a single service by ID. 69 + func (s *Monitor) GetService(id int) (*models.Service, error) { 70 + return s.db.GetService(id) 71 + } 72 + 73 + // CreateServices writes a list of services into the database. 74 + func (s *Monitor) CreateServices(services []models.Service) error { 75 + return s.db.SaveServiceList(services) 76 + } 77 + 78 + func (s *Monitor) GetStatusList(limit int) ([]models.ServiceStatus, error) { 79 + return s.db.GetStatusList(limit) 80 + } 81 + 82 + // CheckStatus polls the configured services every time 83 + // the polling interval expires. 84 + // TODO: doesn't respond to changes in configuration. 85 + func (s *Monitor) CheckStatus() error { 86 + // Get polling config 87 + cfg, err := s.db.GetConfig() 88 + if err != nil { 89 + return err 90 + } 91 + 92 + // TODO: handle config polling interval changes (via channel and reset 93 + // ticker). 94 + timer := time.NewTicker(time.Duration(cfg.PollingInterval) * time.Second) 95 + for range timer.C { 96 + // select { 97 + // case <-timer.C: 98 + // Get services 99 + services, err := s.db.ListServices() 100 + if err != nil { 101 + // TODO - something with errors here. 102 + } 103 + if len(services) == 0 { 104 + continue 105 + } 106 + 107 + statuses := make([]models.Status, len(services)) 108 + mu := sync.RWMutex{} 109 + 110 + var wg sync.WaitGroup 111 + for i, svc := range services { 112 + wg.Go(func() { 113 + client := http.Client{ 114 + Timeout: 2 * time.Second, 115 + } 116 + req, err := http.NewRequest(http.MethodGet, svc.URL, nil) 117 + if err != nil { 118 + s.logger.Error("error creating new request", 119 + slog.String("url", svc.URL), 120 + slog.Any("error", err)) 121 + return 122 + } 123 + if svc.Headers != nil { 124 + for k, v := range svc.Headers { 125 + req.Header.Add(k, v) 126 + } 127 + } 128 + var status models.Status 129 + start := time.Now() 130 + resp, err := client.Do(req) 131 + if err != nil { 132 + s.logger.Error("error getting URL", 133 + slog.String("url", svc.URL), 134 + slog.Any("error", err)) 135 + // Server error response OK for now; just need 136 + // to indicate a problem. 137 + status.HTTPStatus = http.StatusServiceUnavailable 138 + } 139 + status.ServiceID = svc.ID 140 + status.Duration = time.Since(start) 141 + status.Timestamp = start 142 + if resp != nil { 143 + status.HTTPStatus = resp.StatusCode 144 + } 145 + 146 + mu.Lock() 147 + statuses[i] = status 148 + mu.Unlock() 149 + }) 150 + } 151 + wg.Wait() 152 + 153 + // Write statuses to DB 154 + if err := s.db.SaveStatusList(statuses); err != nil { 155 + s.logger.Error("Unable to save service statuses", 156 + slog.Any("error", err)) 157 + } 158 + } 159 + // } 160 + return nil 161 + }
+109
web/api.go
··· 1 + package web 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "strconv" 8 + 9 + "tangled.org/chrismwalker.net/mon/models" 10 + ) 11 + 12 + // health is a basic health endpoint, callable via monitoring scripts. 13 + // This is a very basic implementation which - if a request reaches it - 14 + // simply returns (204) No Content to the caller. 15 + func (a *app) health(w http.ResponseWriter, r *http.Request) { 16 + // TODO. 17 + w.WriteHeader(http.StatusNoContent) 18 + } 19 + 20 + // index returns a graphical overview of service statuses. 21 + func (a *app) index(w http.ResponseWriter, r *http.Request) { 22 + // TODO. 23 + w.WriteHeader(http.StatusNoContent) 24 + } 25 + 26 + func (a *app) createServices(w http.ResponseWriter, r *http.Request) { 27 + var services []models.Service 28 + err := json.NewDecoder(r.Body).Decode(&services) 29 + if err != nil { 30 + // TODO 31 + http.Error(w, err.Error(), http.StatusBadRequest) 32 + return 33 + } 34 + 35 + if err := a.svc.CreateServices(services); err != nil { 36 + a.serverError(w, r, err) 37 + return 38 + } 39 + 40 + w.WriteHeader(http.StatusNoContent) 41 + 42 + } 43 + 44 + // listServices returns a list of defined services. 45 + func (a *app) listServices(w http.ResponseWriter, r *http.Request) { 46 + list, err := a.svc.ListServices() 47 + if err != nil { 48 + a.serverError(w, r, err) 49 + return 50 + } 51 + 52 + // Write back 53 + w.Header().Set("Content-Type", "application/json") 54 + w.WriteHeader(http.StatusOK) 55 + if err := json.NewEncoder(w).Encode(list); err != nil { 56 + a.serverError(w, r, err) 57 + return 58 + } 59 + } 60 + 61 + // getService gets an individual service by ID. 62 + func (a *app) getService(w http.ResponseWriter, r *http.Request) { 63 + val := r.PathValue("id") 64 + id, err := strconv.Atoi(val) 65 + if err != nil { 66 + a.logger.Error("unable to parse path ID", 67 + slog.String("id", val), 68 + slog.Any("error", err)) 69 + a.serverError(w, r, err) 70 + return 71 + } 72 + svc, err := a.svc.GetService(id) 73 + if err != nil { 74 + a.serverError(w, r, err) 75 + return 76 + } 77 + 78 + w.Header().Set("Content-Type", "application/json") 79 + w.WriteHeader(http.StatusOK) 80 + if err := json.NewEncoder(w).Encode(svc); err != nil { 81 + a.serverError(w, r, err) 82 + return 83 + } 84 + } 85 + 86 + func (a *app) getStatusList(w http.ResponseWriter, r *http.Request) { 87 + val := r.URL.Query().Get("limit") 88 + limit, err := strconv.Atoi(val) 89 + if err != nil { 90 + a.logger.Error("unable to limit query parameter", 91 + slog.String("limit", val), 92 + slog.Any("error", err)) 93 + a.serverError(w, r, err) 94 + return 95 + } 96 + 97 + statuses, err := a.svc.GetStatusList(limit) 98 + if err != nil { 99 + a.serverError(w, r, err) 100 + return 101 + } 102 + 103 + w.Header().Set("Content-Type", "application/json") 104 + w.WriteHeader(http.StatusOK) 105 + if err := json.NewEncoder(w).Encode(statuses); err != nil { 106 + a.serverError(w, r, err) 107 + return 108 + } 109 + }
+25
web/error.go
··· 1 + package web 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + // serverError logs an error and returns an Internal Server Error 8 + // response. 9 + func (a *app) serverError(w http.ResponseWriter, r *http.Request, err error) { 10 + a.logger.Error(err.Error(), 11 + "uri", r.URL.RequestURI(), 12 + "method", r.Method) 13 + clientError(w, http.StatusInternalServerError) 14 + } 15 + 16 + // clientError returns the supplied error status to the client. 17 + func clientError(w http.ResponseWriter, status int) { 18 + http.Error(w, http.StatusText(status), status) 19 + } 20 + 21 + // notFound returns a Not Found error response. 22 + //nolint:unused 23 + func (a *app) notFoundError(w http.ResponseWriter) { 24 + clientError(w, http.StatusNotFound) 25 + }
+50
web/error_test.go
··· 1 + package web 2 + 3 + import ( 4 + "bytes" 5 + "errors" 6 + "net/http" 7 + "net/http/httptest" 8 + "net/url" 9 + "testing" 10 + 11 + "log/slog" 12 + ) 13 + 14 + func TestServerError(t *testing.T) { 15 + var b []byte 16 + buf := bytes.NewBuffer(b) 17 + app := &app{ 18 + logger: slog.New(slog.NewJSONHandler(buf, nil)), 19 + } 20 + 21 + r := &http.Request{ 22 + URL: &url.URL{ 23 + Scheme: "https", 24 + Host: "example.org", 25 + }, 26 + Method: http.MethodPost, 27 + } 28 + w := httptest.NewRecorder() 29 + 30 + app.serverError(w, r, errors.New("an error occurred")) 31 + 32 + if w.Code != http.StatusInternalServerError { 33 + t.Errorf("got status code '%d', want '%d'", 34 + w.Code, http.StatusInternalServerError) 35 + } 36 + } 37 + 38 + func TestNotFoundError(t *testing.T) { 39 + app := &app{ 40 + logger: slog.New(slog.DiscardHandler), 41 + } 42 + 43 + w := &httptest.ResponseRecorder{} 44 + app.notFoundError(w) 45 + 46 + if w.Code != http.StatusNotFound { 47 + t.Errorf("got status code '%d', want '%d'", 48 + w.Code, http.StatusNotFound) 49 + } 50 + }
+153
web/server.go
··· 1 + /* 2 + Package web contains the main application server, which handles 3 + the server routes, middleware, handlers and associated HTML 4 + templates. 5 + */ 6 + package web 7 + 8 + import ( 9 + "fmt" 10 + "html/template" 11 + "log/slog" 12 + "net/http" 13 + "os" 14 + "sync" 15 + 16 + "tangled.org/chrismwalker.net/mon/models" 17 + "tangled.org/chrismwalker.net/mon/services" 18 + ) 19 + 20 + type monitorService interface { 21 + // TODO - might not need all of these in tests. 22 + ListServices() ([]models.Service, error) 23 + GetService(id int) (*models.Service, error) 24 + CreateServices(services []models.Service) error 25 + GetStatusList(limit int) ([]models.ServiceStatus, error) 26 + CheckStatus() error 27 + } 28 + 29 + // app is the basic application object, which stores business 30 + // logic services, parsed templates and the application logger. 31 + type app struct { 32 + svc monitorService 33 + templates map[string]*template.Template 34 + logger *slog.Logger 35 + mu sync.Mutex 36 + } 37 + 38 + // TODO 39 + // go:embed static 40 + // var static embed.FS 41 + 42 + // Start configures a new server object for handling requests 43 + // and starts the HTTP server up. 44 + func Start(dbPath string) error { 45 + opts := &slog.HandlerOptions{ 46 + Level: slog.LevelDebug, 47 + } 48 + app := &app{ 49 + logger: slog.New(slog.NewJSONHandler(os.Stdout, opts)), 50 + } 51 + 52 + svc, err := services.New(dbPath, app.logger) 53 + if err != nil { 54 + return err 55 + } 56 + app.svc = svc 57 + 58 + // Templates. 59 + // TODO 60 + // t, err := app.loadTemplates() 61 + // if err != nil { 62 + // return err 63 + // } 64 + // app.templates = t 65 + 66 + mux := http.NewServeMux() 67 + 68 + // Set up static assets. 69 + // TODO 70 + // fs, err := fs.Sub(static, "static") 71 + // if err != nil { 72 + // return err 73 + // } 74 + // fsrv := http.FileServer(http.FS(fs)) 75 + // mux.Handle("/static/", http.StripPrefix("/static/", fsrv)) 76 + // mux.Handle("/images/", http.StripPrefix("/images/", fsrv)) 77 + 78 + // API routes. 79 + mux.HandleFunc("POST /services", app.createServices) 80 + mux.HandleFunc("GET /service", app.listServices) 81 + mux.HandleFunc("GET /service/{id}", app.getService) 82 + mux.HandleFunc("GET /status", app.getStatusList) 83 + mux.HandleFunc("GET /health", app.health) 84 + 85 + // Start service poller. 86 + go app.svc.CheckStatus() 87 + 88 + addr := ":8080" 89 + srv := http.Server{ 90 + Addr: addr, 91 + Handler: mux, 92 + } 93 + app.logger.Info(fmt.Sprintf("Listening on %s...", addr)) 94 + return srv.ListenAndServe() 95 + } 96 + 97 + // go:embed templates 98 + // var templates embed.FS 99 + 100 + // loadTemplates walks the templates directory, and creates 101 + // templates from each file found there. 102 + // func (a *app) loadTemplates() (map[string]*template.Template, error) { 103 + // tmpls := map[string]*template.Template{} 104 + 105 + // pages, err := fs.Glob(templates, "templates/content/*.tmpl") 106 + // if err != nil { 107 + // a.logger.Error("unable to set up file system subtree", 108 + // "err", err.Error()) 109 + // os.Exit(1) 110 + // } 111 + 112 + // funcMap := template.FuncMap{ 113 + // "humanDate": func(t time.Time) string { 114 + // f := "02 Jan 2006" 115 + // sub := time.Since(t) 116 + // if sub.Hours() < float64(24) { 117 + // f = "15:04" 118 + // } 119 + // return t.Format(f) 120 + // }, 121 + // } 122 + 123 + // for _, page := range pages { 124 + // name := filepath.Base(page) 125 + // patterns := []string{ 126 + // "templates/main.tmpl", 127 + // "templates/content/*.tmpl", 128 + // "templates/partials/*.tmpl", 129 + // page, 130 + // } 131 + 132 + // ts, err := template.New(name).Funcs(funcMap).ParseFS(templates, patterns...) 133 + // if err != nil { 134 + // a.logger.Error("unable create template", 135 + // "name", name, 136 + // "err", err.Error()) 137 + // os.Exit(1) 138 + // } 139 + 140 + // tmpls[name] = ts 141 + // } 142 + 143 + // return tmpls, nil 144 + // } 145 + 146 + // formatResponseCode is a utility method to generate a nice 147 + // string for a HTTP response code. Given a code such as 405, 148 + // it will return "(405) Method Not Allowed". 149 + // 150 + //nolint:unused 151 + func formatResponseCode(code int) string { 152 + return fmt.Sprintf("(%d) %s", code, http.StatusText(code)) 153 + }