diff --git a/Dockerfile b/Dockerfile index 0b9604f..f7f6d69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,18 +10,23 @@ COPY client/web ./ RUN npm run build FROM golang:1.26-alpine AS build +# the version the binaries report, for example +# docker build --build-arg VERSION=$(git describe --tags --always --dirty) ... +# .git is not copied in, so without it they report "dev" +ARG VERSION= +ENV LDFLAGS="-X github.com/dhamith93/SyMon/internal/version.Version=${VERSION}" WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . COPY --from=web /web/dist ./client/web/dist RUN for c in agent collector alertprocessor client; do \ - CGO_ENABLED=0 go build -o /out/$c ./$c || exit 1; \ + CGO_ENABLED=0 go build -ldflags "$LDFLAGS" -o /out/$c ./$c || exit 1; \ done # agent builds that new hosts download from the dashboard -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/downloads/agent-linux-amd64 ./agent \ - && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /out/downloads/agent-linux-arm64 ./agent \ - && CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -o /out/downloads/agent-linux-arm ./agent +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o /out/downloads/agent-linux-amd64 ./agent \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o /out/downloads/agent-linux-arm64 ./agent \ + && CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "$LDFLAGS" -o /out/downloads/agent-linux-arm ./agent FROM alpine:3.22 AS collector RUN adduser -D -H symon diff --git a/Makefile b/Makefile index dd1eb64..6f7b563 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,9 @@ .PHONY: proto web clean build-all build-collector build-agent build-alertprocessor build-client pack-all pack-collector pack-agent pack-alertprocessor pack-client +# the version -version and the dashboard show, like v3.0.0-16-g2519821 +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -ldflags "-X github.com/dhamith93/SyMon/internal/version.Version=$(VERSION)" + proto: cd internal && protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ @@ -26,16 +30,16 @@ clean: build-all: build-collector build-agent build-alertprocessor build-client build-collector: - cd collector && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o collector_linux_x86_64 + cd collector && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o collector_linux_x86_64 build-agent: - cd agent && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o agent_linux_x86_64 + cd agent && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o agent_linux_x86_64 build-alertprocessor: - cd alertprocessor && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o alertprocessor_linux_x86_64 + cd alertprocessor && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o alertprocessor_linux_x86_64 build-client: web - cd client && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o client_linux_x86_64 + cd client && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o client_linux_x86_64 pack-all: pack-collector pack-agent pack-alertprocessor pack-client @@ -66,9 +70,9 @@ pack-alertprocessor: build-alertprocessor pack-client: build-client mkdir -p release/client_linux_x86_64/downloads cp client/client_linux_x86_64 release/client_linux_x86_64 - cd agent && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ../release/client_linux_x86_64/downloads/agent-linux-amd64 - cd agent && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o ../release/client_linux_x86_64/downloads/agent-linux-arm64 - cd agent && GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build -o ../release/client_linux_x86_64/downloads/agent-linux-arm + cd agent && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o ../release/client_linux_x86_64/downloads/agent-linux-amd64 + cd agent && GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o ../release/client_linux_x86_64/downloads/agent-linux-arm64 + cd agent && GOOS=linux GOARCH=arm GOARM=6 CGO_ENABLED=0 go build $(LDFLAGS) -o ../release/client_linux_x86_64/downloads/agent-linux-arm cp client/.env-example release/client_linux_x86_64 cp client/Dockerfile release/client_linux_x86_64 cd release/ && tar -cvf client_linux_x86_64.tar.gz client_linux_x86_64 diff --git a/README.MD b/README.MD index 848b296..685383e 100644 --- a/README.MD +++ b/README.MD @@ -11,7 +11,7 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr **Hosts** - CPU, overall and per core, load average, memory and swap - Disk space, disk IO and busy time, network traffic, TCP connections -- When each disk will be full, at the rate it grew over the last week +- When each disk will be full, at the rate it grew over the last week, with a chart of where it is headed - Pressure stall information and temperature sensors - The top processes by CPU and memory at any point in time, and the programs that used the most over any range - Whether chosen systemd services are running @@ -20,6 +20,9 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr - Every running container on a host: Docker, Podman, containerd, LXC and systemd-nspawn - CPU, memory, network and disk IO per container, grouped by Compose project +**Endpoints** +- HTTP checks of any URL from the server, with response time and uptime history + **Custom metrics** - Send any number from a script or cron job and get a chart for it @@ -37,6 +40,7 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr - Raw data kept for 7 days, 1 minute averages for 30 days and 1 hour averages for a year, all adjustable - Each host has its own key, and components can talk over TLS - A Prometheus endpoint, for Grafana or a Prometheus you already run +- Every part reports its version: `-version` on each binary, the dashboard footer, and each host's page for its agent ## Screenshots @@ -113,6 +117,10 @@ The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors re * `GET /api/v1/hosts/{host}/custom-metrics` * Names of the host's custom metrics * `GET /api/v1/alerts?host=&open=1&from=&to=` - * Alerts, newest first. `open=1` leaves out resolved ones + * Alerts, newest first. `open=1` leaves out resolved ones. Endpoint alerts have an empty `host` +* `GET /api/v1/endpoints?from=&to=` + * Every endpoint checked within the range: its newest check, the number of checks, the share that passed and the average response time +* `GET /api/v1/endpoints/series?name=&metric=latency&from=&to=` + * An endpoint's response time in milliseconds (`latency`) or share of passing checks (`availability`) over time, in the same shape as a host's series -The Client also serves `GET /metrics`, every host's latest values in the Prometheus text format. See [Prometheus and Grafana](docs/install.md#prometheus-and-grafana). +The Client also serves `GET /metrics`, every host's latest values in the Prometheus text format, unless `SYMON_CLIENT_METRICS_ENABLED=false`. See [Prometheus and Grafana](docs/install.md#prometheus-and-grafana). diff --git a/agent/main.go b/agent/main.go index 613b35d..18166ac 100644 --- a/agent/main.go +++ b/agent/main.go @@ -20,6 +20,7 @@ import ( "github.com/dhamith93/SyMon/internal/logger" "github.com/dhamith93/SyMon/internal/monitor" "github.com/dhamith93/SyMon/internal/transport" + "github.com/dhamith93/SyMon/internal/version" grpccreds "google.golang.org/grpc/credentials" "google.golang.org/grpc/status" ) @@ -36,7 +37,12 @@ func main() { token := flag.String("token", "", "With -enroll: the enrollment token") host := flag.String("host", "", "With -enroll: the host name to use, the machine's name by default") envFile := flag.String("env", config.DefaultEnvFile("agent"), "Settings file with KEY=value lines, loaded if it exists") + versionPtr := flag.Bool("version", false, "Print the version and exit") flag.Parse() + if *versionPtr { + fmt.Println("SyMon agent " + version.String()) + return + } if err := config.LoadEnvFile(*envFile); err != nil { log.Fatal(err) } @@ -79,6 +85,7 @@ func main() { return } + logger.Log("info", "agent "+version.String()+" started for "+config.ServerId) interval := time.Duration(config.MonitorIntervalSeconds) * time.Second collector := monitor.NewCollector(&config) ticker := time.NewTicker(interval) diff --git a/alertprocessor/main.go b/alertprocessor/main.go index e35db9e..c78f5f9 100644 --- a/alertprocessor/main.go +++ b/alertprocessor/main.go @@ -2,6 +2,7 @@ package main import ( "flag" + "fmt" "log" "net" "os" @@ -10,12 +11,18 @@ import ( "github.com/dhamith93/SyMon/internal/config" "github.com/dhamith93/SyMon/internal/logger" "github.com/dhamith93/SyMon/internal/transport" + "github.com/dhamith93/SyMon/internal/version" "github.com/dhamith93/SyMon/pkg/memdb" ) func main() { envFile := flag.String("env", config.DefaultEnvFile("alertprocessor"), "Settings file with KEY=value lines, loaded if it exists") + versionPtr := flag.Bool("version", false, "Print the version and exit") flag.Parse() + if *versionPtr { + fmt.Println("SyMon alert processor " + version.String()) + return + } if err := config.LoadEnvFile(*envFile); err != nil { log.Fatal(err) } @@ -64,6 +71,7 @@ func main() { } alertapi.RegisterAlertServiceServer(grpcServer, &s) + logger.Log("info", "alert processor "+version.String()+" started on :"+config.Port) if err := grpcServer.Serve(lis); err != nil { log.Fatalf("failed to serve: %s", err) } diff --git a/client/.env-example b/client/.env-example index a6a9f0d..c9db332 100644 --- a/client/.env-example +++ b/client/.env-example @@ -8,6 +8,8 @@ export SYMON_CLIENT_DOWNLOADS_DIR=downloads # how agents on other hosts reach the collector, when the dashboard's host # name with the collector's port does not work export SYMON_CLIENT_AGENT_COLLECTOR_ENDPOINT= +# /metrics for Prometheus, on unless this is false +export SYMON_CLIENT_METRICS_ENABLED= export SYMON_CLIENT_LOG_FILE_ENABLED= export SYMON_CLIENT_LOG_FILE_PATH= export SYMON_KEY='' diff --git a/client/internal/server/metrics.go b/client/internal/server/metrics.go index 3dc4ec4..595e2d2 100644 --- a/client/internal/server/metrics.go +++ b/client/internal/server/metrics.go @@ -16,6 +16,10 @@ import ( // getMetrics serves every host's latest values in the Prometheus text // format, so Prometheus can scrape the dashboard func (s *server) getMetrics(w http.ResponseWriter, r *http.Request) { + if !s.metricsEnabled { + http.Error(w, "metrics are switched off with SYMON_CLIENT_METRICS_ENABLED=false", http.StatusNotFound) + return + } response, err := s.collector.Snapshots(r.Context(), &api.Void{}) if err != nil { writeGRPCError(w, "snapshots", err) @@ -23,7 +27,9 @@ func (s *server) getMetrics(w http.ResponseWriter, r *http.Request) { } metrics := newMetricsWriter() + up := map[string]bool{} for _, host := range response.Hosts { + up[host.Host] = host.Up metrics.gauge("symon_up", "1 when the host reported within the last minute.", boolValue(host.Up), "host", host.Host) if host.LastSeen > 0 { metrics.gauge("symon_last_seen_timestamp_seconds", "When the host was last heard from.", float64(host.LastSeen), "host", host.Host) @@ -40,6 +46,15 @@ func (s *server) getMetrics(w http.ResponseWriter, r *http.Request) { } addHostMetrics(metrics, host.Host, &data) } + for _, custom := range response.CustomMetrics { + if !up[custom.Host] { + continue + } + metrics.gauge("symon_custom_metric", "The newest value of a custom metric sent in the last 2 days.", custom.Value, + "host", custom.Host, "name", custom.Name, "unit", custom.Unit) + metrics.gauge("symon_custom_metric_timestamp_seconds", "When the custom metric was last sent.", float64(custom.Time), + "host", custom.Host, "name", custom.Name) + } w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") if err := metrics.writeTo(w); err != nil { diff --git a/client/internal/server/metrics_test.go b/client/internal/server/metrics_test.go index c953e76..3e7acf6 100644 --- a/client/internal/server/metrics_test.go +++ b/client/internal/server/metrics_test.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "net/http" "net/http/httptest" "strings" "testing" @@ -30,11 +31,17 @@ func (f *fakeCollector) Snapshots(ctx context.Context, in *api.Void) (*api.Snaps if err != nil { return nil, err } - return &api.SnapshotList{Hosts: []*api.HostSnapshot{ - {Host: "web1", Up: true, LastSeen: 1700000000, Time: 1700000000, SnapshotJson: string(snapshot)}, - {Host: "db1", LastSeen: 1690000000, Time: 1690000000, SnapshotJson: string(snapshot)}, - {Host: "new1", Up: true, LastSeen: 1700000000}, - }}, nil + return &api.SnapshotList{ + Hosts: []*api.HostSnapshot{ + {Host: "web1", Up: true, LastSeen: 1700000000, Time: 1700000000, SnapshotJson: string(snapshot)}, + {Host: "db1", LastSeen: 1690000000, Time: 1690000000, SnapshotJson: string(snapshot)}, + {Host: "new1", Up: true, LastSeen: 1700000000}, + }, + CustomMetrics: []*api.CustomValue{ + {Host: "web1", Name: "queue-length", Unit: "jobs", Value: 42, Time: 1699999000}, + {Host: "db1", Name: "backup-age", Unit: "hours", Value: 30, Time: 1689999000}, + }, + }, nil } func TestMetrics(t *testing.T) { @@ -55,13 +62,15 @@ func TestMetrics(t *testing.T) { "# TYPE symon_network_receive_bytes_total counter\n" + `symon_network_receive_bytes_total{host="web1",iface="eth0"} 1000`, `symon_service_up{host="web1",service="nginx"} 1`, `symon_container_receive_bytes_per_second{host="web1",container="web",project="shop"} 2048`, + `symon_custom_metric{host="web1",name="queue-length",unit="jobs"} 42`, + `symon_custom_metric_timestamp_seconds{host="web1",name="queue-length"} 1.699999e+09`, } { if !strings.Contains(body, line+"\n") { t.Errorf("expected %q in:\n%s", line, body) } } // db1 stopped reporting, so its old values are left out - if strings.Contains(body, `{host="db1",`) || strings.Contains(body, `symon_cpu_usage_percent{host="db1"}`) { + if strings.Contains(body, `{host="db1",`) || strings.Contains(body, `symon_cpu_usage_percent{host="db1"}`) || strings.Contains(body, "backup-age") { t.Errorf("expected only up and last seen for db1:\n%s", body) } // web1 has no transmit rate, as if it were on the host network @@ -70,6 +79,15 @@ func TestMetrics(t *testing.T) { } } +func TestMetricsSwitchedOff(t *testing.T) { + s, _ := newTestServer(t, nil) + s.metricsEnabled = false + code, body, _ := get(t, s, "/metrics") + if code != http.StatusNotFound || !strings.Contains(body, "SYMON_CLIENT_METRICS_ENABLED") { + t.Errorf("unexpected response %d: %s", code, body) + } +} + func TestMetricsWriter(t *testing.T) { w := newMetricsWriter() w.gauge("a", "First.", 1, "name", `quote " backslash \ newline`+"\n") diff --git a/client/internal/server/server.go b/client/internal/server/server.go index 184fe5d..eb5c5c7 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/dhamith93/SyMon/internal/config" "github.com/dhamith93/SyMon/internal/logger" "github.com/dhamith93/SyMon/internal/transport" + "github.com/dhamith93/SyMon/internal/version" "github.com/gorilla/handlers" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -31,6 +32,7 @@ type server struct { collectorEndpoint string agentCollector string downloadsDir string + metricsEnabled bool } // Run starts the server on the given address, like ":8080" @@ -50,13 +52,14 @@ func Run(address string) { collectorEndpoint: config.CollectorEndpoint, agentCollector: config.AgentCollectorEndpoint, downloadsDir: config.DownloadsDir, + metricsEnabled: config.MetricsEnabled, } httpServer := &http.Server{ Addr: address, Handler: handlers.CompressHandler(s.routes()), ReadHeaderTimeout: 10 * time.Second, } - logger.Log("info", "dashboard started on "+address) + logger.Log("info", "dashboard "+version.String()+" started on "+address) log.Fatal(httpServer.ListenAndServe()) } @@ -71,6 +74,8 @@ func (s *server) routes() http.Handler { mux.HandleFunc("GET /api/v1/hosts/{host}/custom-metrics", s.getCustomMetrics) mux.HandleFunc("GET /api/v1/hosts/{host}/disk-forecasts", s.getDiskForecasts) mux.HandleFunc("GET /api/v1/alerts", s.getAlerts) + mux.HandleFunc("GET /api/v1/endpoints", s.getEndpoints) + mux.HandleFunc("GET /api/v1/endpoints/series", s.getEndpointSeries) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "no such endpoint") }) @@ -82,7 +87,16 @@ func (s *server) routes() http.Handler { } func (s *server) getConfig(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]int{"refreshSeconds": s.refreshSeconds}) + // empty when the collector cannot say, like one older than the call + collectorVersion := "" + if response, err := s.collector.Version(r.Context(), &api.Void{}); err == nil { + collectorVersion = response.Body + } + writeJSON(w, map[string]any{ + "refreshSeconds": s.refreshSeconds, + "version": version.String(), + "collectorVersion": collectorVersion, + }) } type hostSummary struct { @@ -181,7 +195,10 @@ func (s *server) getSeries(w http.ResponseWriter, r *http.Request) { writeGRPCError(w, query.Get("metric")+" for "+host, err) return } + writeSeries(w, response) +} +func writeSeries(w http.ResponseWriter, response *api.SeriesResponse) { out := make([]series, 0, len(response.Series)) for _, s := range response.Series { points := make([][2]float64, 0, len(s.Points)) @@ -263,6 +280,78 @@ func (s *server) getCustomMetrics(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"names": nonNil(names.Names)}) } +type endpointStatus struct { + Name string `json:"name"` + URL string `json:"url"` + Method string `json:"method"` + // the newest check + Time int64 `json:"time"` + OK bool `json:"ok"` + StatusCode int32 `json:"statusCode"` + LatencyMs float64 `json:"latencyMs"` + Error string `json:"error"` + // over the range + Checks int32 `json:"checks"` + UptimePct float64 `json:"uptimePct"` + AvgLatencyMs float64 `json:"avgLatencyMs"` +} + +// getEndpoints lists the endpoints checked within the range +func (s *server) getEndpoints(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + from, to, err := timeRange(query.Get("from"), query.Get("to")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + response, err := s.collector.Endpoints(r.Context(), &api.EndpointsRequest{From: from, To: to}) + if err != nil { + writeGRPCError(w, "endpoints", err) + return + } + endpoints := make([]endpointStatus, 0, len(response.Endpoints)) + for _, e := range response.Endpoints { + endpoints = append(endpoints, endpointStatus{ + Name: e.Name, + URL: e.Url, + Method: e.Method, + Time: e.Time, + OK: e.Ok, + StatusCode: e.StatusCode, + LatencyMs: e.LatencyMs, + Error: e.Error, + Checks: e.Checks, + UptimePct: e.UptimePct, + AvgLatencyMs: e.AvgLatencyMs, + }) + } + writeJSON(w, map[string]any{"endpoints": endpoints}) +} + +// getEndpointSeries returns an endpoint's latency or availability over time, +// in the same shape as a host's series +func (s *server) getEndpointSeries(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + from, to, err := timeRange(query.Get("from"), query.Get("to")) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + maxPoints, _ := strconv.Atoi(query.Get("maxPoints")) + response, err := s.collector.EndpointSeries(r.Context(), &api.EndpointSeriesRequest{ + Name: query.Get("name"), + Metric: query.Get("metric"), + From: from, + To: to, + MaxPoints: int32(maxPoints), + }) + if err != nil { + writeGRPCError(w, query.Get("metric")+" for endpoint "+query.Get("name"), err) + return + } + writeSeries(w, response) +} + type diskForecast struct { Device string `json:"device"` Mount string `json:"mount"` diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index 1d03129..c706d70 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -15,6 +15,7 @@ import ( "github.com/dhamith93/SyMon/internal/api" "github.com/dhamith93/SyMon/internal/transport" + "github.com/dhamith93/SyMon/internal/version" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -73,6 +74,25 @@ func (f *fakeCollector) ProcessUsage(ctx context.Context, in *api.ProcessUsageRe }}, nil } +func (f *fakeCollector) Endpoints(ctx context.Context, in *api.EndpointsRequest) (*api.EndpointList, error) { + return &api.EndpointList{Endpoints: []*api.EndpointStatus{ + {Name: "api", Url: "https://api.example.com", Method: "GET", Time: in.To, StatusCode: 0, Error: "connection refused", Checks: 30, UptimePct: 90, AvgLatencyMs: 110.5}, + }}, nil +} + +func (f *fakeCollector) EndpointSeries(ctx context.Context, in *api.EndpointSeriesRequest) (*api.SeriesResponse, error) { + if in.Metric != "latency" { + return nil, status.Error(codes.InvalidArgument, "invalid request: unknown endpoint metric") + } + return &api.SeriesResponse{Metric: in.Metric, Source: "raw", StepSeconds: 4, Series: []*api.Series{ + {Label: in.Name, Points: []*api.Point{{Time: 1700000000, Value: 100}}}, + }}, nil +} + +func (f *fakeCollector) Version(ctx context.Context, in *api.Void) (*api.Message, error) { + return &api.Message{Body: "v3.1.0"}, nil +} + func floatPtr(v float64) *float64 { return &v } @@ -103,7 +123,7 @@ func newTestServer(t *testing.T, files fstest.MapFS) (*server, *fakeCollector) { t.Fatal(err) } t.Cleanup(func() { conn.Close() }) - return &server{collector: api.NewMonitorDataServiceClient(conn), refreshSeconds: 15, files: files}, fake + return &server{collector: api.NewMonitorDataServiceClient(conn), refreshSeconds: 15, files: files, metricsEnabled: true}, fake } // get calls the server and returns the status, the body and what was logged @@ -118,6 +138,16 @@ func get(t *testing.T, s *server, url string) (int, string, string) { return rec.Code, rec.Body.String(), logs.String() } +func TestConfig(t *testing.T) { + version.Version = "v3.1.1" + t.Cleanup(func() { version.Version = "" }) + s, _ := newTestServer(t, nil) + code, body, _ := get(t, s, "/api/v1/config") + if code != 200 || strings.TrimSpace(body) != `{"collectorVersion":"v3.1.0","refreshSeconds":15,"version":"v3.1.1"}` { + t.Errorf("unexpected response %d: %s", code, body) + } +} + func TestFleet(t *testing.T) { s, _ := newTestServer(t, nil) code, body, _ := get(t, s, "/api/v1/fleet") @@ -149,6 +179,25 @@ func TestProcessUsage(t *testing.T) { } } +func TestEndpoints(t *testing.T) { + s, _ := newTestServer(t, nil) + code, body, _ := get(t, s, "/api/v1/endpoints?from=1700000000&to=1700003600") + want := `{"endpoints":[{"name":"api","url":"https://api.example.com","method":"GET","time":1700003600,"ok":false,` + + `"statusCode":0,"latencyMs":0,"error":"connection refused","checks":30,"uptimePct":90,"avgLatencyMs":110.5}]}` + if code != 200 || strings.TrimSpace(body) != want { + t.Errorf("unexpected response %d: %s", code, body) + } + + code, body, _ = get(t, s, "/api/v1/endpoints/series?name=api&metric=latency&from=1700000000&to=1700003600") + want = `{"metric":"latency","series":[{"label":"api","points":[[1700000000,100]]}],"source":"raw","stepSeconds":4}` + if code != 200 || strings.TrimSpace(body) != want { + t.Errorf("unexpected response %d: %s", code, body) + } + if code, _, _ := get(t, s, "/api/v1/endpoints/series?name=api&metric=nope"); code != http.StatusBadRequest { + t.Errorf("expected 400 for an unknown metric, got %d", code) + } +} + func TestDiskForecasts(t *testing.T) { s, _ := newTestServer(t, nil) code, body, _ := get(t, s, "/api/v1/hosts/web1/disk-forecasts") diff --git a/client/main.go b/client/main.go index 86a5bba..41b621d 100644 --- a/client/main.go +++ b/client/main.go @@ -2,16 +2,23 @@ package main import ( "flag" + "fmt" "log" "sync" "github.com/dhamith93/SyMon/client/internal/server" "github.com/dhamith93/SyMon/internal/config" + "github.com/dhamith93/SyMon/internal/version" ) func main() { envFile := flag.String("env", config.DefaultEnvFile("client"), "Settings file with KEY=value lines, loaded if it exists") + versionPtr := flag.Bool("version", false, "Print the version and exit") flag.Parse() + if *versionPtr { + fmt.Println("SyMon client " + version.String()) + return + } if err := config.LoadEnvFile(*envFile); err != nil { log.Fatal(err) } diff --git a/client/web/src/App.svelte b/client/web/src/App.svelte index 0a59aae..ad72aa9 100644 --- a/client/web/src/App.svelte +++ b/client/web/src/App.svelte @@ -1,13 +1,19 @@ @@ -22,7 +28,8 @@ SyMon