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 @@
+
+
+
+
+
{{ $t('health.title') }}
+
+
+
+
+
{{ $t('health.cpu') }} — {{ cpuPct.toFixed(0) }}%
+
+
+
+
+
{{ $t('health.memory') }} — {{ memUsedMb }} / {{ memTotalMb }} MB
+
+
{{ memAvailMb }} MB {{ $t('health.available') }}
+
+
+
+
{{ $t('health.swap') }} — {{ swapUsedMb }} / {{ swapTotalMb }} MB
+
+
+
+
+
+
+
{{ $t('health.disks') }}
+
+
+ {{ m.path }}
+ {{ mb(m.used_kb) }} / {{ mb(m.total_kb) }} MB
+
+
+
+
+
+ {{ d.name }}
+ ↓ {{ d.readKBs }} · ↑ {{ d.writeKBs }} KB/s
+
+
+
+
+
+
{{ $t('health.network') }}
+
+
+ {{ n.name }}
+ ↓ {{ n.rxKBs }} · ↑ {{ n.txKBs }} KB/s
+
+
+
+
+
+
+
+
{{ $t('health.events') }}
+
{{ $t('health.noEvents') }}
+
+ -
+ {{ kindIcon(ev.kind) }}
+
+
+ {{ $t('health.kind' + kindCamel(ev.kind)) }}
+
+
+
{{ fmtDetails(ev) }}
+
+
+
+
+
+
+
+
+
+
+
+
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') }}
+
+
+