From 39496374e9f86f8545fd7ba3f74ea1ba18c16dfc Mon Sep 17 00:00:00 2001 From: n/a Date: Wed, 19 Aug 2026 23:58:12 +0200 Subject: [PATCH 1/7] list sessions --- .vscode/settings.json | 5 +++- Makefile | 6 ++++ go.mod | 2 +- go.sum | 2 ++ internal/grpc/acquire.go | 59 ++++++++++++++++++++++++++++++++++++++++ internal/grpc/ssh.go | 4 +++ 6 files changed, 76 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a162b867..4a87807d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "editor.aiStats.enabled": true, "go.lintTool": "golangci-lint", - "go.lintOnSave": "package" + "go.lintOnSave": "package", + "git.scanRepositories": [ + "common" + ] } \ No newline at end of file diff --git a/Makefile b/Makefile index df72ae1c..d0ba914d 100644 --- a/Makefile +++ b/Makefile @@ -103,6 +103,12 @@ image: vendor coverage: ##@ Calculate test coverage percentage from coverage.out @go tool cover -func=$(REPORTS_DIR)/coverage.out | grep total | awk '{print $$3}' +debug-setup: ##@ Set up local debug environment + ##@ Generates go.work (Go version taken from go.mod) and symlinks the common module for local debugging + @GO_VERSION=$$(grep -m1 '^go ' go.mod | awk '{print $$2}') && \ + printf 'go %s\n\nuse (\n\t.\n\t/opt/shared/common\n)\n' "$$GO_VERSION" > go.work + ln -sfn /opt/shared/common common + ##@ ##@ Misc commands ##@ diff --git a/go.mod b/go.mod index 2594d923..512e36f6 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/common v0.40.0 + github.com/k8shell-io/common v0.43.0 github.com/k8shell-io/k8shell-go v0.2.3 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 diff --git a/go.sum b/go.sum index 6c8664a4..0a11ba10 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/k8shell-io/common v0.39.0 h1:hfrKZYX2lBonornGrfK35rSY/+5o2sK+SakgUPKD github.com/k8shell-io/common v0.39.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/common v0.40.0 h1:MhQPVI5oe+JSdRJhWrtvCTJf0MaJDjM58KR7Nq3+lsM= github.com/k8shell-io/common v0.40.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= +github.com/k8shell-io/common v0.43.0 h1:OPpDgIANoXsM6jHC51Nr0vw5WHLP/do3h/SUXuo6leg= +github.com/k8shell-io/common v0.43.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/k8shell-go v0.2.1 h1:6n88ijXkzP39//lIy4ai3XqtpSUXzoa/dVaWogHQYf4= github.com/k8shell-io/k8shell-go v0.2.1/go.mod h1:j1JHgUIKIbaiRaitx6Pzw37ahqS4Hu9OcM4uvJ7BP4g= github.com/k8shell-io/k8shell-go v0.2.2 h1:rwLOeIfyq1+l2Jyv0ak/lXZS7x6xbA5ye0yMsRkTonw= diff --git a/internal/grpc/acquire.go b/internal/grpc/acquire.go index d6a9889e..ddfc1f59 100644 --- a/internal/grpc/acquire.go +++ b/internal/grpc/acquire.go @@ -105,6 +105,65 @@ func (s *ShellHandler) AcquireSession(ctx context.Context, req *k8shelldv1.Acqui }, nil } +// ListSessions implements SshServiceServer.ListSessions. +// It returns the set of live PTY sessions that AcquireSession would currently +// accept: no client attached and no unexpired lock held, along with the OS +// user each session runs as. +func (s *ShellHandler) ListSessions(_ context.Context, _ *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) { + if !s.grpcApi.allowSessionDetach { + return nil, status.Errorf(codes.PermissionDenied, "session attachment is not enabled on this server") + } + + now := time.Now() + locked := make(map[string]bool) + s.grpcApi.SessionLockStore.Range(func(_, v any) bool { + lk := v.(*sessionLock) + if now.Before(lk.expiresAt) { + locked[lk.sessionId] = true + } + return true + }) + + resp := &k8shelldv1.ListSessionsResponse{} + s.grpcApi.SessionStore.Range(func(_, value any) bool { + session, ok := value.(*SessionData) + if !ok || session.ptyDone == nil || !session.Deleted.IsZero() { + return true + } + select { + case <-session.ptyDone: + return true + default: + } + + session.mu.Lock() + attached := session.attachedSender != nil + detachedAt := session.DetachedAt + session.mu.Unlock() + + if attached || locked[session.Id] { + return true + } + + var detachedAtStr string + if !detachedAt.IsZero() { + detachedAtStr = detachedAt.Format(timeFormat) + } + + resp.Sessions = append(resp.Sessions, &k8shelldv1.AcquirableSession{ + SessionId: session.Id, + Owner: session.user.Username, + CmdShell: session.CmdShell, + Pid: int32(session.Pid), + Created: session.Created.Format(timeFormat), + DetachedAt: detachedAtStr, + }) + return true + }) + + return resp, nil +} + // cleanupExpiredLocks removes session locks that have passed their TTL. func (a *GRPCService) cleanupExpiredLocks() { now := time.Now() diff --git a/internal/grpc/ssh.go b/internal/grpc/ssh.go index ef693024..57b7875c 100644 --- a/internal/grpc/ssh.go +++ b/internal/grpc/ssh.go @@ -49,6 +49,10 @@ func (s *SshServiceServer) AcquireSession(ctx context.Context, req *k8shelldv1.A return s.shell.AcquireSession(ctx, req) } +func (s *SshServiceServer) ListSessions(ctx context.Context, req *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) { + return s.shell.ListSessions(ctx, req) +} + func (s *SshServiceServer) Exec(stream grpc.BidiStreamingServer[k8shelldv1.ExecRequest, k8shelldv1.ExecResponse]) error { return s.exec.Exec(stream) } From 8f0ff5b475297c7706c629f73b26f3a0d34d439a Mon Sep 17 00:00:00 2001 From: n/a Date: Thu, 20 Aug 2026 10:26:29 +0200 Subject: [PATCH 2/7] log stream rpc --- go.mod | 4 +++- internal/grpc/system.go | 48 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 512e36f6..1d628bce 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/common v0.43.0 + github.com/k8shell-io/common v0.44.0 github.com/k8shell-io/k8shell-go v0.2.3 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 @@ -44,3 +44,5 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) + +replace github.com/k8shell-io/common => /opt/shared/common diff --git a/internal/grpc/system.go b/internal/grpc/system.go index a8391ec5..af8d532f 100644 --- a/internal/grpc/system.go +++ b/internal/grpc/system.go @@ -19,6 +19,10 @@ import ( "google.golang.org/grpc/status" ) +// logStreamPollInterval is how often GetLogsStream polls the in-memory log +// store for new entries while following (mirrors the REST /logs handler). +const logStreamPollInterval = 100 * time.Millisecond + // SystemServiceServer is the gRPC server for the system service type SystemServiceServer struct { grpcApi *GRPCService @@ -124,3 +128,47 @@ func (s *SystemServiceServer) SystemInfo(ctx context.Context, return k8shelld.SystemInfoToProto(&systemInfo), nil } + +// GetLogsStream streams k8shelld daemon logs (the same logs shown by +// `kbox logs`). With Follow=false it sends the currently buffered entries +// and closes the stream; with Follow=true it keeps streaming new entries as +// they are produced until the client cancels. +func (s *SystemServiceServer) GetLogsStream(req *k8shelldv1.SystemLogsStreamRequest, + stream k8shelldv1.SystemService_GetLogsStreamServer) error { + + component := req.GetComponent() + level := k8shelld.LogLevelFromProto(req.GetLevel()) + follow := req.GetFollow() + + offset := 0 + if n := req.GetLastN(); n > 0 { + offset = -int(n) + } + + ctx := stream.Context() + for { + select { + case <-ctx.Done(): + return status.Errorf(codes.Canceled, "client canceled") + default: + entries, newOffset := logger.GetLogsSince(offset, component, level) + for _, entry := range entries { + if sendErr := stream.Send(&k8shelldv1.SystemLogsStreamResponse{ + Time: entry.Timestamp, + Component: entry.Component, + Level: entry.Level, + Message: entry.Message, + }); sendErr != nil { + return status.Errorf(codes.Canceled, "client canceled") + } + } + offset = newOffset + + if !follow { + return nil + } + + time.Sleep(logStreamPollInterval) + } + } +} From 15bd87079cf6f1d10ec0ac6e6eed5bcd94638db3 Mon Sep 17 00:00:00 2001 From: n/a Date: Thu, 20 Aug 2026 11:07:38 +0200 Subject: [PATCH 3/7] mod fix --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index 1d628bce..44637be0 100644 --- a/go.mod +++ b/go.mod @@ -44,5 +44,3 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) - -replace github.com/k8shell-io/common => /opt/shared/common From cb41df6f5086f66a0591e4fe45d2251cf08dce51 Mon Sep 17 00:00:00 2001 From: n/a Date: Thu, 20 Aug 2026 11:09:46 +0200 Subject: [PATCH 4/7] mod fix --- go.sum | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/go.sum b/go.sum index 0a11ba10..5548cc3a 100644 --- a/go.sum +++ b/go.sum @@ -42,20 +42,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/k8shell-io/common v0.36.0 h1:fkMH1XfYRLzDxqhIq5/luHusWWPGnCGJUXSTEIhEDzI= -github.com/k8shell-io/common v0.36.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.37.0 h1:whq66WosIJECKErUKZF1RQep7tdpOfI6GtP4hXREpsQ= -github.com/k8shell-io/common v0.37.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.39.0 h1:hfrKZYX2lBonornGrfK35rSY/+5o2sK+SakgUPKDL24= -github.com/k8shell-io/common v0.39.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.40.0 h1:MhQPVI5oe+JSdRJhWrtvCTJf0MaJDjM58KR7Nq3+lsM= -github.com/k8shell-io/common v0.40.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.43.0 h1:OPpDgIANoXsM6jHC51Nr0vw5WHLP/do3h/SUXuo6leg= -github.com/k8shell-io/common v0.43.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/k8shell-go v0.2.1 h1:6n88ijXkzP39//lIy4ai3XqtpSUXzoa/dVaWogHQYf4= -github.com/k8shell-io/k8shell-go v0.2.1/go.mod h1:j1JHgUIKIbaiRaitx6Pzw37ahqS4Hu9OcM4uvJ7BP4g= -github.com/k8shell-io/k8shell-go v0.2.2 h1:rwLOeIfyq1+l2Jyv0ak/lXZS7x6xbA5ye0yMsRkTonw= -github.com/k8shell-io/k8shell-go v0.2.2/go.mod h1:ZShnaWs7zxUlNwAkIn4lJodFqaB+PB8O+gn2EIscxq8= +github.com/k8shell-io/common v0.44.0 h1:cry62gDIXW8oyRWCzc2M+Hx62mpnarGdKZRTZ9TV8J8= +github.com/k8shell-io/common v0.44.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/k8shell-go v0.2.3 h1:gL7dXDYN4EhWdQvvnY4B2On9Tpb1sZ7G5lO8RtI/nr4= github.com/k8shell-io/k8shell-go v0.2.3/go.mod h1:wWb5gq693qqb48/p5iYrosLG4uNeGOr5dQJiOClIbE8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= From bb673867de70fb2f7d0f8172050900a705f36e14 Mon Sep 17 00:00:00 2001 From: n/a Date: Sat, 22 Aug 2026 10:41:56 +0200 Subject: [PATCH 5/7] log stream --- go.mod | 2 +- go.sum | 2 + internal/grpc/system.go | 92 +++++++++++++++---- internal/logger/logger.go | 82 +++++++++++++---- internal/logger/logger_test.go | 156 ++++++++++++++++++++++++++------- internal/server/restapi.go | 124 +++++++++++++++++++------- 6 files changed, 358 insertions(+), 100 deletions(-) diff --git a/go.mod b/go.mod index 44637be0..7e9a03d1 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/common v0.44.0 + github.com/k8shell-io/common v0.45.0 github.com/k8shell-io/k8shell-go v0.2.3 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 diff --git a/go.sum b/go.sum index 5548cc3a..1a4bd87c 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/k8shell-io/common v0.44.0 h1:cry62gDIXW8oyRWCzc2M+Hx62mpnarGdKZRTZ9TV8J8= github.com/k8shell-io/common v0.44.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= +github.com/k8shell-io/common v0.45.0 h1:1f3QUIU1DhQ/HpBZVO/5BDcKYjf/EO4RKdOXZk33FzI= +github.com/k8shell-io/common v0.45.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/k8shell-go v0.2.3 h1:gL7dXDYN4EhWdQvvnY4B2On9Tpb1sZ7G5lO8RtI/nr4= github.com/k8shell-io/k8shell-go v0.2.3/go.mod h1:wWb5gq693qqb48/p5iYrosLG4uNeGOr5dQJiOClIbE8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= diff --git a/internal/grpc/system.go b/internal/grpc/system.go index af8d532f..3fb7813a 100644 --- a/internal/grpc/system.go +++ b/internal/grpc/system.go @@ -23,6 +23,10 @@ import ( // store for new entries while following (mirrors the REST /logs handler). const logStreamPollInterval = 100 * time.Millisecond +// defaultLogPageLimit is the page size GetLogsPage falls back to when the +// caller doesn't specify one (mirrors the REST /logs handler's default). +const defaultLogPageLimit = 100 + // SystemServiceServer is the gRPC server for the system service type SystemServiceServer struct { grpcApi *GRPCService @@ -140,9 +144,31 @@ func (s *SystemServiceServer) GetLogsStream(req *k8shelldv1.SystemLogsStreamRequ level := k8shelld.LogLevelFromProto(req.GetLevel()) follow := req.GetFollow() - offset := 0 + send := func(entries []logger.LogEntry) error { + for _, entry := range entries { + if sendErr := stream.Send(logEntryToProto(entry)); sendErr != nil { + return status.Errorf(codes.Canceled, "client canceled") + } + } + return nil + } + + var backlog []logger.LogEntry + var sinceID int64 if n := req.GetLastN(); n > 0 { - offset = -int(n) + backlog, _ = logger.GetLogsBefore(0, int(n), component, level) + if len(backlog) > 0 { + sinceID = backlog[len(backlog)-1].ID + } + } else { + backlog, sinceID = logger.GetLogsSince(0, component, level) + } + if err := send(backlog); err != nil { + return err + } + + if !follow { + return nil } ctx := stream.Context() @@ -151,24 +177,56 @@ func (s *SystemServiceServer) GetLogsStream(req *k8shelldv1.SystemLogsStreamRequ case <-ctx.Done(): return status.Errorf(codes.Canceled, "client canceled") default: - entries, newOffset := logger.GetLogsSince(offset, component, level) - for _, entry := range entries { - if sendErr := stream.Send(&k8shelldv1.SystemLogsStreamResponse{ - Time: entry.Timestamp, - Component: entry.Component, - Level: entry.Level, - Message: entry.Message, - }); sendErr != nil { - return status.Errorf(codes.Canceled, "client canceled") - } - } - offset = newOffset - - if !follow { - return nil + entries, newSinceID := logger.GetLogsSince(sinceID, component, level) + if err := send(entries); err != nil { + return err } + sinceID = newSinceID time.Sleep(logStreamPollInterval) } } } + +// GetLogsPage returns one page of k8shelld daemon logs strictly older than +// the requested BeforeId, for "load more" / infinite-scroll style backward +// pagination independent of GetLogsStream's live tail. +func (s *SystemServiceServer) GetLogsPage(ctx context.Context, + req *k8shelldv1.GetLogsPageRequest) (*k8shelldv1.GetLogsPageResponse, error) { + + if req.GetBeforeId() < 0 { + return nil, status.Errorf(codes.InvalidArgument, "before_id must not be negative") + } + if req.GetLimit() < 0 { + return nil, status.Errorf(codes.InvalidArgument, "limit must not be negative") + } + + limit := int(req.GetLimit()) + if limit == 0 { + limit = defaultLogPageLimit + } + + component := req.GetComponent() + level := k8shelld.LogLevelFromProto(req.GetLevel()) + + entries, hasMore := logger.GetLogsBefore(req.GetBeforeId(), limit, component, level) + + resp := &k8shelldv1.GetLogsPageResponse{ + Entries: make([]*k8shelldv1.SystemLogsStreamResponse, 0, len(entries)), + HasMore: hasMore, + } + for _, entry := range entries { + resp.Entries = append(resp.Entries, logEntryToProto(entry)) + } + return resp, nil +} + +func logEntryToProto(entry logger.LogEntry) *k8shelldv1.SystemLogsStreamResponse { + return &k8shelldv1.SystemLogsStreamResponse{ + Id: entry.ID, + Time: entry.Timestamp, + Component: entry.Component, + Level: entry.Level, + Message: entry.Message, + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 430df289..0d58ad01 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "sync" clogger "github.com/k8shell-io/common/pkg/logger" @@ -17,19 +18,26 @@ const LOGSTORE_CAPACITY = 10000 // Shared memory log store var logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, LOGSTORE_CAPACITY), + entries: make([]LogEntry, 0, LOGSTORE_CAPACITY), cap: LOGSTORE_CAPACITY, } // MemoryLogStore is an in-memory log store that implements io.Writer type MemoryLogStore struct { mu sync.Mutex - entries []logEntry + entries []LogEntry cap int + nextID int64 } -// logEntry represents a single log entry -type logEntry struct { +// LogEntry represents a single log entry. +// +// ID is a per-store, monotonically increasing sequence number assigned on +// write (starting at 1). Unlike a slice index it never shifts as older +// entries are evicted from the buffer, so it's safe to hold onto as a +// pagination cursor across calls. +type LogEntry struct { + ID int64 `json:"id"` Timestamp string `json:"time"` Component string `json:"component"` Level string `json:"level"` @@ -45,7 +53,7 @@ func NewLogger(component string) *zerolog.Logger { // Write implements the io.Writer interface for MemoryLogStore func (s *MemoryLogStore) Write(p []byte) (int, error) { - var entry logEntry + var entry LogEntry if err := json.Unmarshal(p, &entry); err != nil { return 0, fmt.Errorf("failed to unmarshal log entry: %w", err) @@ -54,6 +62,9 @@ func (s *MemoryLogStore) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() + s.nextID++ + entry.ID = s.nextID + if len(s.entries) >= s.cap { s.entries = s.entries[len(s.entries)-s.cap:] } @@ -82,28 +93,67 @@ func InitLogLevel(level string) error { return nil } -// GetLogsSince returns new log entries from the given offset. -func GetLogsSince(offset int, component, level string) ([]logEntry, int) { +// GetLogsSince returns log entries with ID > sinceID (in ID order), along +// with the highest entry ID currently in the store — pass that back in as +// sinceID on the next call to continue tailing without gaps or repeats, +// regardless of how many entries have since been evicted from the buffer. +// sinceID <= 0 returns every entry currently buffered. +func GetLogsSince(sinceID int64, component, level string) ([]LogEntry, int64) { logStore.mu.Lock() defer logStore.mu.Unlock() - if offset >= len(logStore.entries) { - return nil, len(logStore.entries) + lastID := sinceID + if n := len(logStore.entries); n > 0 && logStore.entries[n-1].ID > lastID { + lastID = logStore.entries[n-1].ID } - if offset < 0 { - offset = len(logStore.entries) + offset - if offset < 0 { - offset = 0 + var logs []LogEntry + for _, entry := range logStore.entries { + if entry.ID <= sinceID { + continue + } + if (component == "" || entry.Component == component) && (level == "" || entry.Level == level) { + logs = append(logs, entry) } } + return logs, lastID +} - var logs []logEntry - for i := offset; i < len(logStore.entries); i++ { +// GetLogsBefore returns up to limit log entries with ID < beforeID, oldest +// matching entry first — the "before" half of cursor-based pagination: +// pass the ID of the oldest entry from the previous page back in as +// beforeID to load the page before it. beforeID <= 0 starts from the most +// recent entry (there is no valid entry ID 0, since IDs are assigned +// starting at 1, so it doubles as the "no cursor yet" sentinel for the +// first page). The returned bool reports whether older entries remain +// unscanned in the buffer, i.e. whether a further "load more" call could +// return anything. +func GetLogsBefore(beforeID int64, limit int, component, level string) ([]LogEntry, bool) { + logStore.mu.Lock() + defer logStore.mu.Unlock() + + end := len(logStore.entries) + if beforeID > 0 { + end = sort.Search(end, func(i int) bool { + return logStore.entries[i].ID >= beforeID + }) + } + + var logs []LogEntry + hasMore := false + for i := end - 1; i >= 0; i-- { + if len(logs) == limit { + hasMore = true + break + } entry := logStore.entries[i] if (component == "" || entry.Component == component) && (level == "" || entry.Level == level) { logs = append(logs, entry) } } - return logs, len(logStore.entries) + + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + return logs, hasMore } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 4d1cb9d0..df162457 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -24,11 +24,11 @@ func TestMemoryLogStore_Initialization(t *testing.T) { func TestMemoryLogStore_Write(t *testing.T) { // Create a test store store := &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - logData := logEntry{ + logData := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test-component", Level: "info", @@ -63,7 +63,7 @@ func TestMemoryLogStore_Write(t *testing.T) { func TestMemoryLogStore_Write_InvalidJSON(t *testing.T) { store := &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } @@ -77,13 +77,13 @@ func TestMemoryLogStore_Write_InvalidJSON(t *testing.T) { func TestMemoryLogStore_CapacityLimit(t *testing.T) { capacity := 5 store := &MemoryLogStore{ - entries: make([]logEntry, 0, capacity), + entries: make([]LogEntry, 0, capacity), cap: capacity, } // Write more entries than capacity for i := 0; i < 10; i++ { - logData := logEntry{ + logData := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -129,12 +129,12 @@ func TestGetLogsSince_Basic(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add some test entries - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "debug", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "error", Message: "msg3"}, @@ -166,13 +166,13 @@ func TestGetLogsSince_WithOffset(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add test entries for i := 0; i < 5; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -200,20 +200,20 @@ func TestGetLogsSince_WithOffset(t *testing.T) { } } -func TestGetLogsSince_NegativeOffset(t *testing.T) { +func TestGetLogsBefore_MostRecent(t *testing.T) { // Save and restore original logStore originalStore := logStore defer func() { logStore = originalStore }() // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add test entries for i := 0; i < 5; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -226,19 +226,107 @@ func TestGetLogsSince_NegativeOffset(t *testing.T) { } } - // -2 should get last 2 entries - logs, newOffset := GetLogsSince(-2, "", "") + // beforeID <= 0 should get the last 2 entries, oldest-first + logs, hasMore := GetLogsBefore(0, 2, "", "") if len(logs) != 2 { - t.Errorf("expected 2 logs with offset -2, got %d", len(logs)) + t.Fatalf("expected 2 logs, got %d", len(logs)) } - if newOffset != 5 { - t.Errorf("expected newOffset 5, got %d", newOffset) + if !hasMore { + t.Error("expected hasMore=true, since 3 older entries remain") } - if logs[0].Message != "D" { - t.Errorf("expected first log message 'D', got '%s'", logs[0].Message) + if logs[0].Message != "D" || logs[1].Message != "E" { + t.Errorf("expected [D, E], got [%s, %s]", logs[0].Message, logs[1].Message) + } +} + +func TestGetLogsBefore_Pagination(t *testing.T) { + // Save and restore original logStore + originalStore := logStore + defer func() { logStore = originalStore }() + + // Create test store + logStore = &MemoryLogStore{ + entries: make([]LogEntry, 0, 10), + cap: 10, + } + + // Add 5 entries: A..E, IDs 1..5 + for i := 0; i < 5; i++ { + entry := LogEntry{ + Timestamp: "2025-12-18T10:00:00Z", + Component: "test", + Level: "info", + Message: string(rune('A' + i)), + } + data, _ := json.Marshal(entry) + _, err := logStore.Write(data) + if err != nil { + t.Fatalf("failed to write log entry: %v", err) + } + } + + // Page 1: most recent 2 -> [D, E], more remain (A, B, C) + page1, hasMore1 := GetLogsBefore(0, 2, "", "") + if len(page1) != 2 || page1[0].Message != "D" || page1[1].Message != "E" || !hasMore1 { + t.Fatalf("unexpected page1: %+v hasMore=%v", page1, hasMore1) + } + + // Page 2: before the oldest entry of page1 -> [B, C], A remains + page2, hasMore2 := GetLogsBefore(page1[0].ID, 2, "", "") + if len(page2) != 2 || page2[0].Message != "B" || page2[1].Message != "C" || !hasMore2 { + t.Fatalf("unexpected page2: %+v hasMore=%v", page2, hasMore2) + } + + // Page 3: before the oldest entry of page2 -> [A], nothing left + page3, hasMore3 := GetLogsBefore(page2[0].ID, 2, "", "") + if len(page3) != 1 || page3[0].Message != "A" || hasMore3 { + t.Fatalf("unexpected page3: %+v hasMore=%v", page3, hasMore3) + } +} + +// TestGetLogsBefore_StableAcrossEviction verifies that a cursor obtained +// before older entries are evicted from the buffer still lands in the +// right place afterwards — the whole point of using a stable sequence id +// instead of a slice index, which would shift under eviction. +func TestGetLogsBefore_StableAcrossEviction(t *testing.T) { + // Save and restore original logStore + originalStore := logStore + defer func() { logStore = originalStore }() + + capacity := 5 + logStore = &MemoryLogStore{ + entries: make([]LogEntry, 0, capacity), + cap: capacity, + } + + // Write 10 entries (IDs 1..10); with capacity 5 the store retains the + // last 6 (see TestMemoryLogStore_CapacityLimit), i.e. messages E..J + // with IDs 5..10. + for i := 0; i < 10; i++ { + entry := LogEntry{ + Timestamp: "2025-12-18T10:00:00Z", + Component: "test", + Level: "info", + Message: string(rune('A' + i)), + } + data, _ := json.Marshal(entry) + if _, err := logStore.Write(data); err != nil { + t.Fatalf("Write failed at iteration %d: %v", i, err) + } + } + + // Cursor id 8 ("H") predates eviction of "A"-"D"; paging before it must + // still resolve correctly against what remains in the buffer: entries + // with ID < 8 that are still present are E(5), F(6), G(7). + logs, hasMore := GetLogsBefore(8, 10, "", "") + if len(logs) != 3 || logs[0].Message != "E" || logs[1].Message != "F" || logs[2].Message != "G" { + t.Fatalf("expected [E, F, G], got %+v", logs) + } + if hasMore { + t.Error("expected hasMore=false, buffer has no entries older than E") } } @@ -249,11 +337,11 @@ func TestGetLogsSince_ComponentFilter(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "info", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "info", Message: "msg3"}, @@ -292,11 +380,11 @@ func TestGetLogsSince_LevelFilter(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp1", Level: "error", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "info", Message: "msg3"}, @@ -333,11 +421,11 @@ func TestGetLogsSince_CombinedFilters(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "error", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "error", Message: "msg3"}, @@ -363,20 +451,20 @@ func TestGetLogsSince_CombinedFilters(t *testing.T) { } } -func TestGetLogsSince_OffsetBeyondLength(t *testing.T) { +func TestGetLogsSince_SinceIDBeyondLatest(t *testing.T) { // Save and restore original logStore originalStore := logStore defer func() { logStore = originalStore }() // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - // Add 3 entries + // Add 3 entries (IDs 1..3) for i := 0; i < 3; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -389,14 +477,16 @@ func TestGetLogsSince_OffsetBeyondLength(t *testing.T) { } } - logs, newOffset := GetLogsSince(10, "", "") + // A sinceID beyond the latest entry id has nothing new to return, and + // the cursor doesn't move backward. + logs, lastID := GetLogsSince(10, "", "") if logs != nil { - t.Errorf("expected nil logs for offset beyond length, got %d logs", len(logs)) + t.Errorf("expected nil logs for sinceID beyond the latest entry, got %d logs", len(logs)) } - if newOffset != 3 { - t.Errorf("expected newOffset 3, got %d", newOffset) + if lastID != 10 { + t.Errorf("expected lastID to stay at 10, got %d", lastID) } } diff --git a/internal/server/restapi.go b/internal/server/restapi.go index eed1185c..afe6d426 100644 --- a/internal/server/restapi.go +++ b/internal/server/restapi.go @@ -449,26 +449,59 @@ func (a *RESTService) GetSplash(w http.ResponseWriter, r *http.Request) { } } +// defaultLogPageSize is the page size used for cursor-based pagination +// (the "before" query parameter) when the caller doesn't specify a 'limit'. +const defaultLogPageSize = 100 + +// GetLogs serves k8shelld daemon logs (the same logs shown by `kbox logs`). +// +// Query parameters: +// - component, level: optional filters +// - follow=true: keep the connection open and stream new entries as they +// arrive, seeded with the last 'limit' entries (or everything currently +// buffered if 'limit' is unset) +// - before=: cursor-based pagination — returns up to 'limit' entries +// older than the given entry id, oldest-first, for "load more" style +// paging that stays correct even as the in-memory buffer evicts old +// entries underneath it. Omit (or 0) for the most recent page. The +// response carries an 'X-Log-Has-More' header (true/false) indicating +// whether an older page exists; the next 'before' cursor is simply the +// 'id' of the oldest entry in the response body. +// - limit (alias: lastN, kept for backward compatibility): page size for +// 'before' pagination, or entry count for the initial/follow backlog. +// Omitted with no 'before' returns everything currently buffered. func (a *RESTService) GetLogs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - component := r.URL.Query().Get("component") - level := r.URL.Query().Get("level") - follow := r.URL.Query().Get("follow") == "true" - lastN := r.URL.Query().Get("lastN") + q := r.URL.Query() + component := q.Get("component") + level := q.Get("level") + follow := q.Get("follow") == "true" - var err error - var n int - if lastN != "" { - n, err = strconv.Atoi(lastN) + limitStr := q.Get("limit") + if limitStr == "" { + limitStr = q.Get("lastN") + } + var limit int + if limitStr != "" { + n, err := strconv.Atoi(limitStr) if err != nil || n < 0 { - http.Error(w, "Invalid 'lastN' parameter", http.StatusBadRequest) + http.Error(w, "Invalid 'limit' parameter", http.StatusBadRequest) return } - } else { - n = 0 + limit = n + } + + var beforeID int64 + if beforeStr := q.Get("before"); beforeStr != "" { + id, err := strconv.ParseInt(beforeStr, 10, 64) + if err != nil || id < 0 { + http.Error(w, "Invalid 'before' parameter", http.StatusBadRequest) + return + } + beforeID = id } flusher, ok := w.(http.Flusher) @@ -477,37 +510,62 @@ func (a *RESTService) GetLogs(w http.ResponseWriter, r *http.Request) { return } - offset := 0 - if n > 0 { - offset = -n + writeEntries := func(entries []logger.LogEntry) { + for _, entry := range entries { + b, err := json.Marshal(entry) + if err != nil { + a.logger.Error().Err(err).Msg("failed to encode log entry") + continue + } + _, _ = fmt.Fprintln(w, string(b)) + } } - for { - select { - case <-r.Context().Done(): - return - default: - entries, newOffset := logger.GetLogsSince(offset, component, level) - - for _, entry := range entries { - b, err := json.Marshal(entry) - if err != nil { - a.logger.Error().Err(err).Msg("failed to encode log entry") - continue - } - _, _ = fmt.Fprintln(w, string(b)) + if follow { + var backlog []logger.LogEntry + var sinceID int64 + if limit > 0 { + backlog, _ = logger.GetLogsBefore(0, limit, component, level) + if len(backlog) > 0 { + sinceID = backlog[len(backlog)-1].ID } + } else { + backlog, sinceID = logger.GetLogsSince(0, component, level) + } + writeEntries(backlog) + flusher.Flush() - flusher.Flush() - offset = newOffset - - if !follow { + for { + select { + case <-r.Context().Done(): return + default: + entries, newSinceID := logger.GetLogsSince(sinceID, component, level) + if len(entries) > 0 { + writeEntries(entries) + flusher.Flush() + } + sinceID = newSinceID + time.Sleep(100 * time.Millisecond) } + } + } - time.Sleep(100 * time.Millisecond) + var entries []logger.LogEntry + if beforeID > 0 || limit > 0 { + pageLimit := limit + if pageLimit == 0 { + pageLimit = defaultLogPageSize } + var hasMore bool + entries, hasMore = logger.GetLogsBefore(beforeID, pageLimit, component, level) + w.Header().Set("X-Log-Has-More", strconv.FormatBool(hasMore)) + } else { + entries, _ = logger.GetLogsSince(0, component, level) } + + writeEntries(entries) + flusher.Flush() } func (a *RESTService) ValidateK8shelldFile(w http.ResponseWriter, r *http.Request) { From a572e3486123b9fa3d818c9ff276a555c7d060a8 Mon Sep 17 00:00:00 2001 From: n/a Date: Sun, 23 Aug 2026 10:56:37 +0200 Subject: [PATCH 6/7] ring buffer chart fix --- internal/grpc/detachable.go | 77 ++++++++++++++++++++++++++++ internal/grpc/detachable_test.go | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 internal/grpc/detachable_test.go diff --git a/internal/grpc/detachable.go b/internal/grpc/detachable.go index d4c69f4b..5d018d16 100644 --- a/internal/grpc/detachable.go +++ b/internal/grpc/detachable.go @@ -528,6 +528,17 @@ func (a *GRPCService) runRESTAttachLoop(session *SessionData, conn net.Conn, det // - OSC 10 ; ... ST/BEL — foreground colour report // - OSC 11 ; ... ST/BEL — background colour report // - DCS ... ST — device control string responses (e.g. XTGETTCAP) +// +// Also stripped: the shell's PROMPT_EOL_MARK ("%" or "#" wrapped in SGR +// styling, padded with spaces to the terminal width, then a bare CR — e.g. +// zsh's default "%B%S%#%s%b"). It is rendered by overwriting it with the +// next prompt line, which relies on the replaying terminal being exactly as +// wide as the terminal the shell originally sized its padding for. Replayed +// scrollback is frequently viewed in a differently-sized terminal (the +// session's PTY width at attach time lags the reattaching client's actual +// width — see cmd/kbox/attach.go), so the overwrite trick fails and the +// mark is left stranded above the prompt. Since it carries no information +// for a new client, it's dropped like the other noise above. func stripTerminalQueryResponses(data []byte) []byte { if len(data) == 0 { return data @@ -544,6 +555,9 @@ func stripTerminalQueryResponses(data []byte) []byte { switch data[i+1] { case '[': // CSI end, ok := scanCSIResponse(data, i) + if !ok { + end, ok = scanEOLMark(data, i) + } if ok { i = end } else { @@ -611,6 +625,69 @@ func scanCSIResponse(data []byte, i int) (end int, ok bool) { return i, false } +// scanEOLMark matches a shell's PROMPT_EOL_MARK sequence: one or more SGR +// (colour/style) escapes, the marker character ('%' or '#'), one or more +// closing SGR escapes, a run of padding spaces, and a bare CR (not followed +// by LF — that would just be a normal line ending). Returns the index past +// the CR on success, or (i, false) if the pattern doesn't fully match, +// leaving the caller to emit data[i] unchanged. +// +// i must point at the ESC of what is expected to be the first SGR escape. +func scanEOLMark(data []byte, i int) (end int, ok bool) { + j := i + opened := 0 + for { + next, sgrOK := scanSGR(data, j) + if !sgrOK { + break + } + j = next + opened++ + } + if opened == 0 { + return i, false + } + if j >= len(data) || (data[j] != '%' && data[j] != '#') { + return i, false + } + j++ // consume the marker character + + for { + next, sgrOK := scanSGR(data, j) + if !sgrOK { + break + } + j = next + } + + for j < len(data) && data[j] == ' ' { + j++ + } + + if j >= len(data) || data[j] != '\r' { + return i, false + } + j++ // consume the CR; a following LF (if any) is left untouched + + return j, true +} + +// scanSGR returns the index past an SGR escape (ESC [ params m) starting at +// j, or (j, false) if there isn't one there. +func scanSGR(data []byte, j int) (end int, ok bool) { + if j+1 >= len(data) || data[j] != 0x1b || data[j+1] != '[' { + return j, false + } + k := j + 2 + for k < len(data) && ((data[k] >= 0x30 && data[k] <= 0x3f) || data[k] == ';') { + k++ + } + if k >= len(data) || data[k] != 'm' { + return j, false + } + return k + 1, true +} + // scanOSCResponse returns the index past the end of an OSC sequence that // starts with 10; or 11; (colour query responses), terminated by BEL or ST. func scanOSCResponse(data []byte, i int) (end int, ok bool) { diff --git a/internal/grpc/detachable_test.go b/internal/grpc/detachable_test.go new file mode 100644 index 00000000..c4c0313b --- /dev/null +++ b/internal/grpc/detachable_test.go @@ -0,0 +1,87 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package grpc + +import "testing" + +func TestStripTerminalQueryResponses_EOLMark(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "zsh default PROMPT_EOL_MARK (bold+standout %, padded, bare CR)", + in: "hello\x1b[1m\x1b[7m%\x1b[27m\x1b[m \r[~]$ ", + want: "hello[~]$ ", + }, + { + name: "single SGR wrap with # marker (root prompt)", + in: "out\x1b[7m#\x1b[27m \r$ ", + want: "out$ ", + }, + { + name: "no padding spaces still strips", + in: "x\x1b[7m%\x1b[27m\r$ ", + want: "x$ ", + }, + { + name: "CRLF is left untouched (normal line ending, not the EOL mark)", + in: "line one\r\nline two\r\n", + want: "line one\r\nline two\r\n", + }, + { + name: "bare % without SGR wrapping is not stripped (e.g. a real progress indicator)", + in: "50% \rdone", + want: "50% \rdone", + }, + { + name: "styled text without the marker char is left alone", + in: "\x1b[1mBOLD\x1b[0m text", + want: "\x1b[1mBOLD\x1b[0m text", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(stripTerminalQueryResponses([]byte(c.in))) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestStripTerminalQueryResponses_ExistingCSIFiltering(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "CPR response stripped", + in: "before\x1b[24;80Rafter", + want: "beforeafter", + }, + { + name: "primary DA response stripped", + in: "before\x1b[?1;2cafter", + want: "beforeafter", + }, + { + name: "OSC 11 colour response stripped (BEL terminated)", + in: "before\x1b]11;rgb:0000/0000/0000\x07after", + want: "beforeafter", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(stripTerminalQueryResponses([]byte(c.in))) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} From 663768a9f043ed629aab769b102b6cf0f5d79aa8 Mon Sep 17 00:00:00 2001 From: n/a Date: Sun, 23 Aug 2026 20:36:34 +0200 Subject: [PATCH 7/7] sys info metrics history --- go.mod | 2 +- go.sum | 2 + internal/grpc/system.go | 21 ++ internal/system/history.go | 367 ++++++++++++++++++++++++++++++++ internal/system/history_test.go | 270 +++++++++++++++++++++++ internal/system/system.go | 91 +++++++- 6 files changed, 744 insertions(+), 9 deletions(-) create mode 100644 internal/system/history.go create mode 100644 internal/system/history_test.go diff --git a/go.mod b/go.mod index 7e9a03d1..9295a3d5 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/common v0.45.0 + github.com/k8shell-io/common v0.46.0 github.com/k8shell-io/k8shell-go v0.2.3 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 diff --git a/go.sum b/go.sum index 1a4bd87c..fb531b51 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/k8shell-io/common v0.44.0 h1:cry62gDIXW8oyRWCzc2M+Hx62mpnarGdKZRTZ9TV github.com/k8shell-io/common v0.44.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/common v0.45.0 h1:1f3QUIU1DhQ/HpBZVO/5BDcKYjf/EO4RKdOXZk33FzI= github.com/k8shell-io/common v0.45.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= +github.com/k8shell-io/common v0.46.0 h1:mUTlgfMgt/HYWixyjr8cWrJL6Ovs6Du3A9W7zPNUb/E= +github.com/k8shell-io/common v0.46.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= github.com/k8shell-io/k8shell-go v0.2.3 h1:gL7dXDYN4EhWdQvvnY4B2On9Tpb1sZ7G5lO8RtI/nr4= github.com/k8shell-io/k8shell-go v0.2.3/go.mod h1:wWb5gq693qqb48/p5iYrosLG4uNeGOr5dQJiOClIbE8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= diff --git a/internal/grpc/system.go b/internal/grpc/system.go index 3fb7813a..f5be5b7a 100644 --- a/internal/grpc/system.go +++ b/internal/grpc/system.go @@ -133,6 +133,27 @@ func (s *SystemServiceServer) SystemInfo(ctx context.Context, return k8shelld.SystemInfoToProto(&systemInfo), nil } +// SystemInfoHistory returns historical system/mount/docker usage samples +// for charting. See the proto comment on SystemInfoHistoryRequest for the +// precedence of range vs from/to and how step coarsening works. +func (s *SystemServiceServer) SystemInfoHistory(_ context.Context, + req *k8shelldv1.SystemInfoHistoryRequest) (*k8shelldv1.SystemInfoHistoryResponse, error) { + + query := k8shelld.SystemInfoHistoryQuery{ + From: req.GetFrom(), + To: req.GetTo(), + Range: req.GetRange(), + Step: req.GetStep(), + } + + hist, err := s.grpcApi.sysInfo.GetSystemInfoHistory(query) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid system info history request: %v", err) + } + + return k8shelld.SystemInfoHistoryToProto(hist), nil +} + // GetLogsStream streams k8shelld daemon logs (the same logs shown by // `kbox logs`). With Follow=false it sends the currently buffered entries // and closes the stream; with Follow=true it keeps streaming new entries as diff --git a/internal/system/history.go b/internal/system/history.go new file mode 100644 index 00000000..d871827f --- /dev/null +++ b/internal/system/history.go @@ -0,0 +1,367 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package system + +import ( + "fmt" + "sync" + "time" + + "github.com/k8shell-io/common/pkg/api/client/k8shelld" +) + +const ( + // HistoryRetention is how long historical system/mount/docker usage + // samples are kept in memory before aging out. + HistoryRetention = 24 * time.Hour + + // MaxHistoryPoints caps the number of points returned for a single + // series in one SystemInfoHistory response; a finer step than this + // would allow is coarsened to respect it. + MaxHistoryPoints = 720 + + // DefaultHistoryRange is the window used when a request supplies + // neither range nor a from/to pair. + DefaultHistoryRange = 1 * time.Hour +) + +// systemHistoryPoint is one recorded sample of system.go's CPU/memory +// fields. A nil field means that metric wasn't collected at this tick (the +// underlying refresh failed), so it shows as a gap rather than a stale or +// false value. +type systemHistoryPoint struct { + time time.Time + cpuUsageMillicores *float64 + cpuLimitMillicores *float64 + memoryUsageMiB *float64 + memLimitMiB *float64 +} + +// mountHistoryPoint is one recorded sample of a single mount's usage. +type mountHistoryPoint struct { + time time.Time + usedBytes *uint64 + totalBytes *uint64 +} + +// dockerHistoryPoint is one recorded sample of Docker/Podman storage usage. +type dockerHistoryPoint struct { + time time.Time + totalBytes *uint64 + declaredSize *uint64 +} + +// history holds ring-buffer style, age-pruned samples backing the +// SystemInfoHistory RPC. Samples are appended once per Collect() tick from +// the same data already cached on SystemInfo, and pruned to HistoryRetention +// on every append. +type history struct { + mu sync.Mutex + system []systemHistoryPoint + mounts map[string][]mountHistoryPoint + docker []dockerHistoryPoint +} + +func newHistory() *history { + return &history{mounts: make(map[string][]mountHistoryPoint)} +} + +// record appends one tick's samples. sys is nil when that tick's CPU/memory +// refresh failed. mounts/docker are whatever is currently cached (possibly +// empty/nil on a failed or not-applicable collection) — records are only +// added for what's actually present, leaving the rest as a gap. +func (h *history) record(now time.Time, sys *systemHistoryPoint, mounts []k8shelld.MountUsage, docker *k8shelld.DockerUsage) { + h.mu.Lock() + defer h.mu.Unlock() + + if sys != nil { + h.system = append(h.system, *sys) + } + + for _, m := range mounts { + used, total := m.UsedBytes, m.TotalBytes + h.mounts[m.MountPoint] = append(h.mounts[m.MountPoint], mountHistoryPoint{ + time: now, usedBytes: &used, totalBytes: &total, + }) + } + + if docker != nil { + total, declared := docker.TotalBytes, docker.DeclaredSize + h.docker = append(h.docker, dockerHistoryPoint{time: now, totalBytes: &total, declaredSize: &declared}) + } + + h.pruneLocked(now) +} + +// pruneLocked drops samples older than HistoryRetention. Samples are always +// appended in increasing time order, so the first non-expired sample marks +// where each series should be truncated from. Callers must hold h.mu. +func (h *history) pruneLocked(now time.Time) { + cutoff := now.Add(-HistoryRetention) + + h.system = dropBefore(h.system, cutoff, func(p systemHistoryPoint) time.Time { return p.time }) + + for mountPoint, pts := range h.mounts { + pruned := dropBefore(pts, cutoff, func(p mountHistoryPoint) time.Time { return p.time }) + if len(pruned) == 0 { + delete(h.mounts, mountPoint) + } else { + h.mounts[mountPoint] = pruned + } + } + + h.docker = dropBefore(h.docker, cutoff, func(p dockerHistoryPoint) time.Time { return p.time }) +} + +func dropBefore[T any](pts []T, cutoff time.Time, timeOf func(T) time.Time) []T { + i := 0 + for i < len(pts) && timeOf(pts[i]).Before(cutoff) { + i++ + } + return pts[i:] +} + +// resolveHistoryWindow resolves the [from, to) window for a query. When both +// from and to are set they take precedence over range; otherwise range (or +// DefaultHistoryRange when range is also empty) is applied back from now. +// The window is then clamped to [now-HistoryRetention, now]. +func resolveHistoryWindow(reqFrom, reqTo, reqRange string, now time.Time) (from, to time.Time, err error) { + if reqFrom != "" && reqTo != "" { + from, err = time.Parse(time.RFC3339, reqFrom) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid from %q: %w", reqFrom, err) + } + to, err = time.Parse(time.RFC3339, reqTo) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid to %q: %w", reqTo, err) + } + if !to.After(from) { + return time.Time{}, time.Time{}, fmt.Errorf("to must be after from") + } + } else { + rangeDur := DefaultHistoryRange + if reqRange != "" { + rangeDur, err = time.ParseDuration(reqRange) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid range %q: %w", reqRange, err) + } + if rangeDur <= 0 { + return time.Time{}, time.Time{}, fmt.Errorf("range must be positive") + } + } + to = now + from = now.Add(-rangeDur) + } + + if to.After(now) { + to = now + } + if floor := now.Add(-HistoryRetention); from.Before(floor) { + from = floor + } + if !to.After(from) { + to = from.Add(time.Second) + } + + return from, to, nil +} + +// resolveHistoryStep resolves the sample interval for a query: it can't be +// finer than nativeInterval (the collection resolution), and is coarsened +// further if needed to keep the bucket count within MaxHistoryPoints. +func resolveHistoryStep(reqStep string, from, to time.Time, nativeInterval time.Duration) (time.Duration, error) { + step := nativeInterval + if reqStep != "" { + d, err := time.ParseDuration(reqStep) + if err != nil { + return 0, fmt.Errorf("invalid step %q: %w", reqStep, err) + } + if d <= 0 { + return 0, fmt.Errorf("step must be positive") + } + step = d + } + if step < nativeInterval { + step = nativeInterval + } + + if span := to.Sub(from); span > 0 { + if minStep := span / MaxHistoryPoints; minStep > step { + step = minStep + } + } + + return step, nil +} + +func numHistoryBuckets(from, to time.Time, step time.Duration) int { + span := to.Sub(from) + if span <= 0 || step <= 0 { + return 0 + } + n := int(span / step) + if span%step != 0 { + n++ + } + if n < 1 { + n = 1 + } + return n +} + +func bucketIndex(t, from time.Time, step time.Duration) (int, bool) { + if t.Before(from) { + return 0, false + } + return int(t.Sub(from) / step), true +} + +// bucketSystemPoints downsamples raw system samples into n evenly spaced +// buckets of width step starting at from, averaging present values per +// bucket and leaving a field nil where no sample carried it. +func bucketSystemPoints(points []systemHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.SystemMetricsPoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.SystemMetricsPoint, n) + + type acc struct { + cpuUsage, cpuLimit, mem, memLimit float64 + nCPUUsage, nCPULimit, nMem, nMemLimit int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.cpuUsageMillicores != nil { + sums[idx].cpuUsage += *p.cpuUsageMillicores + sums[idx].nCPUUsage++ + } + if p.cpuLimitMillicores != nil { + sums[idx].cpuLimit += *p.cpuLimitMillicores + sums[idx].nCPULimit++ + } + if p.memoryUsageMiB != nil { + sums[idx].mem += *p.memoryUsageMiB + sums[idx].nMem++ + } + if p.memLimitMiB != nil { + sums[idx].memLimit += *p.memLimitMiB + sums[idx].nMemLimit++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nCPUUsage > 0 { + v := sums[i].cpuUsage / float64(sums[i].nCPUUsage) + result[i].CPUUsageMillicores = &v + } + if sums[i].nCPULimit > 0 { + v := sums[i].cpuLimit / float64(sums[i].nCPULimit) + result[i].CPULimitMillicores = &v + } + if sums[i].nMem > 0 { + v := sums[i].mem / float64(sums[i].nMem) + result[i].MemoryUsageMiB = &v + } + if sums[i].nMemLimit > 0 { + v := sums[i].memLimit / float64(sums[i].nMemLimit) + result[i].MemLimitMiB = &v + } + } + return result +} + +// bucketMountPoints downsamples one mount's raw samples the same way +// bucketSystemPoints does, averaging uint64 fields. +func bucketMountPoints(points []mountHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.MountUsagePoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.MountUsagePoint, n) + + type acc struct { + usedSum, totalSum uint64 + nUsed, nTotal int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.usedBytes != nil { + sums[idx].usedSum += *p.usedBytes + sums[idx].nUsed++ + } + if p.totalBytes != nil { + sums[idx].totalSum += *p.totalBytes + sums[idx].nTotal++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nUsed > 0 { + v := sums[i].usedSum / uint64(sums[i].nUsed) + result[i].UsedBytes = &v + } + if sums[i].nTotal > 0 { + v := sums[i].totalSum / uint64(sums[i].nTotal) + result[i].TotalBytes = &v + } + } + return result +} + +// bucketDockerPoints downsamples raw Docker/Podman samples the same way +// bucketMountPoints does. +func bucketDockerPoints(points []dockerHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.DockerUsagePoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.DockerUsagePoint, n) + + type acc struct { + totalSum, declaredSum uint64 + nTotal, nDeclared int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.totalBytes != nil { + sums[idx].totalSum += *p.totalBytes + sums[idx].nTotal++ + } + if p.declaredSize != nil { + sums[idx].declaredSum += *p.declaredSize + sums[idx].nDeclared++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nTotal > 0 { + v := sums[i].totalSum / uint64(sums[i].nTotal) + result[i].TotalBytes = &v + } + if sums[i].nDeclared > 0 { + v := sums[i].declaredSum / uint64(sums[i].nDeclared) + result[i].DeclaredSize = &v + } + } + return result +} diff --git a/internal/system/history_test.go b/internal/system/history_test.go new file mode 100644 index 00000000..5d76c6cd --- /dev/null +++ b/internal/system/history_test.go @@ -0,0 +1,270 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package system + +import ( + "testing" + "time" + + "github.com/k8shell-io/common/pkg/api/client/k8shelld" + commonmodels "github.com/k8shell-io/common/pkg/models" +) + +func TestResolveHistoryWindow(t *testing.T) { + now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + + t.Run("defaults to DefaultHistoryRange when nothing is set", func(t *testing.T) { + from, to, err := resolveHistoryWindow("", "", "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want %v", to, now) + } + if want := now.Add(-DefaultHistoryRange); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + }) + + t.Run("range shorthand applies back from now", func(t *testing.T) { + from, to, err := resolveHistoryWindow("", "", "30m", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want %v", to, now) + } + if want := now.Add(-30 * time.Minute); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + }) + + t.Run("from/to take precedence over range", func(t *testing.T) { + reqFrom := now.Add(-2 * time.Hour).Format(time.RFC3339) + reqTo := now.Add(-1 * time.Hour).Format(time.RFC3339) + from, to, err := resolveHistoryWindow(reqFrom, reqTo, "5m", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := now.Add(-2 * time.Hour); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + if want := now.Add(-1 * time.Hour); !to.Equal(want) { + t.Errorf("to = %v, want %v", to, want) + } + }) + + t.Run("to is clamped to now", func(t *testing.T) { + reqFrom := now.Add(-1 * time.Hour).Format(time.RFC3339) + reqTo := now.Add(1 * time.Hour).Format(time.RFC3339) + _, to, err := resolveHistoryWindow(reqFrom, reqTo, "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want clamped to now (%v)", to, now) + } + }) + + t.Run("from is clamped to the retention floor", func(t *testing.T) { + from, _, err := resolveHistoryWindow("", "", "1000h", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := now.Add(-HistoryRetention); !from.Equal(want) { + t.Errorf("from = %v, want retention floor %v", from, want) + } + }) + + t.Run("rejects to before from", func(t *testing.T) { + reqFrom := now.Format(time.RFC3339) + reqTo := now.Add(-1 * time.Hour).Format(time.RFC3339) + if _, _, err := resolveHistoryWindow(reqFrom, reqTo, "", now); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("rejects malformed range", func(t *testing.T) { + if _, _, err := resolveHistoryWindow("", "", "not-a-duration", now); err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestResolveHistoryStep(t *testing.T) { + now := time.Now() + native := 30 * time.Second + + t.Run("defaults to native interval", func(t *testing.T) { + step, err := resolveHistoryStep("", now.Add(-time.Hour), now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step != native { + t.Errorf("step = %v, want %v", step, native) + } + }) + + t.Run("clamps a finer request up to native interval", func(t *testing.T) { + step, err := resolveHistoryStep("1s", now.Add(-time.Hour), now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step != native { + t.Errorf("step = %v, want native %v", step, native) + } + }) + + t.Run("coarsens to respect MaxHistoryPoints", func(t *testing.T) { + from := now.Add(-HistoryRetention) + step, err := resolveHistoryStep("", from, now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + n := numHistoryBuckets(from, now, step) + if n > MaxHistoryPoints { + t.Errorf("buckets = %d, want <= %d (step=%v)", n, MaxHistoryPoints, step) + } + }) + + t.Run("rejects malformed step", func(t *testing.T) { + if _, err := resolveHistoryStep("nope", now.Add(-time.Hour), now, native); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("rejects non-positive step", func(t *testing.T) { + if _, err := resolveHistoryStep("0s", now.Add(-time.Hour), now, native); err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestHistoryRecordAndPrune(t *testing.T) { + h := newHistory() + base := time.Now().Add(-2 * HistoryRetention) + + // Old sample that should be pruned away as newer samples come in. + h.record(base, &systemHistoryPoint{time: base, cpuUsageMillicores: floatPtr(1)}, nil, nil) + + now := time.Now() + h.record(now, &systemHistoryPoint{time: now, cpuUsageMillicores: floatPtr(2)}, nil, nil) + + if len(h.system) != 1 { + t.Fatalf("len(h.system) = %d, want 1 (old sample should have aged out)", len(h.system)) + } + if *h.system[0].cpuUsageMillicores != 2 { + t.Errorf("remaining sample = %v, want 2", *h.system[0].cpuUsageMillicores) + } +} + +func TestHistoryRecordMountsAndDocker(t *testing.T) { + h := newHistory() + now := time.Now() + + mounts := []k8shelld.MountUsage{ + {MountPoint: "/data", UsedBytes: 100, TotalBytes: 1000}, + } + docker := &k8shelld.DockerUsage{TotalBytes: 500, DeclaredSize: 2000} + + h.record(now, nil, mounts, docker) + + if _, ok := h.mounts["/data"]; !ok { + t.Fatal("expected /data mount history to be recorded") + } + if len(h.docker) != 1 { + t.Fatalf("len(h.docker) = %d, want 1", len(h.docker)) + } + if len(h.system) != 0 { + t.Errorf("len(h.system) = %d, want 0 (nil sys point should not be recorded)", len(h.system)) + } +} + +func TestBucketSystemPointsAveragesAndGaps(t *testing.T) { + from := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + step := time.Minute + to := from.Add(3 * step) + + points := []systemHistoryPoint{ + {time: from, cpuUsageMillicores: floatPtr(10)}, + {time: from.Add(30 * time.Second), cpuUsageMillicores: floatPtr(20)}, + // bucket 1 (from+1min..from+2min) has no samples: should be a gap. + {time: from.Add(2 * step), memoryUsageMiB: floatPtr(50)}, + } + + result := bucketSystemPoints(points, from, to, step) + if len(result) != 3 { + t.Fatalf("len(result) = %d, want 3", len(result)) + } + + if result[0].CPUUsageMillicores == nil { + t.Fatal("bucket 0 CPUUsageMillicores should be set") + } else if want := 15.0; *result[0].CPUUsageMillicores != want { + t.Errorf("bucket 0 CPUUsageMillicores = %v, want %v (average of 10 and 20)", *result[0].CPUUsageMillicores, want) + } + + if result[1].CPUUsageMillicores != nil { + t.Errorf("bucket 1 CPUUsageMillicores = %v, want nil (gap)", *result[1].CPUUsageMillicores) + } + + if result[2].MemoryUsageMiB == nil || *result[2].MemoryUsageMiB != 50 { + t.Errorf("bucket 2 MemoryUsageMiB = %v, want 50", result[2].MemoryUsageMiB) + } +} + +func TestGetSystemInfoHistoryEndToEnd(t *testing.T) { + blueprint := &commonmodels.Blueprint{} + blueprint.Podman.Enabled = true + + s := NewSystemInfo(nil, blueprint) + s.mu.Lock() + s.collectionInterval = 30 * time.Second + s.mu.Unlock() + + now := time.Now() + for i := 0; i < 3; i++ { + t := now.Add(time.Duration(i) * 30 * time.Second) + s.history.record(t, + &systemHistoryPoint{time: t, cpuUsageMillicores: floatPtr(float64(i))}, + []k8shelld.MountUsage{{MountPoint: "/data", UsedBytes: uint64(i), TotalBytes: 1000}}, + &k8shelld.DockerUsage{TotalBytes: uint64(i * 10)}, + ) + } + + hist, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "5m"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hist.Docker == nil { + t.Fatal("expected Docker to be non-nil since Podman is enabled") + } + if _, ok := hist.Mounts["/data"]; !ok { + t.Fatal("expected /data in Mounts") + } + if len(hist.System) == 0 { + t.Fatal("expected non-empty System series") + } +} + +func TestGetSystemInfoHistoryDockerUnavailable(t *testing.T) { + s := NewSystemInfo(nil, &commonmodels.Blueprint{}) + s.mu.Lock() + s.collectionInterval = 30 * time.Second + s.mu.Unlock() + + hist, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "5m"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hist.Docker != nil { + t.Error("expected Docker to be nil when Podman is not enabled") + } +} + +func TestGetSystemInfoHistoryInvalidQuery(t *testing.T) { + s := NewSystemInfo(nil, &commonmodels.Blueprint{}) + if _, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "not-a-duration"}); err == nil { + t.Fatal("expected error for malformed range") + } +} diff --git a/internal/system/system.go b/internal/system/system.go index 5be3a6c7..3c2e164d 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -83,6 +83,8 @@ type SystemInfo struct { cachedMounts []k8shelld.MountUsage // Cached mount usage (refreshed in background) cachedDocker *k8shelld.DockerUsage // Cached podman/docker usage (refreshed in background) cachedPodmanDetails *PodmanDetails // Cached extra Podman details (refreshed in background) + history *history // Historical system/mount/docker usage samples for SystemInfoHistory + collectionInterval time.Duration // Native sample interval, set by Collect; guarded by mu } func NewSystemInfo(config *config.Config, blueprint *commonmodels.Blueprint) *SystemInfo { @@ -93,15 +95,21 @@ func NewSystemInfo(config *config.Config, blueprint *commonmodels.Blueprint) *Sy prevTime: time.Now(), mu: sync.Mutex{}, log: logger.NewLogger("sysifo"), + history: newHistory(), } } func (s *SystemInfo) Collect(ctx context.Context, refreshTimeSec int) error { + s.mu.Lock() + s.collectionInterval = time.Duration(refreshTimeSec) * time.Second + s.mu.Unlock() + // Do an initial refresh immediately so data is available before the first tick. - if err := s.refresh(); err != nil { - s.log.Warn().Msgf("Initial system info refresh failed: %v", err) + refreshErr := s.refresh() + if refreshErr != nil { + s.log.Warn().Msgf("Initial system info refresh failed: %v", refreshErr) } - s.refreshStorage(ctx) + s.refreshStorage(ctx, refreshErr == nil) ticker := time.NewTicker(time.Duration(refreshTimeSec) * time.Second) defer ticker.Stop() @@ -109,10 +117,11 @@ func (s *SystemInfo) Collect(ctx context.Context, refreshTimeSec int) error { for { select { case <-ticker.C: - if err := s.refresh(); err != nil { - s.log.Warn().Msgf("Failed to update system info: %v", err) + refreshErr := s.refresh() + if refreshErr != nil { + s.log.Warn().Msgf("Failed to update system info: %v", refreshErr) } - s.refreshStorage(ctx) + s.refreshStorage(ctx, refreshErr == nil) case <-ctx.Done(): s.log.Info().Msg("System info updater stopped.") return ctx.Err() @@ -274,8 +283,11 @@ func (s *SystemInfo) computeDockerSnapshot(ctx context.Context) (*k8shelld.Docke return du, pd, nil } -// refreshStorage updates cachedMounts and cachedDocker in parallel. -func (s *SystemInfo) refreshStorage(ctx context.Context) { +// refreshStorage updates cachedMounts and cachedDocker in parallel, then +// records a history sample. refreshOK indicates whether the CPU/memory +// refresh that preceded this call succeeded; when it didn't, the system +// portion of the history sample is left as a gap rather than a stale value. +func (s *SystemInfo) refreshStorage(ctx context.Context, refreshOK bool) { var ( mounts []k8shelld.MountUsage docker *k8shelld.DockerUsage @@ -293,11 +305,74 @@ func (s *SystemInfo) refreshStorage(ctx context.Context) { }() wg.Wait() + now := time.Now() + s.mu.Lock() s.cachedMounts = mounts s.cachedDocker = docker s.cachedPodmanDetails = podman + + var sysPoint *systemHistoryPoint + if refreshOK { + sysPoint = &systemHistoryPoint{ + time: now, + cpuUsageMillicores: floatPtr(s.CPUUsageMillicores), + cpuLimitMillicores: floatPtr(s.CPULimitMillicores), + memoryUsageMiB: floatPtr(s.MemoryUsageMiB), + memLimitMiB: floatPtr(s.MemLimitMiB), + } + } s.mu.Unlock() + + s.history.record(now, sysPoint, mounts, docker) +} + +func floatPtr(v float64) *float64 { return &v } + +// GetSystemInfoHistory resolves a SystemInfoHistory query against the +// recorded ring buffer and returns the downsampled series. The returned +// From/To/Step reflect what was actually used after clamping/coarsening. +func (s *SystemInfo) GetSystemInfoHistory(q k8shelld.SystemInfoHistoryQuery) (*k8shelld.SystemInfoHistory, error) { + now := time.Now() + + from, to, err := resolveHistoryWindow(q.From, q.To, q.Range, now) + if err != nil { + return nil, err + } + + s.mu.Lock() + nativeInterval := s.collectionInterval + s.mu.Unlock() + if nativeInterval <= 0 { + nativeInterval = time.Second + } + + step, err := resolveHistoryStep(q.Step, from, to, nativeInterval) + if err != nil { + return nil, err + } + + s.history.mu.Lock() + defer s.history.mu.Unlock() + + result := &k8shelld.SystemInfoHistory{ + From: from.Format(time.RFC3339), + To: to.Format(time.RFC3339), + Step: step.String(), + System: bucketSystemPoints(s.history.system, from, to, step), + Mounts: make(map[string][]k8shelld.MountUsagePoint, len(s.history.mounts)), + } + + for mountPoint, points := range s.history.mounts { + result.Mounts[mountPoint] = bucketMountPoints(points, from, to, step) + } + + if s.blueprint != nil && s.blueprint.Podman.Enabled { + docker := bucketDockerPoints(s.history.docker, from, to, step) + result.Docker = &docker + } + + return result, nil } // CPUSample represents a CPU usage sample