From 24c009285c63b803407bbf1b300c4fb2467fc2bb Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:55:01 +0530 Subject: [PATCH 01/10] say why a disk has no forecast --- client/internal/server/server.go | 7 ++++- client/internal/server/server_test.go | 6 ++-- client/web/src/lib/api.ts | 6 +++- client/web/src/lib/projection.test.ts | 13 +++++++-- client/web/src/lib/projection.ts | 16 ++++++++++ client/web/src/pages/Host.svelte | 8 +++-- internal/api/api.go | 2 ++ internal/api/api.pb.go | 31 +++++++++++++++++--- internal/api/api.proto | 7 ++++- internal/store/forecast.go | 42 +++++++++++++++++++-------- internal/store/forecast_test.go | 28 +++++++++--------- 11 files changed, 126 insertions(+), 40 deletions(-) diff --git a/client/internal/server/server.go b/client/internal/server/server.go index b59f49c..6f7b034 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -370,8 +370,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 +393,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}) diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index 6a61ab4..9c23cc4 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -115,7 +115,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 } @@ -258,8 +258,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/lib/api.ts b/client/web/src/lib/api.ts index 2602fe8..3fee48d 100644 --- a/client/web/src/lib/api.ts +++ b/client/web/src/lib/api.ts @@ -46,8 +46,12 @@ export interface DiskForecast { usedPct: number; pctPerDay: number; bytesPerDay: number; - // null when the disk is not filling up + // null when the disk is not filling up, and noForecast then says why: + // collecting, not_growing, not_steady or over_a_year daysToFull: number | null; + noForecast: string; + // hours of history behind the forecast + samples: number; } // disks that fill up sooner than this many days are shown as warnings diff --git a/client/web/src/lib/projection.test.ts b/client/web/src/lib/projection.test.ts index 89bccb5..c17a9d3 100644 --- a/client/web/src/lib/projection.test.ts +++ b/client/web/src/lib/projection.test.ts @@ -1,14 +1,23 @@ import { describe, expect, it } from 'vitest'; import type { DiskForecast } from './api'; -import { projectDisks } from './projection'; +import { noForecastText, projectDisks } from './projection'; const now = 1_700_000_000; const day = 86400; function disk(mount: string, usedPct: number, pctPerDay: number, daysToFull: number | null): DiskForecast { - return { device: `/dev/${mount}`, mount, usedPct, pctPerDay, bytesPerDay: 1e9, daysToFull }; + return { device: `/dev/${mount}`, mount, usedPct, pctPerDay, bytesPerDay: 1e9, daysToFull, noForecast: '', samples: 168 }; } +describe('noForecastText', () => { + it('says why there is no forecast', () => { + expect(noForecastText({ ...disk('/', 40, 0, null), noForecast: 'collecting', samples: 12 })).toBe('collecting history, 12 of 24 h'); + expect(noForecastText({ ...disk('/', 40, 0, null), noForecast: 'not_growing' })).toBe('not filling up'); + expect(noForecastText({ ...disk('/', 40, 0, null), noForecast: 'not_steady' })).toBe('growth not steady'); + expect(noForecastText({ ...disk('/', 40, 0, null), noForecast: 'over_a_year' })).toBe('over a year'); + }); +}); + describe('projectDisks', () => { it('leaves out disks that are not filling up', () => { expect(projectDisks([disk('/', 40, 0, null)], now, ['/'])).toBeNull(); diff --git a/client/web/src/lib/projection.ts b/client/web/src/lib/projection.ts index 0d1a566..fe60768 100644 --- a/client/web/src/lib/projection.ts +++ b/client/web/src/lib/projection.ts @@ -3,6 +3,22 @@ import type { DiskForecast } from './api'; const day = 86400; +// why a disk has no forecast, in words +export function noForecastText(forecast: DiskForecast): string { + switch (forecast.noForecast) { + case 'collecting': + return `collecting history, ${forecast.samples} of 24 h`; + case 'not_growing': + return 'not filling up'; + case 'not_steady': + return 'growth not steady'; + case 'over_a_year': + return 'over a year'; + default: + return '–'; + } +} + export interface Projection { data: Aligned; from: number; diff --git a/client/web/src/pages/Host.svelte b/client/web/src/pages/Host.svelte index 309aab4..b6c9660 100644 --- a/client/web/src/pages/Host.svelte +++ b/client/web/src/pages/Host.svelte @@ -5,7 +5,7 @@ import { hostSections, loadChart, loadCores, type ChartSpec } from '../lib/charts'; import { appConfig } from '../lib/config.svelte'; import { formatAgo, formatBytes, formatDate, formatDays, formatDuration, formatMiB, formatPercent } from '../lib/format'; - import { projectDisks } from '../lib/projection'; + import { noForecastText, projectDisks } from '../lib/projection'; import { poll } from '../lib/poll'; import { hostPath, location, navigate } from '../lib/router.svelte'; import { rangeQuery, resolveRange } from '../lib/timerange'; @@ -282,7 +282,7 @@ {disk.Inodes.Usage} {#if forecast?.daysToFull == null} - – + {forecast ? noForecastText(forecast) : '–'} {:else if forecast.daysToFull < diskFullSoonDays} {:else} @@ -430,6 +430,10 @@ min-width: 0; } + .no-forecast { + font-size: 12px; + } + .box h3 { font-size: 14px; margin-bottom: 6px; diff --git a/internal/api/api.go b/internal/api/api.go index 955b7cf..b394647 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -207,6 +207,8 @@ func (s *Server) DiskForecasts(ctx context.Context, in *HostRequest) (*DiskForec PctPerDay: forecast.PctPerDay, BytesPerDay: forecast.BytesPerDay, DaysToFull: forecast.DaysToFull, + NoForecast: forecast.NoForecast, + Samples: int32(forecast.Samples), }) } return list, nil diff --git a/internal/api/api.pb.go b/internal/api/api.pb.go index d4b83d7..0dd52d9 100644 --- a/internal/api/api.pb.go +++ b/internal/api/api.pb.go @@ -1298,8 +1298,13 @@ type DiskForecast struct { UsedPct float64 `protobuf:"fixed64,3,opt,name=usedPct,proto3" json:"usedPct,omitempty"` PctPerDay float64 `protobuf:"fixed64,4,opt,name=pctPerDay,proto3" json:"pctPerDay,omitempty"` BytesPerDay float64 `protobuf:"fixed64,5,opt,name=bytesPerDay,proto3" json:"bytesPerDay,omitempty"` - // unset when the disk is not filling up - DaysToFull *float64 `protobuf:"fixed64,6,opt,name=daysToFull,proto3,oneof" json:"daysToFull,omitempty"` + // unset when the disk is not filling up, and noForecast then says why: + // collecting (history under 24 hours), not_growing, not_steady or + // over_a_year + DaysToFull *float64 `protobuf:"fixed64,6,opt,name=daysToFull,proto3,oneof" json:"daysToFull,omitempty"` + NoForecast string `protobuf:"bytes,7,opt,name=noForecast,proto3" json:"noForecast,omitempty"` + // hours of history behind the forecast + Samples int32 `protobuf:"varint,8,opt,name=samples,proto3" json:"samples,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1376,6 +1381,20 @@ func (x *DiskForecast) GetDaysToFull() float64 { return 0 } +func (x *DiskForecast) GetNoForecast() string { + if x != nil { + return x.NoForecast + } + return "" +} + +func (x *DiskForecast) GetSamples() int32 { + if x != nil { + return x.Samples + } + return 0 +} + type DiskForecastList struct { state protoimpl.MessageState `protogen:"open.v1"` Disks []*DiskForecast `protobuf:"bytes,1,rep,name=disks,proto3" json:"disks,omitempty"` @@ -2364,7 +2383,7 @@ const file_api_api_proto_rawDesc = "" + " \x01(\x03R\n" + "resolvedAt\"5\n" + "\tAlertList\x12(\n" + - "\x06alerts\x18\x01 \x03(\v2\x10.api.AlertRecordR\x06alerts\"\xca\x01\n" + + "\x06alerts\x18\x01 \x03(\v2\x10.api.AlertRecordR\x06alerts\"\x84\x02\n" + "\fDiskForecast\x12\x16\n" + "\x06device\x18\x01 \x01(\tR\x06device\x12\x14\n" + "\x05mount\x18\x02 \x01(\tR\x05mount\x12\x18\n" + @@ -2373,7 +2392,11 @@ const file_api_api_proto_rawDesc = "" + "\vbytesPerDay\x18\x05 \x01(\x01R\vbytesPerDay\x12#\n" + "\n" + "daysToFull\x18\x06 \x01(\x01H\x00R\n" + - "daysToFull\x88\x01\x01B\r\n" + + "daysToFull\x88\x01\x01\x12\x1e\n" + + "\n" + + "noForecast\x18\a \x01(\tR\n" + + "noForecast\x12\x18\n" + + "\asamples\x18\b \x01(\x05R\asamplesB\r\n" + "\v_daysToFull\";\n" + "\x10DiskForecastList\x12'\n" + "\x05disks\x18\x01 \x03(\v2\x11.api.DiskForecastR\x05disks\"M\n" + diff --git a/internal/api/api.proto b/internal/api/api.proto index 6b75cdd..8012404 100644 --- a/internal/api/api.proto +++ b/internal/api/api.proto @@ -153,8 +153,13 @@ message DiskForecast { double usedPct = 3; double pctPerDay = 4; double bytesPerDay = 5; - // unset when the disk is not filling up + // unset when the disk is not filling up, and noForecast then says why: + // collecting (history under 24 hours), not_growing, not_steady or + // over_a_year optional double daysToFull = 6; + string noForecast = 7; + // hours of history behind the forecast + int32 samples = 8; } message DiskForecastList { diff --git a/internal/store/forecast.go b/internal/store/forecast.go index 8c28dc1..91cab5a 100644 --- a/internal/store/forecast.go +++ b/internal/store/forecast.go @@ -19,6 +19,16 @@ const ( forecastHorizonDays = 365 ) +// Why a disk has no forecast +const ( + // NoForecastCollecting is under forecastMinSamples hours of history + NoForecastCollecting = "collecting" + NoForecastNotGrowing = "not_growing" + // NoForecastNotSteady is growth that jumps up and down + NoForecastNotSteady = "not_steady" + NoForecastOverAYear = "over_a_year" +) + // DiskForecast is a disk's growth over the forecast window and when it // fills up at that rate type DiskForecast struct { @@ -28,27 +38,33 @@ type DiskForecast struct { UsedPct float64 PctPerDay float64 BytesPerDay float64 - // DaysToFull is nil when the disk is not filling up + // DaysToFull is nil when the disk is not filling up, and NoForecast + // then says why DaysToFull *float64 + NoForecast string // Samples is the number of hourly values behind the forecast Samples int } -// forecastDays returns how many days are left until a disk is full, and -// false when the disk is not filling up or there is too little history to -// say. df's percent is used / (used + available), where available leaves -// out the blocks reserved for root, so the space left comes from it rather -// than from the disk size. -func forecastDays(usedPct float64, usedBytes float64, bytesPerDay float64, r2 float64, samples int) (float64, bool) { - if samples < forecastMinSamples || bytesPerDay <= 0 || r2 < forecastMinR2 || usedPct <= 0 { - return 0, false +// forecastDays returns how many days are left until a disk is full, or +// why there is no forecast. df's percent is used / (used + available), +// where available leaves out the blocks reserved for root, so the space +// left comes from it rather than from the disk size. +func forecastDays(usedPct float64, usedBytes float64, bytesPerDay float64, r2 float64, samples int) (float64, string) { + switch { + case samples < forecastMinSamples: + return 0, NoForecastCollecting + case bytesPerDay <= 0 || usedPct <= 0: + return 0, NoForecastNotGrowing + case r2 < forecastMinR2: + return 0, NoForecastNotSteady } left := max(usedBytes*(100-usedPct)/usedPct, 0) days := left / bytesPerDay if days > forecastHorizonDays { - return 0, false + return 0, NoForecastOverAYear } - return days, true + return days, "" } // DiskForecasts returns a forecast for each of a host's disks @@ -146,9 +162,11 @@ func (s *Store) queryForecasts(ctx context.Context, filter string, args ...any) if used := valueOrZero(usedBytes); used > 0 { forecast.PctPerDay = forecast.BytesPerDay * forecast.UsedPct / used } - if days, ok := forecastDays(forecast.UsedPct, valueOrZero(usedBytes), forecast.BytesPerDay, valueOrZero(r2), forecast.Samples); ok { + days, noForecast := forecastDays(forecast.UsedPct, valueOrZero(usedBytes), forecast.BytesPerDay, valueOrZero(r2), forecast.Samples) + if noForecast == "" { forecast.DaysToFull = &days } + forecast.NoForecast = noForecast forecasts = append(forecasts, forecast) } return forecasts, rows.Err() diff --git a/internal/store/forecast_test.go b/internal/store/forecast_test.go index 3786455..0555444 100644 --- a/internal/store/forecast_test.go +++ b/internal/store/forecast_test.go @@ -21,22 +21,22 @@ func TestForecastDays(t *testing.T) { r2 float64 samples int wantDays float64 - wantOK bool + wantReason string }{ - {"steady growth", 60, 600 * gb, 20 * gb, 0.95, 168, 20, true}, - {"flat", 60, 600 * gb, 0, 1, 168, 0, false}, - {"shrinking", 60, 600 * gb, -10 * gb, 0.9, 168, 0, false}, - {"noisy", 60, 600 * gb, 20 * gb, 0.3, 168, 0, false}, - {"one day of history", 60, 600 * gb, 20 * gb, 0.95, 24, 20, true}, - {"too little history", 60, 600 * gb, 20 * gb, 0.95, 23, 0, false}, - {"beyond the horizon", 60, 600 * gb, 1 * gb, 0.95, 168, 0, false}, - {"already full", 100, 600 * gb, 1 * gb, 0.95, 168, 0, true}, - {"empty disk", 0, 0, 1 * gb, 0.95, 168, 0, false}, + {"steady growth", 60, 600 * gb, 20 * gb, 0.95, 168, 20, ""}, + {"flat", 60, 600 * gb, 0, 1, 168, 0, NoForecastNotGrowing}, + {"shrinking", 60, 600 * gb, -10 * gb, 0.9, 168, 0, NoForecastNotGrowing}, + {"noisy", 60, 600 * gb, 20 * gb, 0.3, 168, 0, NoForecastNotSteady}, + {"one day of history", 60, 600 * gb, 20 * gb, 0.95, 24, 20, ""}, + {"too little history", 60, 600 * gb, 20 * gb, 0.95, 23, 0, NoForecastCollecting}, + {"beyond the horizon", 60, 600 * gb, 1 * gb, 0.95, 168, 0, NoForecastOverAYear}, + {"already full", 100, 600 * gb, 1 * gb, 0.95, 168, 0, ""}, + {"empty disk", 0, 0, 1 * gb, 0.95, 168, 0, NoForecastNotGrowing}, } for _, tt := range tests { - days, ok := forecastDays(tt.usedPct, tt.usedBytes, tt.bytesPerDay, tt.r2, tt.samples) - if ok != tt.wantOK || days != tt.wantDays { - t.Errorf("%s: got %v %v, want %v %v", tt.name, days, ok, tt.wantDays, tt.wantOK) + days, reason := forecastDays(tt.usedPct, tt.usedBytes, tt.bytesPerDay, tt.r2, tt.samples) + if reason != tt.wantReason || days != tt.wantDays { + t.Errorf("%s: got %v %q, want %v %q", tt.name, days, reason, tt.wantDays, tt.wantReason) } } } @@ -96,7 +96,7 @@ func TestDiskForecasts(t *testing.T) { t.Fatalf("expected 2 disks, got %+v", forecasts) } root, data := forecasts[0], forecasts[1] - if root.Mount != "/" || root.DaysToFull != nil || root.PctPerDay != 0 { + if root.Mount != "/" || root.DaysToFull != nil || root.PctPerDay != 0 || root.NoForecast != NoForecastNotGrowing { t.Errorf("expected / not to be filling up, got %+v", root) } if data.Mount != "/data" || data.DaysToFull == nil || math.Abs(*data.DaysToFull-20) > 2 { From 6a90d1d01e2c1246911cffdf07f48fcf4534572c Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:56:06 +0530 Subject: [PATCH 02/10] flag hosts with outdated agents --- client/internal/server/server.go | 3 +++ client/internal/server/server_test.go | 4 ++-- client/web/src/lib/api.ts | 2 ++ client/web/src/lib/versions.test.ts | 15 ++++++++++++ client/web/src/lib/versions.ts | 6 +++++ client/web/src/pages/Fleet.svelte | 19 ++++++++++++++- client/web/src/pages/Host.svelte | 34 +++++++++++++++++++++++++++ internal/api/api.go | 1 + internal/api/api.pb.go | 16 ++++++++++--- internal/api/api.proto | 2 ++ internal/store/query.go | 3 +++ internal/store/store_test.go | 15 ++++++------ 12 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 client/web/src/lib/versions.test.ts create mode 100644 client/web/src/lib/versions.ts diff --git a/client/internal/server/server.go b/client/internal/server/server.go index 6f7b034..78756e5 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -129,6 +129,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 +158,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}) diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index 9c23cc4..f6af37c 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -82,7 +82,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 } @@ -217,7 +217,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) } } diff --git a/client/web/src/lib/api.ts b/client/web/src/lib/api.ts index 3fee48d..afc5eb4 100644 --- a/client/web/src/lib/api.ts +++ b/client/web/src/lib/api.ts @@ -20,6 +20,8 @@ export interface HostSummary { containers: number; // days until the first disk is full, null when none is filling up diskFullDays: number | null; + // empty from agents older than versions + agentVersion: string; } // An endpoint's newest check up to the end of a range, and how it did over it diff --git a/client/web/src/lib/versions.test.ts b/client/web/src/lib/versions.test.ts new file mode 100644 index 0000000..0429013 --- /dev/null +++ b/client/web/src/lib/versions.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { agentOutdated } from './versions'; + +describe('agentOutdated', () => { + it('compares with the build the dashboard hands out', () => { + expect(agentOutdated('v3.1.0', 'v3.1.0')).toBe(false); + expect(agentOutdated('v3.0.0-17-g543bc75', 'v3.1.0')).toBe(true); + expect(agentOutdated('', 'v3.1.0')).toBe(true); + expect(agentOutdated(undefined, 'v3.1.0')).toBe(true); + }); + + it('says nothing before the dashboard version is known', () => { + expect(agentOutdated('', '')).toBe(false); + }); +}); diff --git a/client/web/src/lib/versions.ts b/client/web/src/lib/versions.ts new file mode 100644 index 0000000..9f08b41 --- /dev/null +++ b/client/web/src/lib/versions.ts @@ -0,0 +1,6 @@ +// An agent is outdated when it is not the build the dashboard hands out, +// which is what the install script upgrades it to. Agents from before +// versions send none. +export function agentOutdated(agentVersion: string | undefined, dashboardVersion: string): boolean { + return dashboardVersion !== '' && agentVersion !== dashboardVersion; +} diff --git a/client/web/src/pages/Fleet.svelte b/client/web/src/pages/Fleet.svelte index bdfb9f3..dc2f174 100644 --- a/client/web/src/pages/Fleet.svelte +++ b/client/web/src/pages/Fleet.svelte @@ -4,6 +4,7 @@ import { appConfig } from '../lib/config.svelte'; import { formatAgo, formatDays, formatDuration, formatRate } from '../lib/format'; import { poll } from '../lib/poll'; + import { agentOutdated } from '../lib/versions'; import { hostPath } from '../lib/router.svelte'; import Meter from '../components/Meter.svelte'; import Sparkline from '../components/Sparkline.svelte'; @@ -52,11 +53,17 @@ return poll(() => untrack(loadSparklines), 60); }); + // only hosts that sent a snapshot say which agent they run + function outdated(host: HostSummary): boolean { + return !!host.time && agentOutdated(host.agentVersion, appConfig.version); + } + function fillsSoon(host: HostSummary): boolean { return host.diskFullDays !== null && host.diskFullDays < diskFullSoonDays; } const up = $derived(hosts.filter((h) => h.up).length); + const outdatedAgents = $derived(hosts.filter(outdated).length); const openAlerts = $derived(hosts.reduce((sum, h) => sum + h.activeAlerts, 0)); const visible = $derived.by(() => { @@ -93,6 +100,13 @@ {#if openAlerts > 0}See alerts{/if} {/snippet} + {#if outdatedAgents > 0} + + {#snippet extra()} + Not on {appConfig.version} + {/snippet} + + {/if}
@@ -151,7 +165,7 @@

No data received yet.

{/if} - {#if host.activeAlerts > 0 || fillsSoon(host)} + {#if host.activeAlerts > 0 || fillsSoon(host) || outdated(host)}
{#if host.activeAlerts > 0} {/if} + {#if outdated(host)} + + {/if}
{/if} diff --git a/client/web/src/pages/Host.svelte b/client/web/src/pages/Host.svelte index b6c9660..5eb7c8d 100644 --- a/client/web/src/pages/Host.svelte +++ b/client/web/src/pages/Host.svelte @@ -6,6 +6,7 @@ import { appConfig } from '../lib/config.svelte'; import { formatAgo, formatBytes, formatDate, formatDays, formatDuration, formatMiB, formatPercent } from '../lib/format'; import { noForecastText, projectDisks } from '../lib/projection'; + import { agentOutdated } from '../lib/versions'; import { poll } from '../lib/poll'; import { hostPath, location, navigate } from '../lib/router.svelte'; import { rangeQuery, resolveRange } from '../lib/timerange'; @@ -155,6 +156,17 @@ {#if hasCustomMetrics}Custom metrics{/if} + {#if snapshot && agentOutdated(snapshot.AgentVersion, appConfig.version)} +
+ +

+ This host runs {snapshot.AgentVersion ? `agent ${snapshot.AgentVersion}` : 'an agent from before versions'}, and the dashboard + hands out {appConfig.version}. To upgrade it, run on the host: +

+ curl -fsSL {window.location.origin}/install.sh | sudo sh +
+ {/if} +
Drag across a chart to zoom in @@ -386,6 +398,28 @@ text-decoration: none; } + .upgrade { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + padding: 10px 14px; + margin-bottom: 12px; + font-size: 13px; + } + + .upgrade p { + margin: 0; + } + + .upgrade code { + font-size: 12px; + padding: 2px 6px; + border-radius: 4px; + background: var(--hover); + overflow-wrap: anywhere; + } + .filters { position: sticky; top: 0; diff --git a/internal/api/api.go b/internal/api/api.go index b394647..6b0a4ec 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -164,6 +164,7 @@ func (s *Server) Fleet(ctx context.Context, in *Void) (*FleetSummary, error) { ActiveAlerts: int32(summary.ActiveAlerts), WorstSeverity: int32(summary.WorstSeverity), Containers: int32(summary.Containers), + AgentVersion: summary.AgentVersion, } if days, ok := diskFull[summary.Name]; ok { host.DiskFullDays = &days diff --git a/internal/api/api.pb.go b/internal/api/api.pb.go index 0dd52d9..11a904e 100644 --- a/internal/api/api.pb.go +++ b/internal/api/api.pb.go @@ -330,7 +330,9 @@ type HostSummary struct { // running containers at the latest snapshot Containers int32 `protobuf:"varint,15,opt,name=containers,proto3" json:"containers,omitempty"` // days until the first disk fills up, unset when none is filling up - DiskFullDays *float64 `protobuf:"fixed64,16,opt,name=diskFullDays,proto3,oneof" json:"diskFullDays,omitempty"` + DiskFullDays *float64 `protobuf:"fixed64,16,opt,name=diskFullDays,proto3,oneof" json:"diskFullDays,omitempty"` + // empty from agents older than versions + AgentVersion string `protobuf:"bytes,17,opt,name=agentVersion,proto3" json:"agentVersion,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -477,6 +479,13 @@ func (x *HostSummary) GetDiskFullDays() float64 { return 0 } +func (x *HostSummary) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + type FleetSummary struct { state protoimpl.MessageState `protogen:"open.v1"` Hosts []*HostSummary `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"` @@ -2301,7 +2310,7 @@ const file_api_api_proto_rawDesc = "" + "\btimezone\x18\x03 \x01(\tR\btimezone\"H\n" + "\x0eEnrollResponse\x12\x1a\n" + "\bhostName\x18\x01 \x01(\tR\bhostName\x12\x1a\n" + - "\bagentKey\x18\x02 \x01(\tR\bagentKey\"\xe3\x03\n" + + "\bagentKey\x18\x02 \x01(\tR\bagentKey\"\x87\x04\n" + "\vHostSummary\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + "\x02up\x18\x02 \x01(\bR\x02up\x12\x1a\n" + @@ -2323,7 +2332,8 @@ const file_api_api_proto_rawDesc = "" + "\n" + "containers\x18\x0f \x01(\x05R\n" + "containers\x12'\n" + - "\fdiskFullDays\x18\x10 \x01(\x01H\x00R\fdiskFullDays\x88\x01\x01B\x0f\n" + + "\fdiskFullDays\x18\x10 \x01(\x01H\x00R\fdiskFullDays\x88\x01\x01\x12\"\n" + + "\fagentVersion\x18\x11 \x01(\tR\fagentVersionB\x0f\n" + "\r_diskFullDays\"6\n" + "\fFleetSummary\x12&\n" + "\x05hosts\x18\x01 \x03(\v2\x10.api.HostSummaryR\x05hosts\"!\n" + diff --git a/internal/api/api.proto b/internal/api/api.proto index 8012404..eb59f80 100644 --- a/internal/api/api.proto +++ b/internal/api/api.proto @@ -54,6 +54,8 @@ message HostSummary { int32 containers = 15; // days until the first disk fills up, unset when none is filling up optional double diskFullDays = 16; + // empty from agents older than versions + string agentVersion = 17; } message FleetSummary { diff --git a/internal/store/query.go b/internal/store/query.go index ea28eb1..6c85cb9 100644 --- a/internal/store/query.go +++ b/internal/store/query.go @@ -32,6 +32,8 @@ type HostSummary struct { WorstSeverity int // Containers is how many containers were running at the latest snapshot Containers int + // AgentVersion is empty from agents older than versions + AgentVersion string } func (s *Store) FleetSummary(ctx context.Context) ([]HostSummary, error) { @@ -78,6 +80,7 @@ func fillSummary(summary *HostSummary, data *monitor.MonitorData) { summary.MemUsedPct = data.Memory.PercentageUsed summary.SwapUsedPct = data.Swap.PercentageUsed summary.Containers = len(data.Containers) + summary.AgentVersion = data.AgentVersion for _, disk := range data.Disk { if pct := parsePercent(disk.Usage.Usage); pct != nil && *pct > summary.DiskUsedPct { summary.DiskUsedPct = *pct diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7b916a7..0ed1bda 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -77,12 +77,13 @@ func testStore(t *testing.T) *Store { func testSnapshot(host string, at time.Time) *monitor.MonitorData { return &monitor.MonitorData{ - UnixTime: strconv.FormatInt(at.Unix(), 10), - ServerId: host, - System: monitor.System{HostName: host, OS: "Debian 13", UpTimeSeconds: 3600}, - Memory: monitor.Memory{PercentageUsed: 42.5, Used: 7000, Available: 9000, Total: 16000, Unit: "MB"}, - Swap: monitor.Swap{PercentageUsed: 1, Used: 20, Total: 2048, Unit: "MB"}, - ProcUsage: monitor.CPU{LoadAvg: 37, CoreAvg: []int{30, 44}, Load1: 0.5, Load5: 0.4, Load15: 0.3}, + UnixTime: strconv.FormatInt(at.Unix(), 10), + ServerId: host, + AgentVersion: "v3.1.0", + System: monitor.System{HostName: host, OS: "Debian 13", UpTimeSeconds: 3600}, + Memory: monitor.Memory{PercentageUsed: 42.5, Used: 7000, Available: 9000, Total: 16000, Unit: "MB"}, + Swap: monitor.Swap{PercentageUsed: 1, Used: 20, Total: 2048, Unit: "MB"}, + ProcUsage: monitor.CPU{LoadAvg: 37, CoreAvg: []int{30, 44}, Load1: 0.5, Load5: 0.4, Load15: 0.3}, Disk: []monitor.Disk{ {FileSystem: "/dev/sda1", MountedOn: "/", Type: "ext4", Usage: monitor.DiskUsage{Size: 1000, Used: 400, Usage: "40%"}, Inodes: monitor.InodeUsage{Usage: "10%"}}, {FileSystem: "/dev/sdb1", MountedOn: "/data", Type: "ext4", Usage: monitor.DiskUsage{Size: 1000, Used: 910, Usage: "91%"}, Inodes: monitor.InodeUsage{Usage: "5%"}}, @@ -238,7 +239,7 @@ func TestSnapshotAndQueries(t *testing.T) { t.Fatalf("expected one host, got %+v", fleet) } host := fleet[0] - if host.CPUPct != 37 || host.DiskUsedPct != 91 || host.RxBps != 100 || host.TxBps != 50 || host.LastSeen.IsZero() { + if host.CPUPct != 37 || host.DiskUsedPct != 91 || host.RxBps != 100 || host.TxBps != 50 || host.LastSeen.IsZero() || host.AgentVersion != "v3.1.0" { t.Errorf("unexpected summary: %+v", host) } }) From 02caa3b6abbfbd8b87878def2520f95a0c3346aa Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:58:13 +0530 Subject: [PATCH 03/10] refuse to start on a broken alerts.json --- collector/main.go | 15 ++-- internal/alerts/alert.go | 18 ---- internal/alerts/rules.go | 165 ++++++++++++++++++++++++++++++++++ internal/alerts/rules_test.go | 47 ++++++++++ 4 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 internal/alerts/rules.go create mode 100644 internal/alerts/rules_test.go diff --git a/collector/main.go b/collector/main.go index 4619e09..c32a60f 100644 --- a/collector/main.go +++ b/collector/main.go @@ -56,13 +56,6 @@ func main() { log.SetOutput(file) } - if len(config.AlertsFilePath) > 0 { - if _, err := os.Stat(config.AlertsFilePath); errors.Is(err, os.ErrNotExist) { - logger.Log("cannot load alert config: ", err.Error()) - } - alertConfig = alerts.GetAlertConfig(config.AlertsFilePath) - } - ctx := context.Background() st, err := openStore(ctx, &config) if err != nil { @@ -97,6 +90,14 @@ func main() { return } + // a broken file would otherwise leave the collector running with no alerts + if len(config.AlertsFilePath) > 0 { + alertConfig, err = alerts.LoadRules(config.AlertsFilePath) + if err != nil { + log.Fatal("cannot load alert rules, fix the file or unset SYMON_ALERTS_CONFIG_PATH: ", err) + } + } + if err := st.Migrate(ctx); err != nil { log.Fatal("cannot update database schema: ", err) } diff --git a/internal/alerts/alert.go b/internal/alerts/alert.go index b7cee71..50b21ad 100644 --- a/internal/alerts/alert.go +++ b/internal/alerts/alert.go @@ -1,10 +1,5 @@ package alerts -import ( - "encoding/json" - "os" -) - type AlertConfig struct { Name string Description string @@ -42,16 +37,3 @@ type Alert struct { CriticalThreshold int TriggerIntveral int } - -func GetAlertConfig(path string) []AlertConfig { - file, err := os.ReadFile(path) - alertConfig := []AlertConfig{} - - if err != nil { - return alertConfig - } - - _ = json.Unmarshal([]byte(file), &alertConfig) - - return alertConfig -} diff --git a/internal/alerts/rules.go b/internal/alerts/rules.go new file mode 100644 index 0000000..235b72e --- /dev/null +++ b/internal/alerts/rules.go @@ -0,0 +1,165 @@ +package alerts + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/dhamith93/SyMon/internal/monitor" +) + +// AllHosts in a rule's Servers means every registered host +const AllHosts = "*" + +var thresholdOps = map[string]bool{">": true, "<": true, ">=": true, "<=": true, "==": true, "!=": true} + +// LoadRules reads an alerts.json file. Any mistake in it is an error, so a +// typo cannot quietly switch alerts off. +func LoadRules(path string) ([]AlertConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + rules, err := ParseRules(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return rules, nil +} + +// ParseRules reads a JSON list of rules. Unknown fields are errors, since +// they are almost always a misspelled one. +func ParseRules(data []byte) ([]AlertConfig, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var rules []AlertConfig + if err := decoder.Decode(&rules); err != nil { + return nil, jsonError(data, err) + } + names := map[string]bool{} + for i := range rules { + rule := &rules[i] + if err := rule.Validate(); err != nil { + return nil, fmt.Errorf("rule %d %q: %w", i+1, rule.Name, err) + } + if names[rule.Name] { + return nil, fmt.Errorf("rule %d: there is already a rule named %q", i+1, rule.Name) + } + names[rule.Name] = true + } + return rules, nil +} + +// jsonError adds the line and column to a syntax error +func jsonError(data []byte, err error) error { + var syntax *json.SyntaxError + var wrongType *json.UnmarshalTypeError + offset := int64(-1) + switch { + case errors.As(err, &syntax): + offset = syntax.Offset + case errors.As(err, &wrongType): + offset = wrongType.Offset + } + if offset < 0 { + return err + } + // the offset counts the byte that went wrong + before := data[:min(int(offset), len(data))] + line := bytes.Count(before, []byte("\n")) + 1 + column := max(len(before)-bytes.LastIndexByte(before, '\n')-1, 1) + return fmt.Errorf("line %d, column %d: %w", line, column, err) +} + +// Validate checks that a rule has what its metric needs +func (rule *AlertConfig) Validate() error { + rule.Name = strings.TrimSpace(rule.Name) + switch { + case rule.Name == "": + return errors.New("a rule needs a Name") + case len(rule.Name) > 100: + return errors.New("a Name can be at most 100 characters") + case rule.TriggerIntveral < 0: + return errors.New("TriggerIntveral cannot be negative") + } + + if rule.IsCustom { + if strings.TrimSpace(rule.MetricName) == "" { + return errors.New("a custom metric rule needs MetricName, the custom metric's name") + } + return firstError(rule.checkServers(), rule.checkOp()) + } + + switch rule.MetricName { + case monitor.PROC_USAGE, monitor.MEMORY, monitor.SWAP: + return firstError(rule.checkServers(), rule.checkOp()) + case monitor.DISKS, monitor.DISK_FORECAST: + if strings.TrimSpace(rule.Disk) == "" { + return errors.New("a disk rule needs Disk, the device like /dev/sda1") + } + return firstError(rule.checkServers(), rule.checkOp()) + case monitor.SERVICES: + if strings.TrimSpace(rule.Service) == "" { + return errors.New("a service rule needs Service, a name from the agent's service list") + } + if rule.Op != "active" && rule.Op != "inactive" { + return errors.New(`a service rule needs Op "inactive" (alert when it stops) or "active" (alert when it runs)`) + } + return rule.checkServers() + case monitor.PING: + return rule.checkServers() + case monitor.ENDPOINT: + return rule.checkEndpoint() + case "": + return errors.New("a rule needs MetricName") + } + return fmt.Errorf("unknown MetricName %q, it can be procUsage, memory, swap, disks, disk_forecast, services, ping, endpoint, or a custom metric's name with IsCustom", rule.MetricName) +} + +func (rule *AlertConfig) checkServers() error { + if len(rule.Servers) == 0 { + return fmt.Errorf(`a rule needs Servers, host names or "%s" for all hosts`, AllHosts) + } + for _, server := range rule.Servers { + if strings.TrimSpace(server) == "" { + return errors.New("Servers has an empty host name") + } + } + return nil +} + +func (rule *AlertConfig) checkOp() error { + if !thresholdOps[rule.Op] { + return fmt.Errorf("unknown Op %q, it can be >, <, >=, <=, == or !=", rule.Op) + } + return nil +} + +func (rule *AlertConfig) checkEndpoint() error { + target, err := url.Parse(rule.Endpoint) + if err != nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" { + return errors.New("an endpoint rule needs Endpoint, an http:// or https:// URL") + } + switch strings.ToUpper(strings.TrimSpace(rule.Method)) { + case "", "GET", "HEAD", "POST": + default: + return fmt.Errorf("unknown Method %q, it can be GET, HEAD or POST", rule.Method) + } + if rule.ExpectedHTTPCode != 0 && (rule.ExpectedHTTPCode < 100 || rule.ExpectedHTTPCode > 599) { + return errors.New("ExpectedHTTPCode has to be an HTTP status code, like 200") + } + return nil +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/alerts/rules_test.go b/internal/alerts/rules_test.go new file mode 100644 index 0000000..5fb0408 --- /dev/null +++ b/internal/alerts/rules_test.go @@ -0,0 +1,47 @@ +package alerts + +import ( + "strings" + "testing" +) + +func TestParseRules(t *testing.T) { + rules, err := ParseRules([]byte(`[ + {"Name": "CPU", "MetricName": "procUsage", "Servers": ["*"], "Op": ">", "WarnThreshold": 80, "CriticalThreshold": 95}, + {"Name": "Shop", "MetricName": "endpoint", "Endpoint": "https://shop.example.com", "PagerDuty": true} + ]`)) + if err != nil || len(rules) != 2 || rules[0].Servers[0] != AllHosts || !rules[1].Pagerduty { + t.Fatalf("expected two rules, got %+v %v", rules, err) + } + + tests := []struct { + name string + json string + want string + }{ + {"syntax", "[\n {\"Name\": \"CPU\",}\n]", "line 2, column 18"}, + {"misspelled field", `[{"Name": "CPU", "MetricName": "procUsage", "Servers": ["web1"], "Op": ">", "WarnTreshold": 80}]`, `unknown field "WarnTreshold"`}, + {"wrong type", `[{"Name": "CPU", "MetricName": "procUsage", "WarnThreshold": "80"}]`, "line 1"}, + {"no name", `[{"MetricName": "procUsage", "Servers": ["web1"], "Op": ">"}]`, "needs a Name"}, + {"unknown metric", `[{"Name": "x", "MetricName": "cpu", "Servers": ["web1"], "Op": ">"}]`, `unknown MetricName "cpu"`}, + {"no servers", `[{"Name": "x", "MetricName": "memory", "Op": ">"}]`, "needs Servers"}, + {"bad op", `[{"Name": "x", "MetricName": "memory", "Servers": ["web1"], "Op": "=>"}]`, `unknown Op "=>"`}, + {"disk without device", `[{"Name": "x", "MetricName": "disks", "Servers": ["web1"], "Op": ">"}]`, "needs Disk"}, + {"service op", `[{"Name": "x", "MetricName": "services", "Service": "nginx", "Servers": ["web1"], "Op": ">"}]`, `Op "inactive"`}, + {"endpoint url", `[{"Name": "x", "MetricName": "endpoint", "Endpoint": "shop.example.com"}]`, "http:// or https:// URL"}, + {"custom without name", `[{"Name": "x", "IsCustom": true, "Servers": ["web1"], "Op": ">"}]`, "custom metric"}, + {"duplicate names", `[{"Name": "x", "MetricName": "ping", "Servers": ["*"]}, {"Name": "x", "MetricName": "ping", "Servers": ["*"]}]`, "already a rule named"}, + } + for _, tt := range tests { + _, err := ParseRules([]byte(tt.json)) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("%s: expected an error with %q, got %v", tt.name, tt.want, err) + } + } +} + +func TestSampleRulesAreValid(t *testing.T) { + if _, err := LoadRules("../../collector/alerts.json"); err != nil { + t.Errorf("the sample alerts.json does not load: %v", err) + } +} From 0ccffd5fc1962f7562d0213c6bd3a4ab459daaaf Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 01:01:06 +0530 Subject: [PATCH 04/10] add admin and viewer roles, and let users change their password --- client/internal/server/auth.go | 94 +++++++++++--- client/internal/server/auth_test.go | 27 +++- client/internal/server/server.go | 1 + client/internal/server/server_test.go | 24 +++- client/web/src/App.svelte | 10 +- client/web/src/lib/api.ts | 11 +- client/web/src/lib/auth.svelte.ts | 7 +- client/web/src/lib/router.svelte.ts | 2 + client/web/src/pages/Account.svelte | 116 +++++++++++++++++ collector/main.go | 7 +- collector/users.go | 29 ++++- internal/api/api.pb.go | 163 +++++++++++++++++------- internal/api/api.proto | 11 +- internal/api/api_grpc.pb.go | 40 ++++++ internal/api/login.go | 20 ++- internal/store/migrations/008_roles.sql | 3 + internal/store/users.go | 99 +++++++++++--- internal/store/users_test.go | 63 ++++++++- 18 files changed, 622 insertions(+), 105 deletions(-) create mode 100644 client/web/src/pages/Account.svelte create mode 100644 internal/store/migrations/008_roles.sql 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/server.go b/client/internal/server/server.go index 78756e5..41a0175 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -82,6 +82,7 @@ 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("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "no such endpoint") }) diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index f6af37c..aa908d2 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -36,12 +36,28 @@ type fakeCollector struct { loggedOut 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 +76,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) { diff --git a/client/web/src/App.svelte b/client/web/src/App.svelte index f77deb3..9a653b6 100644 --- a/client/web/src/App.svelte +++ b/client/web/src/App.svelte @@ -3,6 +3,7 @@ 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'; @@ -43,7 +44,7 @@ {/if} {#if auth.state === 'in'} - {auth.user} + {auth.user} {/if}