diff --git a/.drone.jsonnet b/.drone.jsonnet index 0bd6db26..fe6cf764 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -321,7 +321,6 @@ local build(arch, testUI) = [{ trigger: { event: [ 'push', - 'pull_request', ], }, services: [ diff --git a/backend/cmd/stability/main.go b/backend/cmd/stability/main.go index 08ce1160..c955ad54 100644 --- a/backend/cmd/stability/main.go +++ b/backend/cmd/stability/main.go @@ -16,7 +16,12 @@ func main() { defer cancel() mem := stability.NewMemInfo("/proc") - z := stability.NewZram(mem, stability.SwaponSyscall, stability.SwapoffSyscall, logger) + commonDir := os.Getenv("SNAP_COMMON") + if commonDir == "" { + commonDir = "/var/snap/platform/common" + } + events := stability.NewEventLog(commonDir + "/stability-events.jsonl") + z := stability.NewZram(mem, stability.SwaponSyscall, stability.SwapoffSyscall, events, logger) if err := z.EnsureConfigured(); err != nil { logger.Sugar().Warnf("stability: zram setup failed (continuing): %v", err) } @@ -24,7 +29,7 @@ func main() { scan := stability.NewProcScanner("/proc") w := stability.NewWatcher(mem, scan, func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) - }, logger) + }, events, logger) if err := w.Run(ctx); err != nil && err != context.Canceled { logger.Sugar().Errorf("stability: watcher exited: %v", err) diff --git a/backend/health/health.go b/backend/health/health.go new file mode 100644 index 00000000..af5efbf9 --- /dev/null +++ b/backend/health/health.go @@ -0,0 +1,22 @@ +package health + +import ( + "github.com/syncloud/platform/stability" +) + +type Health struct { + events *stability.EventLog + collector *Collector +} + +func NewHealth(events *stability.EventLog, collector *Collector) *Health { + return &Health{events: events, collector: collector} +} + +func (h *Health) Events(limit int) ([]stability.Event, error) { + return h.events.Recent(limit) +} + +func (h *Health) Metrics() (Snapshot, error) { + return h.collector.Snapshot() +} diff --git a/backend/health/metrics.go b/backend/health/metrics.go new file mode 100644 index 00000000..cbe04c19 --- /dev/null +++ b/backend/health/metrics.go @@ -0,0 +1,234 @@ +package health + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type CPU struct { + User uint64 `json:"user"` + Nice uint64 `json:"nice"` + System uint64 `json:"system"` + Idle uint64 `json:"idle"` + IOWait uint64 `json:"iowait"` + IRQ uint64 `json:"irq"` + SoftIRQ uint64 `json:"softirq"` + Steal uint64 `json:"steal"` +} + +func (c CPU) Total() uint64 { + return c.User + c.Nice + c.System + c.Idle + c.IOWait + c.IRQ + c.SoftIRQ + c.Steal +} + +func (c CPU) Busy() uint64 { + return c.Total() - c.Idle - c.IOWait +} + +type Memory struct { + TotalKB uint64 `json:"total_kb"` + AvailableKB uint64 `json:"available_kb"` + FreeKB uint64 `json:"free_kb"` + BuffersKB uint64 `json:"buffers_kb"` + CachedKB uint64 `json:"cached_kb"` + SwapTotalKB uint64 `json:"swap_total_kb"` + SwapFreeKB uint64 `json:"swap_free_kb"` +} + +type Disk struct { + Name string `json:"name"` + ReadsTotal uint64 `json:"reads_total"` + WritesTotal uint64 `json:"writes_total"` + SectorsRead uint64 `json:"sectors_read"` + SectorsWrt uint64 `json:"sectors_written"` +} + +type Mount struct { + Path string `json:"path"` + TotalKB uint64 `json:"total_kb"` + UsedKB uint64 `json:"used_kb"` +} + +type Net struct { + Name string `json:"name"` + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` +} + +type Snapshot struct { + CPU CPU `json:"cpu"` + Memory Memory `json:"memory"` + Disks []Disk `json:"disks"` + Mounts []Mount `json:"mounts"` + Net []Net `json:"net"` +} + +type Collector struct { + procDir string +} + +func NewCollector(procDir string) *Collector { + return &Collector{procDir: procDir} +} + +func (c *Collector) Snapshot() (Snapshot, error) { + var s Snapshot + cpu, err := c.readCPU() + if err != nil { + return s, err + } + s.CPU = cpu + mem, err := c.readMemory() + if err != nil { + return s, err + } + s.Memory = mem + s.Disks, _ = c.readDisks() + s.Net, _ = c.readNet() + s.Mounts = c.Mounts() + return s, nil +} + +func (c *Collector) readCPU() (CPU, error) { + f, err := os.Open(filepath.Join(c.procDir, "stat")) + if err != nil { + return CPU{}, err + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line) + nums := make([]uint64, 0, 8) + for _, fld := range fields[1:] { + n, _ := strconv.ParseUint(fld, 10, 64) + nums = append(nums, n) + } + for len(nums) < 8 { + nums = append(nums, 0) + } + return CPU{nums[0], nums[1], nums[2], nums[3], nums[4], nums[5], nums[6], nums[7]}, nil + } + return CPU{}, fmt.Errorf("cpu: 'cpu ' line missing") +} + +func (c *Collector) readMemory() (Memory, error) { + f, err := os.Open(filepath.Join(c.procDir, "meminfo")) + if err != nil { + return Memory{}, err + } + defer f.Close() + m := Memory{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + idx := strings.IndexByte(line, ':') + if idx < 0 { + continue + } + key := line[:idx] + rest := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line[idx+1:]), " kB")) + v, _ := strconv.ParseUint(rest, 10, 64) + switch key { + case "MemTotal": + m.TotalKB = v + case "MemAvailable": + m.AvailableKB = v + case "MemFree": + m.FreeKB = v + case "Buffers": + m.BuffersKB = v + case "Cached": + m.CachedKB = v + case "SwapTotal": + m.SwapTotalKB = v + case "SwapFree": + m.SwapFreeKB = v + } + } + return m, sc.Err() +} + +func (c *Collector) readDisks() ([]Disk, error) { + f, err := os.Open(filepath.Join(c.procDir, "diskstats")) + if err != nil { + return nil, err + } + defer f.Close() + var out []Disk + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 14 { + continue + } + name := fields[2] + if isPartition(name) { + continue + } + reads, _ := strconv.ParseUint(fields[3], 10, 64) + sectorsRead, _ := strconv.ParseUint(fields[5], 10, 64) + writes, _ := strconv.ParseUint(fields[7], 10, 64) + sectorsWrt, _ := strconv.ParseUint(fields[9], 10, 64) + out = append(out, Disk{ + Name: name, + ReadsTotal: reads, + SectorsRead: sectorsRead, + WritesTotal: writes, + SectorsWrt: sectorsWrt, + }) + } + return out, sc.Err() +} + +func isPartition(name string) bool { + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") { + return true + } + if len(name) == 0 { + return true + } + last := name[len(name)-1] + if last < '0' || last > '9' { + return false + } + if strings.HasPrefix(name, "mmcblk") || strings.HasPrefix(name, "nvme") { + return strings.Contains(name, "p") + } + return true +} + +func (c *Collector) readNet() ([]Net, error) { + f, err := os.Open(filepath.Join(c.procDir, "net/dev")) + if err != nil { + return nil, err + } + defer f.Close() + var out []Net + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + idx := strings.IndexByte(line, ':') + if idx < 0 { + continue + } + name := strings.TrimSpace(line[:idx]) + if name == "lo" { + continue + } + fields := strings.Fields(line[idx+1:]) + if len(fields) < 9 { + continue + } + rx, _ := strconv.ParseUint(fields[0], 10, 64) + tx, _ := strconv.ParseUint(fields[8], 10, 64) + out = append(out, Net{Name: name, RxBytes: rx, TxBytes: tx}) + } + return out, sc.Err() +} diff --git a/backend/health/metrics_test.go b/backend/health/metrics_test.go new file mode 100644 index 00000000..5adb2077 --- /dev/null +++ b/backend/health/metrics_test.go @@ -0,0 +1,104 @@ +package health + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeProc(t *testing.T, dir, rel, contents string) { + t.Helper() + p := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0755)) + require.NoError(t, os.WriteFile(p, []byte(contents), 0644)) +} + +func newTestCollector(t *testing.T) (*Collector, string) { + t.Helper() + dir := t.TempDir() + writeProc(t, dir, "stat", "cpu 1000 50 200 5000 30 0 10 0\ncpu0 ...\n") + writeProc(t, dir, "meminfo", "MemTotal: 3700000 kB\nMemAvailable: 1500000 kB\nMemFree: 200000 kB\nBuffers: 50000 kB\nCached: 900000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1500000 kB\n") + writeProc(t, dir, "diskstats", " 8 0 sda 100 0 200 0 10 0 20 0 0 0 0 0 0 0\n"+ + " 8 1 sda1 50 0 100 0 5 0 10 0 0 0 0 0 0 0\n"+ + " 179 0 mmcblk0 1000 0 2000 0 100 0 200 0 0 0 0 0 0 0\n"+ + " 179 1 mmcblk0p1 500 0 1000 0 50 0 100 0 0 0 0 0 0 0\n"+ + " 7 0 loop0 1 0 2 0 0 0 0 0 0 0 0 0 0 0\n") + writeProc(t, dir, "net/dev", `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1000 10 0 0 0 0 0 0 1000 10 0 0 0 0 0 0 + eth0: 5000 20 0 0 0 0 0 0 8000 30 0 0 0 0 0 0 +`) + return NewCollector(dir), dir +} + +func TestReadCPU(t *testing.T) { + c, _ := newTestCollector(t) + cpu, err := c.readCPU() + require.NoError(t, err) + assert.Equal(t, uint64(1000), cpu.User) + assert.Equal(t, uint64(5000), cpu.Idle) + assert.Equal(t, uint64(30), cpu.IOWait) +} + +func TestReadMemory(t *testing.T) { + c, _ := newTestCollector(t) + mem, err := c.readMemory() + require.NoError(t, err) + assert.Equal(t, uint64(3700000), mem.TotalKB) + assert.Equal(t, uint64(1500000), mem.AvailableKB) + assert.Equal(t, uint64(2000000), mem.SwapTotalKB) +} + +func TestReadDisksFiltersPartitionsAndLoops(t *testing.T) { + c, _ := newTestCollector(t) + disks, err := c.readDisks() + require.NoError(t, err) + names := []string{} + for _, d := range disks { + names = append(names, d.Name) + } + assert.ElementsMatch(t, []string{"sda", "mmcblk0"}, names) +} + +func TestReadNetSkipsLoopback(t *testing.T) { + c, _ := newTestCollector(t) + nets, err := c.readNet() + require.NoError(t, err) + require.Len(t, nets, 1) + assert.Equal(t, "eth0", nets[0].Name) + assert.Equal(t, uint64(5000), nets[0].RxBytes) + assert.Equal(t, uint64(8000), nets[0].TxBytes) +} + +func TestSnapshotEndToEnd(t *testing.T) { + c, _ := newTestCollector(t) + s, err := c.Snapshot() + require.NoError(t, err) + assert.Equal(t, uint64(1000), s.CPU.User) + assert.Equal(t, uint64(3700000), s.Memory.TotalKB) + require.Len(t, s.Disks, 2) + require.Len(t, s.Net, 1) +} + +func TestIsPartition(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {"sda", false}, + {"sda1", true}, + {"mmcblk0", false}, + {"mmcblk0p1", true}, + {"nvme0n1", false}, + {"nvme0n1p1", true}, + {"loop0", true}, + {"dm-0", true}, + {"ram0", true}, + } + for _, c := range cases { + assert.Equal(t, c.want, isPartition(c.name), c.name) + } +} diff --git a/backend/health/mounts.go b/backend/health/mounts.go new file mode 100644 index 00000000..fb74904f --- /dev/null +++ b/backend/health/mounts.go @@ -0,0 +1,45 @@ +package health + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "syscall" +) + +func (c *Collector) Mounts() []Mount { + f, err := os.Open(filepath.Join(c.procDir, "mounts")) + if err != nil { + return nil + } + defer f.Close() + var out []Mount + seen := map[string]bool{} + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 3 { + continue + } + dev, mount, fs := fields[0], fields[1], fields[2] + if !strings.HasPrefix(dev, "/dev/") { + continue + } + if fs == "squashfs" || fs == "tmpfs" || fs == "devtmpfs" { + continue + } + if seen[mount] { + continue + } + seen[mount] = true + var st syscall.Statfs_t + if err := syscall.Statfs(mount, &st); err != nil { + continue + } + total := st.Blocks * uint64(st.Bsize) / 1024 + free := st.Bavail * uint64(st.Bsize) / 1024 + out = append(out, Mount{Path: mount, TotalKB: total, UsedKB: total - free}) + } + return out +} diff --git a/backend/ioc/common.go b/backend/ioc/common.go index 80257dbf..274aa376 100644 --- a/backend/ioc/common.go +++ b/backend/ioc/common.go @@ -15,6 +15,7 @@ import ( "github.com/syncloud/platform/date" "github.com/syncloud/platform/du" "github.com/syncloud/platform/event" + "github.com/syncloud/platform/health" "github.com/syncloud/platform/hook" "github.com/syncloud/platform/identification" "github.com/syncloud/platform/installer" @@ -26,6 +27,7 @@ import ( "github.com/syncloud/platform/rest" "github.com/syncloud/platform/session" "github.com/syncloud/platform/snap" + "github.com/syncloud/platform/stability" "github.com/syncloud/platform/storage" "github.com/syncloud/platform/storage/btrfs" "github.com/syncloud/platform/support" @@ -570,5 +572,24 @@ func Init(userConfig string, systemConfig string, backupDir string, varDir strin return nil, err } + err = c.Singleton(func() *stability.EventLog { + return stability.NewEventLog("/var/snap/platform/common/stability-events.jsonl") + }) + if err != nil { + return nil, err + } + err = c.Singleton(func() *health.Collector { + return health.NewCollector("/proc") + }) + if err != nil { + return nil, err + } + err = c.Singleton(func(events *stability.EventLog, collector *health.Collector) *health.Health { + return health.NewHealth(events, collector) + }) + if err != nil { + return nil, err + } + return c, nil } diff --git a/backend/ioc/public_api.go b/backend/ioc/public_api.go index 17445b43..1ee08aa8 100644 --- a/backend/ioc/public_api.go +++ b/backend/ioc/public_api.go @@ -9,6 +9,7 @@ import ( "github.com/syncloud/platform/config" "github.com/syncloud/platform/cron" "github.com/syncloud/platform/event" + "github.com/syncloud/platform/health" "github.com/syncloud/platform/identification" "github.com/syncloud/platform/installer" "github.com/syncloud/platform/job" @@ -39,12 +40,13 @@ func InitPublicApi(userConfig string, systemConfig string, backupDir string, var changesClient *snap.ChangesClient, oidcService *auth.OIDCService, authelia *auth.Authelia, totp *auth.TOTP, tz *timezone.Applier, + healthService *health.Health, ) *rest.Backend { return rest.NewBackend(master, backupService, eventTrigger, worker, redirectService, installerService, storageService, id, activate, userConfig, redirectConfig, cert, externalAddress, snapd, disks, journalCtl, executor, iface, sender, proxy, customProxy, ldapService, middleware, cookies, net, address, changesClient, - oidcService, authelia, totp, tz, logger) + oidcService, authelia, totp, tz, healthService, logger) }) if err != nil { return nil, err diff --git a/backend/rest/backend.go b/backend/rest/backend.go index c6b9f99f..ed551cb4 100644 --- a/backend/rest/backend.go +++ b/backend/rest/backend.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "strconv" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/syncloud/platform/cli" "github.com/syncloud/platform/config" "github.com/syncloud/platform/event" + "github.com/syncloud/platform/health" "github.com/syncloud/platform/identification" "github.com/syncloud/platform/installer" "github.com/syncloud/platform/job" @@ -62,6 +64,7 @@ type Backend struct { authelia *auth.Authelia totp *auth.TOTP timezone *timezone.Applier + health *health.Health network string address string logger *zap.Logger @@ -79,6 +82,7 @@ func NewBackend( changesClient *snap.ChangesClient, oidcService *auth.OIDCService, authelia *auth.Authelia, totp *auth.TOTP, timezone *timezone.Applier, + healthService *health.Health, logger *zap.Logger) *Backend { return &Backend{ @@ -110,6 +114,7 @@ func NewBackend( authelia: authelia, totp: totp, timezone: timezone, + health: healthService, network: network, address: address, changesClient: changesClient, @@ -152,6 +157,8 @@ func (b *Backend) Start() error { r.HandleFunc("/rest/settings/timezone", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.GetTimezone))).Methods("GET") r.HandleFunc("/rest/settings/timezone", b.mw.FailIfNotActivated(b.mw.AdminSecuredHandle(b.SetTimezone))).Methods("POST") r.HandleFunc("/rest/settings/time", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.GetTime))).Methods("GET") + r.HandleFunc("/rest/settings/health/events", b.mw.FailIfNotActivated(b.mw.AdminSecuredHandle(b.HealthEvents))).Methods("GET") + r.HandleFunc("/rest/settings/health/metrics", b.mw.FailIfNotActivated(b.mw.AdminSecuredHandle(b.HealthMetrics))).Methods("GET") // /rest/totp/setup is handled by the login service, not the backend r.HandleFunc("/rest/job/status", b.mw.FailIfNotActivated(b.mw.AdminSecuredHandle(b.JobStatus))).Methods("GET") r.HandleFunc("/rest/backup/list", b.mw.FailIfNotActivated(b.mw.AdminSecuredHandle(b.BackupList))).Methods("GET") @@ -611,6 +618,20 @@ func (b *Backend) UserLogout(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, autheliaLogout, http.StatusFound) } +func (b *Backend) HealthEvents(req *http.Request) (interface{}, error) { + limit := 100 + if v := req.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { + limit = n + } + } + return b.health.Events(limit) +} + +func (b *Backend) HealthMetrics(_ *http.Request) (interface{}, error) { + return b.health.Metrics() +} + func (b *Backend) GetTwoFactorSettings(_ *http.Request) (interface{}, error) { return map[string]interface{}{ "enabled": b.userConfig.IsTwoFactorEnabled(), diff --git a/backend/stability/events.go b/backend/stability/events.go new file mode 100644 index 00000000..c916bb44 --- /dev/null +++ b/backend/stability/events.go @@ -0,0 +1,148 @@ +package stability + +import ( + "encoding/json" + "errors" + "os" + "sync" + "time" +) + +const ( + maxLogFileBytes = 256 * 1024 + keepEvents = 1000 + defaultLimit = 100 +) + +type EventKind string + +const ( + EventKindZramEnabled EventKind = "zram_enabled" + EventKindSwapoffFile EventKind = "swapoff_file" + EventKindPressure EventKind = "pressure_detected" + EventKindVictimSigterm EventKind = "victim_sigterm" + EventKindVictimSigkill EventKind = "victim_sigkill" +) + +type Event struct { + Time time.Time `json:"time"` + Kind EventKind `json:"kind"` + Message string `json:"message,omitempty"` + PID int `json:"pid,omitempty"` + Comm string `json:"comm,omitempty"` + RSSkb uint64 `json:"rss_kb,omitempty"` + Cgroup string `json:"cgroup,omitempty"` + AvailRatio float64 `json:"avail_ratio,omitempty"` + PSIavg10 float64 `json:"psi_avg10,omitempty"` + Path string `json:"path,omitempty"` + SizeBytes uint64 `json:"size_bytes,omitempty"` +} + +type EventLog struct { + path string + mu sync.Mutex +} + +func NewEventLog(path string) *EventLog { + return &EventLog{path: path} +} + +func (l *EventLog) Append(e Event) error { + if e.Time.IsZero() { + e.Time = time.Now().UTC() + } + l.mu.Lock() + defer l.mu.Unlock() + f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + if err := json.NewEncoder(f).Encode(e); err != nil { + f.Close() + return err + } + size := int64(0) + if st, err := f.Stat(); err == nil { + size = st.Size() + } + f.Close() + if size > maxLogFileBytes { + return l.rotateLocked(keepEvents) + } + return nil +} + +func (l *EventLog) Recent(limit int) ([]Event, error) { + if limit <= 0 { + limit = defaultLimit + } + l.mu.Lock() + defer l.mu.Unlock() + return l.readLastLocked(limit, true) +} + +func (l *EventLog) readLastLocked(limit int, reverse bool) ([]Event, error) { + f, err := os.Open(l.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []Event{}, nil + } + return nil, err + } + defer f.Close() + ring := make([]Event, limit) + n := 0 + dec := json.NewDecoder(f) + for { + var e Event + if err := dec.Decode(&e); err != nil { + break + } + ring[n%limit] = e + n++ + } + count := n + if count > limit { + count = limit + } + out := make([]Event, count) + for i := 0; i < count; i++ { + var idx int + if reverse { + idx = ((n - 1 - i) % limit + limit) % limit + } else { + start := 0 + if n > limit { + start = n % limit + } + idx = (start + i) % limit + } + out[i] = ring[idx] + } + return out, nil +} + +func (l *EventLog) rotateLocked(keep int) error { + events, err := l.readLastLocked(keep, false) + if err != nil { + return err + } + tmp := l.path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + enc := json.NewEncoder(f) + for _, e := range events { + if err := enc.Encode(e); err != nil { + f.Close() + os.Remove(tmp) + return err + } + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, l.path) +} diff --git a/backend/stability/events_test.go b/backend/stability/events_test.go new file mode 100644 index 00000000..8e5c6305 --- /dev/null +++ b/backend/stability/events_test.go @@ -0,0 +1,76 @@ +package stability + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppendAndRecentReverseOrder(t *testing.T) { + dir := t.TempDir() + log := NewEventLog(filepath.Join(dir, "events.jsonl")) + require.NoError(t, log.Append(Event{Kind: EventKindZramEnabled, SizeBytes: 1 << 30})) + require.NoError(t, log.Append(Event{Kind: EventKindPressure, AvailRatio: 0.05})) + require.NoError(t, log.Append(Event{Kind: EventKindVictimSigterm, PID: 1234, Comm: "python3", RSSkb: 2000000})) + + evs, err := log.Recent(10) + require.NoError(t, err) + require.Len(t, evs, 3) + assert.Equal(t, EventKindVictimSigterm, evs[0].Kind) + assert.Equal(t, "python3", evs[0].Comm) + assert.Equal(t, EventKindPressure, evs[1].Kind) + assert.Equal(t, EventKindZramEnabled, evs[2].Kind) +} + +func TestRecentMissingFileReturnsEmpty(t *testing.T) { + dir := t.TempDir() + evs, err := NewEventLog(filepath.Join(dir, "nope.jsonl")).Recent(10) + require.NoError(t, err) + assert.Empty(t, evs) +} + +func TestRecentCapsLimit(t *testing.T) { + dir := t.TempDir() + log := NewEventLog(filepath.Join(dir, "events.jsonl")) + for i := 0; i < 20; i++ { + require.NoError(t, log.Append(Event{Kind: EventKindPressure, PID: i})) + } + evs, err := log.Recent(5) + require.NoError(t, err) + require.Len(t, evs, 5) + assert.Equal(t, 19, evs[0].PID) + assert.Equal(t, 15, evs[4].PID) +} + +func TestAppendRotatesWhenFileExceedsMax(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + log := NewEventLog(path) + for i := 0; i < 5000; i++ { + require.NoError(t, log.Append(Event{Kind: EventKindPressure, PID: i, Comm: "memhog"})) + } + st, err := os.Stat(path) + require.NoError(t, err) + assert.LessOrEqual(t, st.Size(), int64(maxLogFileBytes*2), "file should stay bounded by rotation") + + evs, err := log.Recent(10) + require.NoError(t, err) + require.Len(t, evs, 10) + assert.Equal(t, 4999, evs[0].PID, "newest event preserved across rotation") + assert.Equal(t, 4990, evs[9].PID) +} + +func TestRecentUsesBoundedMemory(t *testing.T) { + dir := t.TempDir() + log := NewEventLog(filepath.Join(dir, "events.jsonl")) + for i := 0; i < 2000; i++ { + require.NoError(t, log.Append(Event{Kind: EventKindPressure, PID: i})) + } + evs, err := log.Recent(5) + require.NoError(t, err) + require.Len(t, evs, 5) + assert.Equal(t, 1999, evs[0].PID) +} diff --git a/backend/stability/oom.go b/backend/stability/oom.go index ff5cf8d9..be38079c 100644 --- a/backend/stability/oom.go +++ b/backend/stability/oom.go @@ -17,6 +17,7 @@ type Watcher struct { scan *ProcScanner protect Protect kill KillFn + events *EventLog log *zap.Logger interval time.Duration availMin float64 @@ -25,12 +26,13 @@ type Watcher struct { selfPID int } -func NewWatcher(mem *MemInfo, scan *ProcScanner, kill KillFn, log *zap.Logger) *Watcher { +func NewWatcher(mem *MemInfo, scan *ProcScanner, kill KillFn, events *EventLog, log *zap.Logger) *Watcher { return &Watcher{ mem: mem, scan: scan, protect: DefaultProtect(), kill: kill, + events: events, log: log, interval: 2 * time.Second, availMin: 0.08, @@ -82,6 +84,9 @@ func (w *Watcher) tick() error { zap.Float64("psi_avg10", psi), zap.Bool("psi_ok", psiOK), ) + if w.events != nil { + _ = w.events.Append(Event{Kind: EventKindPressure, AvailRatio: avail, PSIavg10: psi}) + } return w.killWorst() } @@ -110,6 +115,9 @@ func (w *Watcher) killWorst() error { zap.Uint64("rss_kb", v.RSSkB), zap.String("cgroup", v.Cgroup), ) + if w.events != nil { + _ = w.events.Append(Event{Kind: EventKindVictimSigterm, PID: v.PID, Comm: v.Comm, RSSkb: v.RSSkB, Cgroup: v.Cgroup}) + } if err := w.kill(v.PID, syscall.SIGTERM); err != nil { if errors.Is(err, syscall.ESRCH) { return nil @@ -124,6 +132,9 @@ func (w *Watcher) killWorst() error { time.Sleep(200 * time.Millisecond) } w.log.Warn("oom-watcher: SIGKILL victim", zap.Int("pid", v.PID), zap.String("comm", v.Comm)) + if w.events != nil { + _ = w.events.Append(Event{Kind: EventKindVictimSigkill, PID: v.PID, Comm: v.Comm, RSSkb: v.RSSkB, Cgroup: v.Cgroup}) + } if err := w.kill(v.PID, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { return err } diff --git a/backend/stability/oom_test.go b/backend/stability/oom_test.go index b9f73c2b..012226b7 100644 --- a/backend/stability/oom_test.go +++ b/backend/stability/oom_test.go @@ -35,7 +35,7 @@ func newWatcherWithProc(t *testing.T, memTotal, memAvail uint64, procDir string) root := t.TempDir() procRoot := root writeProcFile(t, procRoot, "meminfo", "MemTotal: "+strconvUint(memTotal)+" kB\nMemAvailable: "+strconvUint(memAvail)+" kB\n") - return NewWatcher(NewMemInfo(procRoot), NewProcScanner(procDir), nil, zap.NewNop()) + return NewWatcher(NewMemInfo(procRoot), NewProcScanner(procDir), nil, nil, zap.NewNop()) } func TestTickNoActionWhenHealthy(t *testing.T) { @@ -85,7 +85,7 @@ func TestKillWorstReturnsErrNoVictim(t *testing.T) { } func TestPressureExceededByAvailOrPSI(t *testing.T) { - w := NewWatcher(NewMemInfo(t.TempDir()), nil, nil, zap.NewNop()) + w := NewWatcher(NewMemInfo(t.TempDir()), nil, nil, nil, zap.NewNop()) assert.True(t, w.pressureExceeded(0.05, 0, false)) assert.False(t, w.pressureExceeded(0.30, 0, false)) assert.True(t, w.pressureExceeded(0.30, 50, true)) diff --git a/backend/stability/zram.go b/backend/stability/zram.go index 26281630..b57097a8 100644 --- a/backend/stability/zram.go +++ b/backend/stability/zram.go @@ -33,10 +33,11 @@ type Zram struct { mem *MemInfo swapon SwaponFn swapoff SwapoffFn + events *EventLog log *zap.Logger } -func NewZram(mem *MemInfo, swapon SwaponFn, swapoff SwapoffFn, log *zap.Logger) *Zram { +func NewZram(mem *MemInfo, swapon SwaponFn, swapoff SwapoffFn, events *EventLog, log *zap.Logger) *Zram { return &Zram{ sysBlock: zramSysBlockDefault, hotAdd: zramHotAddDefault, @@ -45,6 +46,7 @@ func NewZram(mem *MemInfo, swapon SwaponFn, swapoff SwapoffFn, log *zap.Logger) mem: mem, swapon: swapon, swapoff: swapoff, + events: events, log: log, } } @@ -83,6 +85,9 @@ func (z *Zram) EnsureConfigured() error { return fmt.Errorf("zram: swapon: %w", err) } z.log.Info("zram: enabled", zap.Uint64("size_bytes", size), zap.Int("priority", zramPriority)) + if z.events != nil { + _ = z.events.Append(Event{Kind: EventKindZramEnabled, SizeBytes: size}) + } if err := z.disableFileSwaps(); err != nil { z.log.Warn("zram: file-swap disable failed", zap.Error(err)) } @@ -107,6 +112,9 @@ func (z *Zram) disableFileSwaps() error { continue } z.log.Info("zram: swapoff file swap", zap.String("path", fields[0])) + if z.events != nil { + _ = z.events.Append(Event{Kind: EventKindSwapoffFile, Path: fields[0]}) + } } return nil } diff --git a/web/platform/src/locales/ar.json b/web/platform/src/locales/ar.json index 4970776a..ca744164 100644 --- a/web/platform/src/locales/ar.json +++ b/web/platform/src/locales/ar.json @@ -46,7 +46,8 @@ "logs": "السجلات", "customProxy": "وكيل مخصص", "system": "النظام", - "locale": "الإعدادات الإقليمية" + "locale": "الإعدادات الإقليمية", + "health": "الصحة" }, "activation": { "title": "التفعيل", @@ -271,5 +272,30 @@ "timezone": "المنطقة الزمنية:", "currentTime": "الوقت الحالي:", "save": "حفظ" + }, + "health": { + "title": "الصحة", + "cpu": "المعالج", + "memory": "الذاكرة", + "swap": "التبديل", + "disks": "الأقراص", + "network": "الشبكة", + "events": "أحداث الاستقرار", + "noEvents": "لم تُسجَّل أي أحداث بعد.", + "colTime": "الوقت", + "colKind": "النوع", + "colDetails": "التفاصيل", + "used": "مستخدم", + "total": "الإجمالي", + "available": "متاح", + "ioRead": "قراءة", + "ioWrite": "كتابة", + "netRx": "استقبال", + "netTx": "إرسال", + "kindZramEnabled": "تم تمكين zram", + "kindSwapoffFile": "تم تعطيل ملف التبديل", + "kindPressure": "تم اكتشاف ضغط على الذاكرة", + "kindVictimSigterm": "تم إنهاء العملية (SIGTERM)", + "kindVictimSigkill": "تم قتل العملية (SIGKILL)" } } diff --git a/web/platform/src/locales/de.json b/web/platform/src/locales/de.json index ced7b513..a84585a5 100644 --- a/web/platform/src/locales/de.json +++ b/web/platform/src/locales/de.json @@ -46,7 +46,8 @@ "logs": "Protokolle", "customProxy": "Benutzerdefinierter Proxy", "system": "System", - "locale": "Regional" + "locale": "Regional", + "health": "Gesundheit" }, "activation": { "title": "Aktivierung", @@ -271,5 +272,30 @@ "timezone": "Zeitzone:", "currentTime": "Aktuelle Zeit:", "save": "Speichern" + }, + "health": { + "title": "Gesundheit", + "cpu": "CPU", + "memory": "Speicher", + "swap": "Auslagerung", + "disks": "Datenträger", + "network": "Netzwerk", + "events": "Stabilitätsereignisse", + "noEvents": "Keine Ereignisse aufgezeichnet.", + "colTime": "Zeit", + "colKind": "Art", + "colDetails": "Details", + "used": "belegt", + "total": "gesamt", + "available": "verfügbar", + "ioRead": "lesen", + "ioWrite": "schreiben", + "netRx": "Empfang", + "netTx": "Senden", + "kindZramEnabled": "zram aktiviert", + "kindSwapoffFile": "Auslagerungsdatei deaktiviert", + "kindPressure": "Speicherengpass erkannt", + "kindVictimSigterm": "Prozess beendet (SIGTERM)", + "kindVictimSigkill": "Prozess gekillt (SIGKILL)" } } diff --git a/web/platform/src/locales/en.json b/web/platform/src/locales/en.json index 3b23d4db..dc6f313f 100644 --- a/web/platform/src/locales/en.json +++ b/web/platform/src/locales/en.json @@ -53,7 +53,33 @@ "logs": "Logs", "customProxy": "Custom Proxy", "system": "System", - "locale": "Locale" + "locale": "Locale", + "health": "Health" + }, + "health": { + "title": "Health", + "cpu": "CPU", + "memory": "Memory", + "swap": "Swap", + "disks": "Disks", + "network": "Network", + "events": "Stability events", + "noEvents": "No events recorded yet.", + "colTime": "Time", + "colKind": "Kind", + "colDetails": "Details", + "used": "used", + "total": "total", + "available": "available", + "ioRead": "read", + "ioWrite": "write", + "netRx": "down", + "netTx": "up", + "kindZramEnabled": "zram enabled", + "kindSwapoffFile": "file swap disabled", + "kindPressure": "memory pressure detected", + "kindVictimSigterm": "process terminated (SIGTERM)", + "kindVictimSigkill": "process killed (SIGKILL)" }, "activation": { "title": "Activation", diff --git a/web/platform/src/locales/es.json b/web/platform/src/locales/es.json index eed6cffc..975889c2 100644 --- a/web/platform/src/locales/es.json +++ b/web/platform/src/locales/es.json @@ -46,7 +46,8 @@ "logs": "Registros", "customProxy": "Proxy personalizado", "system": "Sistema", - "locale": "Regional" + "locale": "Regional", + "health": "Salud" }, "activation": { "title": "Activación", @@ -271,5 +272,30 @@ "timezone": "Zona horaria:", "currentTime": "Hora actual:", "save": "Guardar" + }, + "health": { + "title": "Salud", + "cpu": "CPU", + "memory": "Memoria", + "swap": "Intercambio", + "disks": "Discos", + "network": "Red", + "events": "Eventos de estabilidad", + "noEvents": "Aún no se han registrado eventos.", + "colTime": "Hora", + "colKind": "Tipo", + "colDetails": "Detalles", + "used": "usado", + "total": "total", + "available": "disponible", + "ioRead": "lectura", + "ioWrite": "escritura", + "netRx": "recibido", + "netTx": "enviado", + "kindZramEnabled": "zram activado", + "kindSwapoffFile": "archivo de intercambio desactivado", + "kindPressure": "presión de memoria detectada", + "kindVictimSigterm": "proceso terminado (SIGTERM)", + "kindVictimSigkill": "proceso matado (SIGKILL)" } } diff --git a/web/platform/src/locales/fr.json b/web/platform/src/locales/fr.json index fbd50f56..10dd214d 100644 --- a/web/platform/src/locales/fr.json +++ b/web/platform/src/locales/fr.json @@ -46,7 +46,8 @@ "logs": "Journaux", "customProxy": "Proxy personnalisé", "system": "Système", - "locale": "Régional" + "locale": "Régional", + "health": "Santé" }, "activation": { "title": "Activation", @@ -271,5 +272,30 @@ "timezone": "Fuseau horaire:", "currentTime": "Heure actuelle:", "save": "Enregistrer" + }, + "health": { + "title": "Santé", + "cpu": "CPU", + "memory": "Mémoire", + "swap": "Swap", + "disks": "Disques", + "network": "Réseau", + "events": "Événements de stabilité", + "noEvents": "Aucun événement enregistré pour le moment.", + "colTime": "Heure", + "colKind": "Type", + "colDetails": "Détails", + "used": "utilisé", + "total": "total", + "available": "disponible", + "ioRead": "lecture", + "ioWrite": "écriture", + "netRx": "reçu", + "netTx": "envoyé", + "kindZramEnabled": "zram activé", + "kindSwapoffFile": "fichier swap désactivé", + "kindPressure": "pression mémoire détectée", + "kindVictimSigterm": "processus terminé (SIGTERM)", + "kindVictimSigkill": "processus tué (SIGKILL)" } } diff --git a/web/platform/src/locales/hi.json b/web/platform/src/locales/hi.json index 79cec7b8..082951b0 100644 --- a/web/platform/src/locales/hi.json +++ b/web/platform/src/locales/hi.json @@ -46,7 +46,8 @@ "logs": "लॉग", "customProxy": "कस्टम प्रॉक्सी", "system": "सिस्टम", - "locale": "क्षेत्रीय" + "locale": "क्षेत्रीय", + "health": "स्वास्थ्य" }, "activation": { "title": "सक्रियण", @@ -271,5 +272,30 @@ "timezone": "समय क्षेत्र:", "currentTime": "वर्तमान समय:", "save": "सहेजें" + }, + "health": { + "title": "स्वास्थ्य", + "cpu": "CPU", + "memory": "मेमोरी", + "swap": "स्वैप", + "disks": "डिस्क", + "network": "नेटवर्क", + "events": "स्थिरता घटनाएँ", + "noEvents": "अभी तक कोई घटना दर्ज नहीं हुई है।", + "colTime": "समय", + "colKind": "प्रकार", + "colDetails": "विवरण", + "used": "उपयोग में", + "total": "कुल", + "available": "उपलब्ध", + "ioRead": "पढ़ना", + "ioWrite": "लिखना", + "netRx": "डाउनलोड", + "netTx": "अपलोड", + "kindZramEnabled": "zram सक्षम", + "kindSwapoffFile": "फ़ाइल स्वैप अक्षम", + "kindPressure": "मेमोरी दबाव का पता चला", + "kindVictimSigterm": "प्रक्रिया समाप्त (SIGTERM)", + "kindVictimSigkill": "प्रक्रिया मार दी गई (SIGKILL)" } } diff --git a/web/platform/src/locales/ja.json b/web/platform/src/locales/ja.json index c907970e..f8b6cd81 100644 --- a/web/platform/src/locales/ja.json +++ b/web/platform/src/locales/ja.json @@ -46,7 +46,8 @@ "logs": "ログ", "customProxy": "カスタムプロキシ", "system": "システム", - "locale": "ロケール" + "locale": "ロケール", + "health": "ヘルス" }, "activation": { "title": "アクティベーション", @@ -271,5 +272,30 @@ "timezone": "タイムゾーン:", "currentTime": "現在時刻:", "save": "保存" + }, + "health": { + "title": "ヘルス", + "cpu": "CPU", + "memory": "メモリー", + "swap": "スワップ", + "disks": "ディスク", + "network": "ネットワーク", + "events": "安定性イベント", + "noEvents": "まだイベントは記録されていません。", + "colTime": "時刻", + "colKind": "種類", + "colDetails": "詳細", + "used": "使用中", + "total": "合計", + "available": "利用可能", + "ioRead": "読み込み", + "ioWrite": "書き込み", + "netRx": "受信", + "netTx": "送信", + "kindZramEnabled": "zram を有効化", + "kindSwapoffFile": "ファイルスワップを無効化", + "kindPressure": "メモリ圧迫を検出", + "kindVictimSigterm": "プロセス終了 (SIGTERM)", + "kindVictimSigkill": "プロセス強制終了 (SIGKILL)" } } diff --git a/web/platform/src/locales/pt.json b/web/platform/src/locales/pt.json index fdd33ca0..8f94367a 100644 --- a/web/platform/src/locales/pt.json +++ b/web/platform/src/locales/pt.json @@ -46,7 +46,8 @@ "logs": "Registros", "customProxy": "Proxy personalizado", "system": "Sistema", - "locale": "Regional" + "locale": "Regional", + "health": "Saúde" }, "activation": { "title": "Ativação", @@ -271,5 +272,30 @@ "timezone": "Fuso horário:", "currentTime": "Hora atual:", "save": "Salvar" + }, + "health": { + "title": "Saúde", + "cpu": "CPU", + "memory": "Memória", + "swap": "Swap", + "disks": "Discos", + "network": "Rede", + "events": "Eventos de estabilidade", + "noEvents": "Nenhum evento registrado ainda.", + "colTime": "Hora", + "colKind": "Tipo", + "colDetails": "Detalhes", + "used": "usado", + "total": "total", + "available": "disponível", + "ioRead": "leitura", + "ioWrite": "escrita", + "netRx": "recebido", + "netTx": "enviado", + "kindZramEnabled": "zram ativado", + "kindSwapoffFile": "arquivo de swap desativado", + "kindPressure": "pressão de memória detectada", + "kindVictimSigterm": "processo encerrado (SIGTERM)", + "kindVictimSigkill": "processo morto (SIGKILL)" } } diff --git a/web/platform/src/locales/ru.json b/web/platform/src/locales/ru.json index b2ce3a42..ac4a5742 100644 --- a/web/platform/src/locales/ru.json +++ b/web/platform/src/locales/ru.json @@ -46,7 +46,8 @@ "logs": "Журналы", "customProxy": "Свой прокси", "system": "Система", - "locale": "Регион" + "locale": "Регион", + "health": "Здоровье" }, "activation": { "title": "Активация", @@ -271,5 +272,30 @@ "timezone": "Часовой пояс:", "currentTime": "Текущее время:", "save": "Сохранить" + }, + "health": { + "title": "Здоровье", + "cpu": "ЦП", + "memory": "Память", + "swap": "Подкачка", + "disks": "Диски", + "network": "Сеть", + "events": "События стабильности", + "noEvents": "События ещё не зарегистрированы.", + "colTime": "Время", + "colKind": "Тип", + "colDetails": "Детали", + "used": "использовано", + "total": "всего", + "available": "доступно", + "ioRead": "чтение", + "ioWrite": "запись", + "netRx": "приём", + "netTx": "передача", + "kindZramEnabled": "zram включён", + "kindSwapoffFile": "файл подкачки отключён", + "kindPressure": "обнаружена нехватка памяти", + "kindVictimSigterm": "процесс завершён (SIGTERM)", + "kindVictimSigkill": "процесс убит (SIGKILL)" } } diff --git a/web/platform/src/locales/zh-CN.json b/web/platform/src/locales/zh-CN.json index fcc6a3f6..16072d35 100644 --- a/web/platform/src/locales/zh-CN.json +++ b/web/platform/src/locales/zh-CN.json @@ -46,7 +46,8 @@ "logs": "日志", "customProxy": "自定义代理", "system": "系统", - "locale": "区域设置" + "locale": "区域设置", + "health": "健康" }, "activation": { "title": "激活", @@ -271,5 +272,30 @@ "timezone": "时区:", "currentTime": "当前时间:", "save": "保存" + }, + "health": { + "title": "健康", + "cpu": "CPU", + "memory": "内存", + "swap": "交换", + "disks": "磁盘", + "network": "网络", + "events": "稳定性事件", + "noEvents": "尚未记录事件。", + "colTime": "时间", + "colKind": "类型", + "colDetails": "详情", + "used": "已用", + "total": "总计", + "available": "可用", + "ioRead": "读取", + "ioWrite": "写入", + "netRx": "下载", + "netTx": "上传", + "kindZramEnabled": "已启用 zram", + "kindSwapoffFile": "已禁用文件交换", + "kindPressure": "检测到内存压力", + "kindVictimSigterm": "进程已终止 (SIGTERM)", + "kindVictimSigkill": "进程被强制结束 (SIGKILL)" } } diff --git a/web/platform/src/router/index.js b/web/platform/src/router/index.js index cc6a245e..da7f2cc5 100644 --- a/web/platform/src/router/index.js +++ b/web/platform/src/router/index.js @@ -22,6 +22,7 @@ const routes = [ { path: '/customproxy', name: 'CustomProxy', component: () => import('../views/CustomProxy.vue') }, { path: '/system', name: 'System', component: () => import('../views/System.vue') }, { path: '/locale', name: 'Locale', component: () => import('../views/Locale.vue') }, + { path: '/health', name: 'Health', component: () => import('../views/Health.vue') }, { path: '/:catchAll(.*)', redirect: '/' } ] diff --git a/web/platform/src/stub/api.js b/web/platform/src/stub/api.js index 32ee35e4..bb5cce85 100644 --- a/web/platform/src/stub/api.js +++ b/web/platform/src/stub/api.js @@ -631,6 +631,68 @@ export function mock () { ] return new Response(200, {}, { success: true, data: logs }) }) + const stubHealth = { + tick: 0, + cpu: { user: 1000000, nice: 100, system: 200000, idle: 5000000, iowait: 30000, irq: 0, softirq: 10000, steal: 0 }, + net: [ + { name: 'eth0', rx_bytes: 50000000, tx_bytes: 20000000 }, + { name: 'wlan0', rx_bytes: 1000000, tx_bytes: 500000 } + ], + disks: [ + { name: 'sda', reads_total: 100000, sectors_read: 800000000, writes_total: 50000, sectors_written: 200000000 }, + { name: 'mmcblk0', reads_total: 500000, sectors_read: 4000000000, writes_total: 200000, sectors_written: 1500000000 } + ] + } + this.get('/rest/settings/health/metrics', function (_schema, _request) { + stubHealth.tick++ + // simulate variable CPU activity (sine-ish pattern) + const busyMs = Math.round(800 + 600 * Math.sin(stubHealth.tick / 8)) + stubHealth.cpu.user += busyMs * 0.6 + stubHealth.cpu.system += busyMs * 0.3 + stubHealth.cpu.iowait += busyMs * 0.1 + stubHealth.cpu.idle += 2000 - busyMs + stubHealth.net.forEach((n, i) => { + n.rx_bytes += Math.round(50000 + 200000 * Math.random()) * (i === 0 ? 1 : 0.05) + n.tx_bytes += Math.round(20000 + 100000 * Math.random()) * (i === 0 ? 1 : 0.05) + }) + stubHealth.disks.forEach(d => { + d.sectors_read += Math.round(500 + 8000 * Math.random()) + d.sectors_written += Math.round(200 + 4000 * Math.random()) + }) + const memUsed = 1500000 + Math.round(800000 * Math.sin(stubHealth.tick / 12)) + const data = { + cpu: { ...stubHealth.cpu }, + memory: { + total_kb: 3789348, + available_kb: 3789348 - memUsed, + free_kb: 200000, + buffers_kb: 50000, + cached_kb: 900000, + swap_total_kb: 2097148, + swap_free_kb: 2097148 - 600000 - Math.round(300000 * Math.sin(stubHealth.tick / 7)) + }, + disks: stubHealth.disks.map(d => ({ ...d })), + mounts: [ + { path: '/', total_kb: 30 * 1024 * 1024, used_kb: 24 * 1024 * 1024 }, + { path: '/opt/disk/external', total_kb: 1.9 * 1024 * 1024 * 1024, used_kb: 0.235 * 1024 * 1024 * 1024 } + ], + net: stubHealth.net.map(n => ({ ...n })) + } + return new Response(200, {}, { success: true, data }) + }) + this.get('/rest/settings/health/events', function (_schema, _request) { + const now = Date.now() + const events = [ + { time: new Date(now - 30 * 1000).toISOString(), kind: 'victim_sigterm', pid: 3325956, comm: 'python3', rss_kb: 1943228, cgroup: '0::/user.slice/user-0.slice/session-61617.scope' }, + { time: new Date(now - 30 * 1000 - 1500).toISOString(), kind: 'pressure_detected', avail_ratio: 0.058 }, + { time: new Date(now - 17 * 60 * 1000).toISOString(), kind: 'victim_sigkill', pid: 4421, comm: 'photoprism', rss_kb: 1102456, cgroup: '0::/system.slice/snap.photoprism.web.service' }, + { time: new Date(now - 17 * 60 * 1000 - 4500).toISOString(), kind: 'victim_sigterm', pid: 4421, comm: 'photoprism', rss_kb: 1102456, cgroup: '0::/system.slice/snap.photoprism.web.service' }, + { time: new Date(now - 17 * 60 * 1000 - 6500).toISOString(), kind: 'pressure_detected', avail_ratio: 0.041, psi_avg10: 62.4 }, + { time: new Date(now - 3 * 3600 * 1000).toISOString(), kind: 'swapoff_file', path: '/swapfile' }, + { time: new Date(now - 3 * 3600 * 1000 - 200).toISOString(), kind: 'zram_enabled', size_bytes: 1939916800 } + ] + return new Response(200, {}, { success: true, data: events }) + }) } }) } diff --git a/web/platform/src/views/Health.vue b/web/platform/src/views/Health.vue new file mode 100644 index 00000000..7ac2f98c --- /dev/null +++ b/web/platform/src/views/Health.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/web/platform/src/views/Settings.vue b/web/platform/src/views/Settings.vue index d39a88c2..59353775 100644 --- a/web/platform/src/views/Settings.vue +++ b/web/platform/src/views/Settings.vue @@ -103,6 +103,13 @@ +
+ + favorite +
{{ $t('settings.health') }}
+
+
+