diff --git a/README.MD b/README.MD index 9ae2791..65da2ba 100644 --- a/README.MD +++ b/README.MD @@ -22,12 +22,15 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr **Endpoints** - HTTP checks of any URL from the server, with response time and uptime history +- Alerts on HTTPS certificates that are about to expire **Custom metrics** - Send any number from a script or cron job and get a chart for it **Alerts** -- Rules for CPU, memory, swap, disks, disks filling up, services, custom metrics, silent hosts and HTTP endpoints +- Rules for CPU, memory, swap, disks, disks filling up, services, custom metrics, silent hosts, HTTP endpoints and certificates +- Rules are edited on the dashboard and apply right away. They can watch every host, including ones added later +- Every host is watched for going silent from the start - Warning and critical levels, shown on the dashboard and sent by email, Slack or PagerDuty **Dashboard** @@ -84,7 +87,8 @@ Optional. The Collector sends it alerts as they open, change and resolve, and it - **Agent keys.** Each enrolled host gets its own key, stored hashed on the server. It can only send data, and only as that host. `collector -remove-agent ` revokes it. - **Shared key.** The Collector, Client and Alert processor use a shared key from `collector -init`. Each call carries a short-lived token signed with it. - **TLS.** Traffic between components can be encrypted. See the `*_TLS_*` and `*_CERT_PATH` settings in each component's `.env-example`. -- **Dashboard login.** The dashboard stays locked until a user exists. `collector -add-user ` creates one and prints its password, `-reset-password`, `-remove-user` and `-list-users` manage them. A login lasts 30 days. After 10 wrong passwords a user name is locked for 15 minutes. The install script and agent downloads stay public, so new hosts can enroll. +- **Dashboard login.** The dashboard stays locked until a user exists. `collector -add-user ` creates one and prints its password, `-reset-password`, `-remove-user` and `-list-users` manage them, and users change their own password on the dashboard. A login lasts 30 days. After 10 wrong passwords a user name is locked for 15 minutes. The install script and agent downloads stay public, so new hosts can enroll. +- **Roles.** Admins can change alert rules, viewers can only look. `-add-user -role viewer` creates a viewer, `-set-role -role admin` changes it. - **HTTPS.** Put a reverse proxy like Caddy or nginx in front of the dashboard, so passwords and the session cookie are encrypted. ## Local development @@ -106,6 +110,8 @@ The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors re * `POST /api/v1/login` with `{"user": "...", "password": "..."}` as JSON * Sets the `symon_session` cookie. 401 for a wrong password, 429 while the user is locked out * `POST /api/v1/logout` with `{}` as JSON +* `POST /api/v1/password` with `{"current": "...", "new": "..."}` as JSON + * Changes your own password and logs you out everywhere else * `GET /api/v1/session` * `{"user": "..."}` when logged in, otherwise 401 with `hasUsers`, false until the first user exists @@ -126,6 +132,10 @@ The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors re * 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. Endpoint alerts have an empty `host` +* `GET /api/v1/rules` + * The alert rules, each with `id`, `enabled` and `rule`, the rule in the alerts.json format +* `POST /api/v1/rules` and `PUT /api/v1/rules/{id}` with `{"enabled": true, "rule": {...}}` as JSON, `DELETE /api/v1/rules/{id}` + * For admins only. A deleted or switched off rule resolves its open alerts * `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=` diff --git a/client/internal/server/auth.go b/client/internal/server/auth.go index 226111a..ea1bb78 100644 --- a/client/internal/server/auth.go +++ b/client/internal/server/auth.go @@ -42,22 +42,28 @@ type authCache struct { passwords map[[32]byte]time.Time } +// userSession is who a session belongs to. role is admin or viewer. +type userSession struct { + user string + role string +} + type cachedSession struct { - user string + userSession until time.Time } -func (c *authCache) session(key [32]byte) (string, bool) { +func (c *authCache) session(key [32]byte) (userSession, bool) { c.mu.Lock() defer c.mu.Unlock() cached, ok := c.sessions[key] if !ok || time.Now().After(cached.until) { - return "", false + return userSession{}, false } - return cached.user, true + return cached.userSession, true } -func (c *authCache) keepSession(key [32]byte, user string, expires time.Time) { +func (c *authCache) keepSession(key [32]byte, session userSession, expires time.Time) { c.mu.Lock() defer c.mu.Unlock() if c.sessions == nil { @@ -67,7 +73,7 @@ func (c *authCache) keepSession(key [32]byte, user string, expires time.Time) { if expires.Before(until) { until = expires } - c.sessions[key] = cachedSession{user: user, until: until} + c.sessions[key] = cachedSession{userSession: session, until: until} } func (c *authCache) forgetSession(key [32]byte) { @@ -76,6 +82,18 @@ func (c *authCache) forgetSession(key [32]byte) { delete(c.sessions, key) } +// forgetUser drops a user's cached sessions but one, after the collector +// ended the others +func (c *authCache) forgetUser(user string, keep [32]byte) { + c.mu.Lock() + defer c.mu.Unlock() + for key, cached := range c.sessions { + if cached.user == user && key != keep { + delete(c.sessions, key) + } + } +} + func (c *authCache) password(key [32]byte) bool { c.mu.Lock() defer c.mu.Unlock() @@ -92,31 +110,32 @@ func (c *authCache) keepPassword(key [32]byte) { c.passwords[key] = time.Now() } -// sessionUser returns who the request's session cookie belongs to -func (s *server) sessionUser(r *http.Request) (string, error) { +// sessionOf returns who the request's session cookie belongs to +func (s *server) sessionOf(r *http.Request) (userSession, error) { cookie, err := r.Cookie(sessionCookie) if err != nil || cookie.Value == "" { - return "", errNotLoggedIn + return userSession{}, errNotLoggedIn } key := sha256.Sum256([]byte(cookie.Value)) - if user, ok := s.auth.session(key); ok { - return user, nil + if session, ok := s.auth.session(key); ok { + return session, nil } info, err := s.collector.CheckSession(r.Context(), &api.SessionRequest{Token: cookie.Value}) if status.Code(err) == codes.Unauthenticated { - return "", errNotLoggedIn + return userSession{}, errNotLoggedIn } if err != nil { - return "", err + return userSession{}, err } - s.auth.keepSession(key, info.User, time.Unix(info.Expires, 0)) - return info.User, nil + session := userSession{user: info.User, role: info.Role} + s.auth.keepSession(key, session, time.Unix(info.Expires, 0)) + return session, nil } // requireLogin answers 401 unless the request has a valid session func (s *server) requireLogin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, err := s.sessionUser(r) + _, err := s.sessionOf(r) switch { case errors.Is(err, errNotLoggedIn): writeError(w, http.StatusUnauthorized, err.Error()) @@ -131,9 +150,9 @@ func (s *server) requireLogin(next http.Handler) http.Handler { // getSession says who is logged in. Without a session it says whether // there are any users yet, since the dashboard stays locked until there are. func (s *server) getSession(w http.ResponseWriter, r *http.Request) { - user, err := s.sessionUser(r) + session, err := s.sessionOf(r) if err == nil { - writeJSON(w, map[string]string{"user": user}) + writeJSON(w, map[string]string{"user": session.user, "role": session.role}) return } if !errors.Is(err, errNotLoggedIn) { @@ -190,7 +209,42 @@ func (s *server) postLogin(w http.ResponseWriter, r *http.Request) { Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) - writeJSON(w, map[string]string{"user": session.User}) + writeJSON(w, map[string]string{"user": session.User, "role": session.Role}) +} + +// postPassword changes the logged in user's own password. Their other +// sessions end, this one stays. +func (s *server) postPassword(w http.ResponseWriter, r *http.Request) { + if !jsonBody(r) { + writeError(w, http.StatusUnsupportedMediaType, "send the passwords as JSON") + return + } + var passwords struct { + Current string `json:"current"` + New string `json:"new"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&passwords); err != nil { + writeError(w, http.StatusBadRequest, "send current and new") + return + } + session, err := s.sessionOf(r) + if err != nil { + writeError(w, http.StatusUnauthorized, errNotLoggedIn.Error()) + return + } + cookie, _ := r.Cookie(sessionCookie) + _, err = s.collector.ChangePassword(r.Context(), &api.ChangePasswordRequest{Token: cookie.Value, Current: passwords.Current, NewPassword: passwords.New}) + // the session was just checked, so this is the current password + if status.Code(err) == codes.Unauthenticated { + writeError(w, http.StatusForbidden, "the current password is wrong") + return + } + if err != nil { + writeGRPCError(w, "password change", err) + return + } + s.auth.forgetUser(session.user, sha256.Sum256([]byte(cookie.Value))) + writeJSON(w, map[string]string{}) } func (s *server) postLogout(w http.ResponseWriter, r *http.Request) { @@ -224,7 +278,7 @@ func (s *server) metricsAllowed(w http.ResponseWriter, r *http.Request) bool { if !s.metricsAuth { return true } - if _, err := s.sessionUser(r); err == nil { + if _, err := s.sessionOf(r); err == nil { return true } if user, password, ok := r.BasicAuth(); ok { diff --git a/client/internal/server/auth_test.go b/client/internal/server/auth_test.go index 63e60b2..81e66ea 100644 --- a/client/internal/server/auth_test.go +++ b/client/internal/server/auth_test.go @@ -60,7 +60,7 @@ func TestAppNeedsNoLogin(t *testing.T) { func TestSession(t *testing.T) { s, fake := newTestServer(t, nil) - if rec := call(s, "GET", "/api/v1/session", "", testSession, nil); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"user":"tester"}` { + if rec := call(s, "GET", "/api/v1/session", "", testSession, nil); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"role":"admin","user":"tester"}` { t.Errorf("expected the logged in user, got %d %s", rec.Code, rec.Body) } if rec := call(s, "GET", "/api/v1/session", "", "", nil); rec.Code != 401 || !strings.Contains(rec.Body.String(), `"hasUsers":true`) { @@ -81,7 +81,7 @@ func TestLogin(t *testing.T) { r.Header.Set("X-Forwarded-Proto", "https") }) cookie := sessionCookieOf(rec) - if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"user":"alice"}` || cookie == nil { + if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"role":"admin","user":"alice"}` || cookie == nil { t.Fatalf("expected a login, got %d %s", rec.Code, rec.Body) } if cookie.Value != "new-token" || !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" { @@ -169,3 +169,26 @@ func TestMetricsAuth(t *testing.T) { t.Errorf("expected metrics for a logged in browser, got %d", rec.Code) } } + +func TestChangePassword(t *testing.T) { + s, _ := newTestServer(t, nil) + tests := []struct { + body string + cookie string + prepare func(*http.Request) + code int + want string + }{ + {`{"current":"correct horse battery","new":"a brand new password"}`, testSession, asJSON, 200, "{}"}, + {`{"current":"wrong","new":"a brand new password"}`, testSession, asJSON, http.StatusForbidden, "current password is wrong"}, + {`{"current":"correct horse battery","new":"short"}`, testSession, asJSON, http.StatusBadRequest, "at least 12 characters"}, + {`{"current":"correct horse battery","new":"a brand new password"}`, "", asJSON, http.StatusUnauthorized, "log in first"}, + {`{"current":"correct horse battery","new":"a brand new password"}`, testSession, nil, http.StatusUnsupportedMediaType, "JSON"}, + } + for _, tt := range tests { + rec := call(s, "POST", "/api/v1/password", tt.body, tt.cookie, tt.prepare) + if rec.Code != tt.code || !strings.Contains(rec.Body.String(), tt.want) { + t.Errorf("%s with %q: got %d %s, want %d", tt.body, tt.cookie, rec.Code, rec.Body, tt.code) + } + } +} diff --git a/client/internal/server/rules.go b/client/internal/server/rules.go new file mode 100644 index 0000000..f90e50a --- /dev/null +++ b/client/internal/server/rules.go @@ -0,0 +1,112 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/dhamith93/SyMon/internal/api" +) + +// Alert rules: every user can see them, admins can change them. A rule has +// the same fields as an alerts.json entry. + +type alertRule struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + Rule json.RawMessage `json:"rule"` + UpdatedAt int64 `json:"updatedAt"` + // empty for rules SyMon set up + UpdatedBy string `json:"updatedBy"` +} + +// requireAdmin answers 403 for viewers. It runs behind requireLogin. +func (s *server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session, err := s.sessionOf(r) + if err != nil { + writeError(w, http.StatusUnauthorized, errNotLoggedIn.Error()) + return + } + if session.role != "admin" { + writeError(w, http.StatusForbidden, "only admins can change alert rules") + return + } + next(w, r) + } +} + +func (s *server) getRules(w http.ResponseWriter, r *http.Request) { + response, err := s.collector.AlertRules(r.Context(), &api.Void{}) + if err != nil { + writeGRPCError(w, "alert rules", err) + return + } + rules := make([]alertRule, 0, len(response.Rules)) + for _, rule := range response.Rules { + rules = append(rules, alertRule{ + ID: rule.Id, + Enabled: rule.Enabled, + Rule: json.RawMessage(rule.RuleJson), + UpdatedAt: rule.UpdatedAt, + UpdatedBy: rule.UpdatedBy, + }) + } + writeJSON(w, map[string]any{"rules": rules}) +} + +func (s *server) postRule(w http.ResponseWriter, r *http.Request) { + s.saveRule(w, r, 0) +} + +func (s *server) putRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusNotFound, "no such rule") + return + } + s.saveRule(w, r, id) +} + +// saveRule creates a rule for id 0, and replaces one otherwise +func (s *server) saveRule(w http.ResponseWriter, r *http.Request, id int64) { + if !jsonBody(r) { + writeError(w, http.StatusUnsupportedMediaType, "send the rule as JSON") + return + } + var body struct { + Enabled bool `json:"enabled"` + Rule json.RawMessage `json:"rule"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil || len(body.Rule) == 0 { + writeError(w, http.StatusBadRequest, "send enabled and rule") + return + } + session, _ := s.sessionOf(r) + saved, err := s.collector.SaveRule(r.Context(), &api.SaveRuleRequest{Id: id, Enabled: body.Enabled, RuleJson: string(body.Rule), By: session.user}) + if err != nil { + writeGRPCError(w, "saving a rule", err) + return + } + if id == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]int64{"id": saved.Id}) + return + } + writeJSON(w, map[string]int64{"id": saved.Id}) +} + +func (s *server) deleteRule(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || id <= 0 { + writeError(w, http.StatusNotFound, "no such rule") + return + } + session, _ := s.sessionOf(r) + if _, err := s.collector.DeleteRule(r.Context(), &api.RuleRequest{Id: id, By: session.user}); err != nil { + writeGRPCError(w, "deleting a rule", err) + return + } + writeJSON(w, map[string]int64{"id": id}) +} diff --git a/client/internal/server/rules_test.go b/client/internal/server/rules_test.go new file mode 100644 index 0000000..c49296d --- /dev/null +++ b/client/internal/server/rules_test.go @@ -0,0 +1,53 @@ +package server + +import ( + "net/http" + "strings" + "testing" +) + +func TestRules(t *testing.T) { + s, fake := newTestServer(t, nil) + + // viewers can read the rules + rec := call(s, "GET", "/api/v1/rules", "", viewerSession, nil) + want := `{"rules":[{"id":1,"enabled":true,"rule":{"Name":"CPU","MetricName":"procUsage"},"updatedAt":1700000000,"updatedBy":"alice"}]}` + if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != want { + t.Errorf("unexpected rules %d %s", rec.Code, rec.Body) + } + + tests := []struct { + method string + url string + body string + cookie string + code int + want string + }{ + {"POST", "/api/v1/rules", `{"enabled":true,"rule":{"Name":"Memory"}}`, testSession, http.StatusCreated, `{"id":7}`}, + {"POST", "/api/v1/rules", `{"enabled":true,"rule":{"Name":"CPU"}}`, testSession, http.StatusConflict, "already a rule"}, + {"POST", "/api/v1/rules", `{"enabled":true,"rule":{"MetricName":"memory"}}`, testSession, http.StatusBadRequest, "needs a Name"}, + {"POST", "/api/v1/rules", `{"enabled":true}`, testSession, http.StatusBadRequest, "send enabled and rule"}, + {"PUT", "/api/v1/rules/3", `{"enabled":false,"rule":{"Name":"CPU"}}`, testSession, 200, `{"id":3}`}, + {"PUT", "/api/v1/rules/404", `{"enabled":false,"rule":{"Name":"x"}}`, testSession, http.StatusNotFound, "no data found"}, + {"PUT", "/api/v1/rules/nope", `{"enabled":false,"rule":{"Name":"x"}}`, testSession, http.StatusNotFound, "no such rule"}, + {"DELETE", "/api/v1/rules/3", "", testSession, 200, `{"id":3}`}, + // viewers cannot change anything + {"POST", "/api/v1/rules", `{"enabled":true,"rule":{"Name":"Memory"}}`, viewerSession, http.StatusForbidden, "only admins"}, + {"PUT", "/api/v1/rules/3", `{"enabled":true,"rule":{"Name":"CPU"}}`, viewerSession, http.StatusForbidden, "only admins"}, + {"DELETE", "/api/v1/rules/3", "", viewerSession, http.StatusForbidden, "only admins"}, + {"DELETE", "/api/v1/rules/3", "", "", http.StatusUnauthorized, "log in first"}, + } + for _, tt := range tests { + rec := call(s, tt.method, tt.url, tt.body, tt.cookie, asJSON) + if rec.Code != tt.code || !strings.Contains(rec.Body.String(), tt.want) { + t.Errorf("%s %s %s as %q: got %d %s, want %d", tt.method, tt.url, tt.body, tt.cookie, rec.Code, rec.Body, tt.code) + } + } + if by := fake.lastRuleBy.Load(); by != "tester" { + t.Errorf("expected the change to be made by tester, got %v", by) + } + if rec := call(s, "POST", "/api/v1/rules", `{"enabled":true,"rule":{"Name":"x"}}`, testSession, nil); rec.Code != http.StatusUnsupportedMediaType { + t.Errorf("expected 415 for a rule that is not JSON, got %d", rec.Code) + } +} diff --git a/client/internal/server/server.go b/client/internal/server/server.go index b59f49c..f83b319 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -82,6 +82,11 @@ func (s *server) routes() http.Handler { data.HandleFunc("GET /api/v1/alerts", s.getAlerts) data.HandleFunc("GET /api/v1/endpoints", s.getEndpoints) data.HandleFunc("GET /api/v1/endpoints/series", s.getEndpointSeries) + data.HandleFunc("POST /api/v1/password", s.postPassword) + data.HandleFunc("GET /api/v1/rules", s.getRules) + data.HandleFunc("POST /api/v1/rules", s.requireAdmin(s.postRule)) + data.HandleFunc("PUT /api/v1/rules/{id}", s.requireAdmin(s.putRule)) + data.HandleFunc("DELETE /api/v1/rules/{id}", s.requireAdmin(s.deleteRule)) data.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "no such endpoint") }) @@ -129,6 +134,8 @@ type hostSummary struct { Containers int32 `json:"containers"` // null when no disk is filling up DiskFullDays *float64 `json:"diskFullDays"` + // empty from agents older than versions + AgentVersion string `json:"agentVersion"` } func (s *server) getFleet(w http.ResponseWriter, r *http.Request) { @@ -156,6 +163,7 @@ func (s *server) getFleet(w http.ResponseWriter, r *http.Request) { WorstSeverity: h.WorstSeverity, Containers: h.Containers, DiskFullDays: h.DiskFullDays, + AgentVersion: h.AgentVersion, }) } writeJSON(w, map[string]any{"hosts": hosts}) @@ -306,6 +314,8 @@ type endpointStatus struct { Checks int32 `json:"checks"` UptimePct float64 `json:"uptimePct"` AvgLatencyMs float64 `json:"avgLatencyMs"` + // when the newest certificate seen expires, 0 without one + CertExpires int64 `json:"certExpires"` } // getEndpoints lists the endpoints checked within the range @@ -335,6 +345,7 @@ func (s *server) getEndpoints(w http.ResponseWriter, r *http.Request) { Checks: e.Checks, UptimePct: e.UptimePct, AvgLatencyMs: e.AvgLatencyMs, + CertExpires: e.CertExpires, }) } writeJSON(w, map[string]any{"endpoints": endpoints}) @@ -370,8 +381,11 @@ type diskForecast struct { UsedPct float64 `json:"usedPct"` PctPerDay float64 `json:"pctPerDay"` BytesPerDay float64 `json:"bytesPerDay"` - // null when the disk is not filling up + // null when the disk is not filling up, and noForecast then says why DaysToFull *float64 `json:"daysToFull"` + NoForecast string `json:"noForecast"` + // hours of history behind the forecast + Samples int32 `json:"samples"` } func (s *server) getDiskForecasts(w http.ResponseWriter, r *http.Request) { @@ -390,6 +404,8 @@ func (s *server) getDiskForecasts(w http.ResponseWriter, r *http.Request) { PctPerDay: d.PctPerDay, BytesPerDay: d.BytesPerDay, DaysToFull: d.DaysToFull, + NoForecast: d.NoForecast, + Samples: d.Samples, }) } writeJSON(w, map[string]any{"disks": disks}) @@ -519,6 +535,8 @@ func writeGRPCError(w http.ResponseWriter, what string, err error) { writeError(w, http.StatusNotFound, st.Message()) case codes.Unauthenticated: writeError(w, http.StatusUnauthorized, st.Message()) + case codes.AlreadyExists: + writeError(w, http.StatusConflict, st.Message()) case codes.ResourceExhausted: writeError(w, http.StatusTooManyRequests, st.Message()) case codes.InvalidArgument: diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index 6a61ab4..6bdd051 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -34,14 +34,31 @@ type fakeCollector struct { sessionChecks atomic.Int32 passwordChecks atomic.Int32 loggedOut atomic.Value + lastRuleBy atomic.Value } -const testSession = "test-session" +const ( + testSession = "test-session" + viewerSession = "viewer-session" +) + +func (f *fakeCollector) ChangePassword(ctx context.Context, in *api.ChangePasswordRequest) (*api.Message, error) { + switch { + case in.Current != "correct horse battery": + return nil, status.Error(codes.Unauthenticated, "wrong user name or password") + case len(in.NewPassword) < 12: + return nil, status.Error(codes.InvalidArgument, "invalid request: a password needs at least 12 characters") + } + return &api.Message{Body: "ok"}, nil +} func (f *fakeCollector) CheckSession(ctx context.Context, in *api.SessionRequest) (*api.SessionInfo, error) { f.sessionChecks.Add(1) - if in.Token == testSession || in.Token == "new-token" { - return &api.SessionInfo{User: "tester", Expires: time.Now().Add(time.Hour).Unix()}, nil + switch in.Token { + case testSession, "new-token": + return &api.SessionInfo{User: "tester", Role: "admin", Expires: time.Now().Add(time.Hour).Unix()}, nil + case viewerSession: + return &api.SessionInfo{User: "vera", Role: "viewer", Expires: time.Now().Add(time.Hour).Unix()}, nil } return nil, status.Error(codes.Unauthenticated, "not logged in") } @@ -60,7 +77,7 @@ func (f *fakeCollector) Login(ctx context.Context, in *api.Credentials) (*api.Se if err := f.checkCredentials(in); err != nil { return nil, err } - return &api.SessionInfo{Token: "new-token", User: in.User, Expires: 1900000000}, nil + return &api.SessionInfo{Token: "new-token", User: in.User, Role: "admin", Expires: 1900000000}, nil } func (f *fakeCollector) CheckPassword(ctx context.Context, in *api.Credentials) (*api.Message, error) { @@ -82,7 +99,7 @@ func (f *fakeCollector) HasUsers(ctx context.Context, in *api.Void) (*api.UserSt func (f *fakeCollector) Fleet(ctx context.Context, in *api.Void) (*api.FleetSummary, error) { return &api.FleetSummary{Hosts: []*api.HostSummary{ - {Name: "web1", Up: true, CpuPct: 37, ActiveAlerts: 2, DiskFullDays: floatPtr(12.5)}, + {Name: "web1", Up: true, CpuPct: 37, ActiveAlerts: 2, DiskFullDays: floatPtr(12.5), AgentVersion: "v3.1.0"}, {Name: "db1", Up: true}, }}, nil } @@ -115,7 +132,7 @@ func (f *fakeCollector) CustomMetricNames(ctx context.Context, in *api.HostReque func (f *fakeCollector) DiskForecasts(ctx context.Context, in *api.HostRequest) (*api.DiskForecastList, error) { return &api.DiskForecastList{Disks: []*api.DiskForecast{ - {Device: "/dev/sda1", Mount: "/", UsedPct: 40}, + {Device: "/dev/sda1", Mount: "/", UsedPct: 40, NoForecast: "collecting", Samples: 12}, {Device: "/dev/sdb1", Mount: "/data", UsedPct: 60, PctPerDay: 2, BytesPerDay: 2e7, DaysToFull: floatPtr(20)}, }}, nil } @@ -129,7 +146,7 @@ func (f *fakeCollector) ProcessUsage(ctx context.Context, in *api.ProcessUsageRe 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}, + {Name: "api", Url: "https://api.example.com", Method: "GET", Time: in.To, StatusCode: 0, Error: "connection refused", Checks: 30, UptimePct: 90, AvgLatencyMs: 110.5, CertExpires: 1702592000}, }}, nil } @@ -146,6 +163,34 @@ func (f *fakeCollector) Version(ctx context.Context, in *api.Void) (*api.Message return &api.Message{Body: "v3.1.0"}, nil } +func (f *fakeCollector) AlertRules(ctx context.Context, in *api.Void) (*api.AlertRuleList, error) { + return &api.AlertRuleList{Rules: []*api.AlertRuleInfo{ + {Id: 1, Enabled: true, RuleJson: `{"Name":"CPU","MetricName":"procUsage"}`, UpdatedAt: 1700000000, UpdatedBy: "alice"}, + }}, nil +} + +// SaveRule knows the name CPU is taken and refuses rules without a name +func (f *fakeCollector) SaveRule(ctx context.Context, in *api.SaveRuleRequest) (*api.AlertRuleInfo, error) { + f.lastRuleBy.Store(in.By) + switch { + case strings.Contains(in.RuleJson, `"Name":"CPU"`) && in.Id == 0: + return nil, status.Error(codes.AlreadyExists, "there is already a rule with that name") + case !strings.Contains(in.RuleJson, `"Name"`): + return nil, status.Error(codes.InvalidArgument, "invalid request: a rule needs a Name") + case in.Id == 404: + return nil, status.Error(codes.NotFound, "no data found") + } + id := in.Id + if id == 0 { + id = 7 + } + return &api.AlertRuleInfo{Id: id, Enabled: in.Enabled}, nil +} + +func (f *fakeCollector) DeleteRule(ctx context.Context, in *api.RuleRequest) (*api.Message, error) { + return &api.Message{Body: "ok"}, nil +} + func floatPtr(v float64) *float64 { return &v } @@ -217,7 +262,7 @@ func TestFleet(t *testing.T) { if code != 200 || len(out.Hosts) != 2 || out.Hosts[0].Name != "web1" || out.Hosts[0].CPUPct != 37 || out.Hosts[0].ActiveAlerts != 2 { t.Errorf("unexpected response %d: %s", code, body) } - if !strings.Contains(body, `"diskFullDays":12.5`) || !strings.Contains(body, `"diskFullDays":null`) { + if !strings.Contains(body, `"diskFullDays":12.5`) || !strings.Contains(body, `"diskFullDays":null`) || !strings.Contains(body, `"agentVersion":"v3.1.0"`) { t.Errorf("expected a forecast for web1 and null for db1: %s", body) } } @@ -239,7 +284,7 @@ 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}]}` + `"statusCode":0,"latencyMs":0,"error":"connection refused","checks":30,"uptimePct":90,"avgLatencyMs":110.5,"certExpires":1702592000}]}` if code != 200 || strings.TrimSpace(body) != want { t.Errorf("unexpected response %d: %s", code, body) } @@ -258,8 +303,8 @@ func TestDiskForecasts(t *testing.T) { s, _ := newTestServer(t, nil) code, body, _ := get(t, s, "/api/v1/hosts/web1/disk-forecasts") want := `{"disks":[` + - `{"device":"/dev/sda1","mount":"/","usedPct":40,"pctPerDay":0,"bytesPerDay":0,"daysToFull":null},` + - `{"device":"/dev/sdb1","mount":"/data","usedPct":60,"pctPerDay":2,"bytesPerDay":20000000,"daysToFull":20}]}` + `{"device":"/dev/sda1","mount":"/","usedPct":40,"pctPerDay":0,"bytesPerDay":0,"daysToFull":null,"noForecast":"collecting","samples":12},` + + `{"device":"/dev/sdb1","mount":"/data","usedPct":60,"pctPerDay":2,"bytesPerDay":20000000,"daysToFull":20,"noForecast":"","samples":0}]}` if code != 200 || strings.TrimSpace(body) != want { t.Errorf("unexpected response %d: %s", code, body) } diff --git a/client/web/src/App.svelte b/client/web/src/App.svelte index f77deb3..82b2021 100644 --- a/client/web/src/App.svelte +++ b/client/web/src/App.svelte @@ -3,18 +3,22 @@ import { appConfig, loadConfig } from './lib/config.svelte'; import { handleLinkClick, location, match } from './lib/router.svelte'; import { setTheme, theme, type ThemeChoice } from './lib/theme.svelte'; + import Account from './pages/Account.svelte'; import Alerts from './pages/Alerts.svelte'; import CustomMetrics from './pages/CustomMetrics.svelte'; import Endpoints from './pages/Endpoints.svelte'; import Fleet from './pages/Fleet.svelte'; import Host from './pages/Host.svelte'; import Login from './pages/Login.svelte'; + import RuleEditor from './pages/RuleEditor.svelte'; + import Rules from './pages/Rules.svelte'; import Setup from './pages/Setup.svelte'; const page = $derived(match(location.path)); const onAlerts = $derived(page.name === 'alerts'); const onEndpoints = $derived(page.name === 'endpoints'); - const onHosts = $derived(!onAlerts && !onEndpoints); + const onRules = $derived(page.name === 'rules' || page.name === 'rule'); + const onHosts = $derived(!onAlerts && !onEndpoints && !onRules && page.name !== 'account'); // one version when the dashboard and collector match, both when they do not const sameVersion = $derived(!appConfig.collectorVersion || appConfig.collectorVersion === appConfig.version); @@ -40,10 +44,11 @@ Hosts Endpoints Alerts + Rules {/if} {#if auth.state === 'in'} - {auth.user} + {auth.user} {/if}