From c161b08e3520143c329f0836b84c8b31b9508d4d Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Tue, 6 Oct 2026 16:28:23 +0200 Subject: [PATCH 1/3] Add tail to print and follow a fleet's logs tail takes a fleet id and optional IMEIs. Without -n it follows new logs. -n prints the newest logs, and --offset skips that many of the newest first so a tool can page back. --- README.md | 6 +- internal/api/logs.go | 61 ++++++++ internal/tail/tail.go | 168 +++++++++++++++++++++ internal/tail/tail_test.go | 292 +++++++++++++++++++++++++++++++++++++ main.go | 3 +- main_test.go | 7 +- 6 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 internal/api/logs.go create mode 100644 internal/tail/tail.go create mode 100644 internal/tail/tail_test.go diff --git a/README.md b/README.md index 97a17b4..f5b8252 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Superstack CLI `superstack` is the command line interface to Superstack: log in, create -fleets, manage devices, and control who can reach them. It is a single static -binary for managing Superstack from a terminal. - -Streaming logs is not available yet. +fleets, manage devices, read their logs, and control who can reach them. It is +a single static binary for managing Superstack from a terminal. ## Install diff --git a/internal/api/logs.go b/internal/api/logs.go new file mode 100644 index 0000000..e6c3c8c --- /dev/null +++ b/internal/api/logs.go @@ -0,0 +1,61 @@ +package api + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "time" +) + +type LogEntry struct { + Id int64 `json:"id"` + Imei string `json:"imei"` + Name *string `json:"name"` + Kind string `json:"kind"` + Text string `json:"text"` + ReceivedAt time.Time `json:"received_at"` +} + +type LogsAnswer struct { + Logs []LogEntry `json:"logs"` + Next int64 `json:"next"` +} + +// position is last, for the newest logs, or after, for the logs newer than a cursor +func FetchLogs(invocation Invocation, fleetID int64, imeis []string, position string, value int64) (LogsAnswer, error) { + query := url.Values{position: {strconv.FormatInt(value, 10)}} + + for _, imei := range imeis { + query.Add("imei", imei) + } + + request, err := AuthenticatedRequest(invocation, http.MethodGet, + "/fleets/"+strconv.FormatInt(fleetID, 10)+"/logs?"+query.Encode(), nil) + + if err != nil { + return LogsAnswer{}, err + } + + response, err := invocation.Client.Do(request) + + if err != nil { + return LogsAnswer{}, errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return LogsAnswer{}, ServerError(response) + } + + answer := LogsAnswer{} + + err = Decode(response, &answer) + + if err != nil { + return LogsAnswer{}, err + } + + return answer, nil +} diff --git a/internal/tail/tail.go b/internal/tail/tail.go new file mode 100644 index 0000000..701f90e --- /dev/null +++ b/internal/tail/tail.go @@ -0,0 +1,168 @@ +package tail + +import ( + "errors" + "fmt" + "io" + "math" + "strconv" + "strings" + + "github.com/siliconwitchery/superstack-cli/internal/api" +) + +func Tail(invocation api.Invocation, arguments []string) error { + positionals := []string{} + count := int64(-1) + offset := int64(0) + offsetGiven := false + + for index := 0; index < len(arguments); index++ { + switch arguments[index] { + case "-n", "--offset": + flag := arguments[index] + + if index+1 == len(arguments) { + return fmt.Errorf("%s needs a number", flag) + } + + index++ + + value, err := strconv.ParseInt(arguments[index], 10, 64) + + if err != nil || value < 0 { + return fmt.Errorf("%s takes a whole number of logs", flag) + } + + if flag == "-n" { + count = value + } else { + offset = value + offsetGiven = true + } + + default: + positionals = append(positionals, arguments[index]) + } + } + + if len(positionals) == 0 { + return errors.New("tail takes a fleet id, then the IMEIs of the devices to show if not all of them") + } + + fleetID, err := strconv.ParseInt(positionals[0], 10, 64) + + if err != nil || fleetID < 1 { + return errors.New("the fleet id is the number shown by fleet list") + } + + imeis := positionals[1:] + + for _, imei := range imeis { + if len(imei) != 15 || strings.ContainsFunc(imei, func(digit rune) bool { return digit < '0' || digit > '9' }) { + return fmt.Errorf("%s is not an IMEI, which is the 15-digit number printed on the device", api.Printable(imei)) + } + } + + if offsetGiven && count < 0 { + return errors.New("--offset skips the newest logs before -n prints, so it needs -n") + } + + if count > math.MaxInt64-offset { + return errors.New("-n and --offset together ask for more logs than can be counted") + } + + // Follow + if count < 0 { + answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", 0) + + if err != nil { + return err + } + + for { + answer, err = api.FetchLogs(invocation, fleetID, imeis, "after", answer.Next) + + if err != nil { + return err + } + + for _, entry := range answer.Logs { + printLog(invocation.Out, entry) + } + } + } + + if count == 0 { + return nil + } + + // The oldest of the newest offset logs is the first one not to print + boundary := int64(math.MaxInt64) + + if offset > 0 { + answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", offset) + + if err != nil { + return err + } + + if int64(len(answer.Logs)) < min(offset, 1000) { + return nil + } + + boundary = answer.Logs[0].Id + } + + answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", count+offset) + + if err != nil { + return err + } + + printed := int64(0) + + for { + for _, entry := range answer.Logs { + if printed == count || entry.Id >= boundary { + return nil + } + + printLog(invocation.Out, entry) + printed++ + } + + if printed == count || len(answer.Logs) < 1000 { + return nil + } + + answer, err = api.FetchLogs(invocation, fleetID, imeis, "after", answer.Next) + + if err != nil { + return err + } + } +} + +func printLog(out io.Writer, entry api.LogEntry) { + received := entry.ReceivedAt.Local().Format("2006-01-02T15:04:05-07:00") + name := "" + + if entry.Name != nil { + name = printableKeepingTabs(*entry.Name) + } + + for _, line := range strings.Split(entry.Text, "\n") { + fmt.Fprintf(out, "%s %s %s [%s] %s\n", received, entry.Imei, entry.Kind, name, printableKeepingTabs(line)) + } +} + +func printableKeepingTabs(text string) string { + segments := strings.Split(text, "\t") + + for index, segment := range segments { + segments[index] = api.Printable(segment) + } + + return strings.Join(segments, "\t") +} diff --git a/internal/tail/tail_test.go b/internal/tail/tail_test.go new file mode 100644 index 0000000..fa6a1a3 --- /dev/null +++ b/internal/tail/tail_test.go @@ -0,0 +1,292 @@ +package tail + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/siliconwitchery/superstack-cli/internal/api" + "github.com/siliconwitchery/superstack-cli/internal/api/apitest" +) + +var testReceivedAt = time.Date(2026, 10, 6, 13, 4, 5, 678000000, time.UTC) + +// Serves stored logs by the paging rules of GET /fleets/{id}/logs, and records each query it answers +type testLogServer struct { + mutex sync.Mutex + logs []api.LogEntry + queries []string + follow []api.LogsAnswer +} + +func (server *testLogServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + server.mutex.Lock() + defer server.mutex.Unlock() + + server.queries = append(server.queries, r.URL.RawQuery) + + if r.URL.Path != "/fleets/3/logs" { + http.Error(w, "no such fleet", http.StatusNotFound) + return + } + + query := r.URL.Query() + selected := []api.LogEntry{} + + for _, entry := range server.logs { + if !query.Has("imei") || slices.Contains(query["imei"], entry.Imei) { + selected = append(selected, entry) + } + } + + answer := api.LogsAnswer{Logs: []api.LogEntry{}} + + if query.Has("last") { + last, _ := strconv.ParseInt(query.Get("last"), 10, 64) + window := selected[len(selected)-int(min(last, int64(len(selected)))):] + answer.Logs = window[:min(len(window), 1000)] + + if len(selected) > 0 { + answer.Next = selected[len(selected)-1].Id + } + } else { + if len(server.follow) > 0 { + answer = server.follow[0] + server.follow = server.follow[1:] + } else if server.follow != nil { + http.Error(w, "the server is restarting", http.StatusServiceUnavailable) + return + } else { + after, _ := strconv.ParseInt(query.Get("after"), 10, 64) + answer.Next = after + + for _, entry := range selected { + if entry.Id > after && len(answer.Logs) < 1000 { + answer.Logs = append(answer.Logs, entry) + } + } + } + } + + if len(answer.Logs) > 0 { + answer.Next = answer.Logs[len(answer.Logs)-1].Id + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(answer) +} + +func testLogs(count int) []api.LogEntry { + logs := []api.LogEntry{} + name := "boiler" + + for id := int64(1); id <= int64(count); id++ { + imei := "111111111111111" + + if id%2 == 0 { + imei = "222222222222222" + } + + logs = append(logs, api.LogEntry{ + Id: id, Imei: imei, Name: &name, Kind: "print", Text: "log " + strconv.FormatInt(id, 10), ReceivedAt: testReceivedAt, + }) + } + + return logs +} + +func printedTexts(output string) []string { + texts := []string{} + + for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") { + if line == "" { + continue + } + + _, text, _ := strings.Cut(line, "] ") + texts = append(texts, text) + } + + return texts +} + +func TestTailRefusesArguments(t *testing.T) { + tests := []struct { + arguments []string + wantError string + }{ + {[]string{}, "takes a fleet id"}, + {[]string{"pilot"}, "number shown by fleet list"}, + {[]string{"0"}, "number shown by fleet list"}, + {[]string{"3", "12345"}, "12345 is not an IMEI"}, + {[]string{"3", "-n"}, "-n needs a number"}, + {[]string{"3", "-n", "-1"}, "-n takes a whole number"}, + {[]string{"3", "-n", "ten"}, "-n takes a whole number"}, + {[]string{"3", "--offset", "5"}, "it needs -n"}, + {[]string{"3", "-n", "9223372036854775807", "--offset", "1"}, "more logs than can be counted"}, + } + + for _, test := range tests { + t.Run(strings.Join(test.arguments, " "), func(t *testing.T) { + server := &testLogServer{} + invocation, _ := apitest.LoggedInInvocation(t, server) + + err := Tail(invocation, test.arguments) + + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want it to contain %q", err, test.wantError) + } + + if len(server.queries) != 0 { + t.Fatalf("asked the server %v before refusing", server.queries) + } + }) + } +} + +func TestTailPrintsTheNewestLogs(t *testing.T) { + tests := []struct { + name string + arguments []string + stored int + wantTexts []string + wantQueries []string + }{ + {"the newest two", []string{"3", "-n", "2"}, 5, []string{"log 4", "log 5"}, []string{"last=2"}}, + {"more than are stored", []string{"3", "-n", "10"}, 3, []string{"log 1", "log 2", "log 3"}, []string{"last=10"}}, + {"none asked", []string{"3", "-n", "0"}, 5, []string{}, nil}, + {"none stored", []string{"3", "-n", "5"}, 0, []string{}, []string{"last=5"}}, + {"one device", []string{"3", "111111111111111", "-n", "2"}, 5, []string{"log 3", "log 5"}, []string{"imei=111111111111111&last=2"}}, + {"two devices", []string{"3", "111111111111111", "222222222222222", "-n", "1"}, 5, []string{"log 5"}, []string{"imei=111111111111111&imei=222222222222222&last=1"}}, + {"skipping the newest", []string{"3", "-n", "2", "--offset", "1"}, 5, []string{"log 3", "log 4"}, []string{"last=1", "last=3"}}, + {"a page that runs out", []string{"3", "-n", "2", "--offset", "4"}, 5, []string{"log 1"}, []string{"last=4", "last=6"}}, + {"skipping every log", []string{"3", "-n", "2", "--offset", "5"}, 5, []string{}, []string{"last=5", "last=7"}}, + {"skipping more than are stored", []string{"3", "-n", "2", "--offset", "9"}, 5, []string{}, []string{"last=9"}}, + {"flags before the fleet", []string{"-n", "1", "3"}, 5, []string{"log 5"}, []string{"last=1"}}, + {"paging past one answer", []string{"3", "-n", "1200"}, 2500, nil, []string{"last=1200", "after=2300"}}, + {"paging past one answer with an offset", []string{"3", "-n", "1200", "--offset", "100"}, 2500, nil, []string{"last=100", "last=1300", "after=2200"}}, + {"stopping at an answer short of one thousand", []string{"3", "-n", "5000"}, 1500, nil, []string{"last=5000", "after=1000"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := &testLogServer{logs: testLogs(test.stored)} + invocation, out := apitest.LoggedInInvocation(t, server) + + err := Tail(invocation, test.arguments) + + if err != nil { + t.Fatal(err) + } + + texts := printedTexts(out.String()) + + if test.wantTexts != nil && !slices.Equal(texts, test.wantTexts) { + t.Errorf("printed %q, want %q", texts, test.wantTexts) + } + + if !slices.Equal(server.queries, test.wantQueries) { + t.Errorf("asked %q, want %q", server.queries, test.wantQueries) + } + }) + } + + server := &testLogServer{logs: testLogs(2500)} + invocation, out := apitest.LoggedInInvocation(t, server) + + err := Tail(invocation, []string{"3", "-n", "1200", "--offset", "100"}) + + if err != nil { + t.Fatal(err) + } + + texts := printedTexts(out.String()) + + if len(texts) != 1200 || texts[0] != "log 1201" || texts[1199] != "log 2400" { + t.Fatalf("printed %d logs from %q to %q, want 1200 from log 1201 to log 2400", + len(texts), texts[0], texts[len(texts)-1]) + } +} + +func TestTailPrintsEachLineSafely(t *testing.T) { + name := "roof \x1b[2Junit" + server := &testLogServer{logs: []api.LogEntry{ + {Id: 1, Imei: "111111111111111", Name: nil, Kind: "state", Text: "Code started", ReceivedAt: testReceivedAt}, + {Id: 2, Imei: "222222222222222", Name: &name, Kind: "print", Text: "21.5\tok\nsecond\x1b]0;title\a‮line", ReceivedAt: testReceivedAt}, + }} + invocation, out := apitest.LoggedInInvocation(t, server) + + err := Tail(invocation, []string{"3", "-n", "2"}) + + if err != nil { + t.Fatal(err) + } + + received := testReceivedAt.Local().Format("2006-01-02T15:04:05-07:00") + want := fmt.Sprintf("%[1]s 111111111111111 state [] Code started\n"+ + "%[1]s 222222222222222 print [roof \\x1b[2Junit] 21.5\tok\n"+ + "%[1]s 222222222222222 print [roof \\x1b[2Junit] second\\x1b]0;title\\a\\u202eline\n", received) + + if out.String() != want { + t.Fatalf("printed\n%q\nwant\n%q", out.String(), want) + } + + if strings.Contains(received, " ") || strings.Contains(received, ".") { + t.Fatalf("received time %q holds a space or fractions of a second", received) + } +} + +func TestTailFollows(t *testing.T) { + logs := testLogs(7) + server := &testLogServer{ + logs: logs[:5], + follow: []api.LogsAnswer{ + {Logs: logs[5:6]}, + {Logs: []api.LogEntry{}, Next: 6}, + {Logs: logs[6:7]}, + }, + } + invocation, out := apitest.LoggedInInvocation(t, server) + + err := Tail(invocation, []string{"3", "222222222222222"}) + + if err == nil || !strings.Contains(err.Error(), "the server is restarting") { + t.Fatalf("error = %v, want the server's refusal", err) + } + + texts := printedTexts(out.String()) + + if !slices.Equal(texts, []string{"log 6", "log 7"}) { + t.Errorf("printed %q, want only the logs that arrived while following", texts) + } + + wantQueries := []string{ + "imei=222222222222222&last=0", + "after=4&imei=222222222222222", + "after=6&imei=222222222222222", + "after=6&imei=222222222222222", + "after=7&imei=222222222222222", + } + + if !slices.Equal(server.queries, wantQueries) { + t.Errorf("asked %q, want %q", server.queries, wantQueries) + } +} + +func TestTailReportsTheServersRefusal(t *testing.T) { + invocation, _ := apitest.LoggedInInvocation(t, &testLogServer{}) + + for _, arguments := range [][]string{{"9"}, {"9", "-n", "1"}} { + err := Tail(invocation, arguments) + + if err == nil || err.Error() != "no such fleet" { + t.Errorf("tail %v error = %v, want the server's refusal", arguments, err) + } + } +} diff --git a/main.go b/main.go index 166e2cd..610d52d 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "github.com/siliconwitchery/superstack-cli/internal/fleetkey" "github.com/siliconwitchery/superstack-cli/internal/login" "github.com/siliconwitchery/superstack-cli/internal/member" + "github.com/siliconwitchery/superstack-cli/internal/tail" ) const version = "0.0.4" @@ -57,7 +58,7 @@ var sections = []dispatch.Section{ { Title: "Logs", Commands: []dispatch.Command{ - {Name: "tail", Arguments: " [-n num] [--log-file ]", Summary: "Stream a device or fleet's log as it arrives"}, + {Name: "tail", Arguments: " [imei ...] [-n num [--offset num]]", Summary: "Follow a fleet's log as it arrives, or print its newest logs with -n", Run: tail.Tail}, }, }, { diff --git a/main_test.go b/main_test.go index ac95ef7..b744b95 100644 --- a/main_test.go +++ b/main_test.go @@ -43,8 +43,7 @@ func TestCommandTable(t *testing.T) { func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { plannedCommands := map[string]bool{ - "dev": true, - "tail": true, + "dev": true, } answeredByDispatch := map[string]bool{ "version": true, @@ -98,6 +97,7 @@ func TestNoPartImportsAnother(t *testing.T) { "fleetkey": {"api"}, "login": {"api"}, "member": {"api"}, + "tail": {"api"}, } walk := func(path string, entry fs.DirEntry, err error) error { @@ -163,6 +163,7 @@ func TestTheTableWiresEveryCommandOffered(t *testing.T) { "key create", "key list", "key revoke", "login", "logout", "member add", "member list", "member remove", + "tail", "upload", } @@ -227,7 +228,7 @@ func TestMainReportsFailureWithANonZeroExit(t *testing.T) { {name: "no arguments", arguments: " ", wantCode: 0, wantSays: "Usage: superstack"}, {name: "the version", arguments: "version", wantCode: 0}, {name: "an unknown command", arguments: "nonsense", wantCode: 1, wantSays: "unknown command"}, - {name: "a command nobody has built yet", arguments: "tail 111111111111111", wantCode: 1, wantSays: "not available yet"}, + {name: "a command nobody has built yet", arguments: "dev 111111111111111 main.lua", wantCode: 1, wantSays: "not available yet"}, {name: "a command that needs a login", arguments: "fleet list", wantCode: 1, wantSays: "not logged in"}, {name: "a flag with no value", arguments: "--server", wantCode: 1, wantSays: "needs an address"}, } From a891f96d46682f289387eaf626c25366efb7c4a8 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 7 Oct 2026 11:38:01 +0200 Subject: [PATCH 2/3] Print a tail window with -n and -o in one request Plain tail prints the newest 100 logs before following, and retries from the same cursor while the server is unavailable. -o replaces --offset and sends offset with last, so a window never pages. Later lines of a log are indented under the first. --- internal/api/logs.go | 23 +++--- internal/tail/tail.go | 120 +++++++++++++++---------------- internal/tail/tail_test.go | 144 ++++++++++++++++++------------------- main.go | 2 +- 4 files changed, 145 insertions(+), 144 deletions(-) diff --git a/internal/api/logs.go b/internal/api/logs.go index e6c3c8c..257c913 100644 --- a/internal/api/logs.go +++ b/internal/api/logs.go @@ -1,7 +1,6 @@ package api import ( - "errors" "net/http" "net/url" "strconv" @@ -22,14 +21,17 @@ type LogsAnswer struct { Next int64 `json:"next"` } -// position is last, for the newest logs, or after, for the logs newer than a cursor -func FetchLogs(invocation Invocation, fleetID int64, imeis []string, position string, value int64) (LogsAnswer, error) { - query := url.Values{position: {strconv.FormatInt(value, 10)}} +// A request that failed because the server could not be reached or is not available, so a follower may try again +type Unavailable struct { + Reason string +} - for _, imei := range imeis { - query.Add("imei", imei) - } +func (unavailable Unavailable) Error() string { + return unavailable.Reason +} +// query holds last and optionally offset, or after, and any imei values that narrow the fleet +func FetchLogs(invocation Invocation, fleetID int64, query url.Values) (LogsAnswer, error) { request, err := AuthenticatedRequest(invocation, http.MethodGet, "/fleets/"+strconv.FormatInt(fleetID, 10)+"/logs?"+query.Encode(), nil) @@ -40,11 +42,16 @@ func FetchLogs(invocation Invocation, fleetID int64, imeis []string, position st response, err := invocation.Client.Do(request) if err != nil { - return LogsAnswer{}, errors.New("the server could not be reached, check your internet access") + return LogsAnswer{}, Unavailable{"the server could not be reached, check your internet access"} } defer response.Body.Close() + if response.StatusCode == http.StatusBadGateway || response.StatusCode == http.StatusServiceUnavailable || + response.StatusCode == http.StatusGatewayTimeout { + return LogsAnswer{}, Unavailable{ServerError(response).Error()} + } + if response.StatusCode != http.StatusOK { return LogsAnswer{}, ServerError(response) } diff --git a/internal/tail/tail.go b/internal/tail/tail.go index 701f90e..57b5076 100644 --- a/internal/tail/tail.go +++ b/internal/tail/tail.go @@ -4,22 +4,23 @@ import ( "errors" "fmt" "io" - "math" + "net/url" "strconv" "strings" + "time" + "unicode/utf8" "github.com/siliconwitchery/superstack-cli/internal/api" ) func Tail(invocation api.Invocation, arguments []string) error { positionals := []string{} - count := int64(-1) + count := int64(0) offset := int64(0) - offsetGiven := false for index := 0; index < len(arguments); index++ { switch arguments[index] { - case "-n", "--offset": + case "-n", "-o": flag := arguments[index] if index+1 == len(arguments) { @@ -30,15 +31,18 @@ func Tail(invocation api.Invocation, arguments []string) error { value, err := strconv.ParseInt(arguments[index], 10, 64) - if err != nil || value < 0 { - return fmt.Errorf("%s takes a whole number of logs", flag) - } - if flag == "-n" { + if err != nil || value < 1 || value > 1000 { + return errors.New("-n takes a number of logs from 1 to 1000") + } + count = value } else { + if err != nil || value < 1 { + return errors.New("-o takes how many of the newest logs to skip, 1 or more") + } + offset = value - offsetGiven = true } default: @@ -56,104 +60,96 @@ func Tail(invocation api.Invocation, arguments []string) error { return errors.New("the fleet id is the number shown by fleet list") } - imeis := positionals[1:] + query := url.Values{} - for _, imei := range imeis { + for _, imei := range positionals[1:] { if len(imei) != 15 || strings.ContainsFunc(imei, func(digit rune) bool { return digit < '0' || digit > '9' }) { return fmt.Errorf("%s is not an IMEI, which is the 15-digit number printed on the device", api.Printable(imei)) } - } - if offsetGiven && count < 0 { - return errors.New("--offset skips the newest logs before -n prints, so it needs -n") + query.Add("imei", imei) } - if count > math.MaxInt64-offset { - return errors.New("-n and --offset together ask for more logs than can be counted") + if offset > 0 && count == 0 { + return errors.New("-o sits the window back from the newest logs, so it needs -n") } - // Follow - if count < 0 { - answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", 0) + // A window of past logs + if count > 0 { + query.Set("last", strconv.FormatInt(count, 10)) - if err != nil { - return err + if offset > 0 { + query.Set("offset", strconv.FormatInt(offset, 10)) } - for { - answer, err = api.FetchLogs(invocation, fleetID, imeis, "after", answer.Next) - - if err != nil { - return err - } - - for _, entry := range answer.Logs { - printLog(invocation.Out, entry) - } - } - } - - if count == 0 { - return nil - } - - // The oldest of the newest offset logs is the first one not to print - boundary := int64(math.MaxInt64) - - if offset > 0 { - answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", offset) + answer, err := api.FetchLogs(invocation, fleetID, query) if err != nil { return err } - if int64(len(answer.Logs)) < min(offset, 1000) { - return nil + for _, entry := range answer.Logs { + printLog(invocation.Out, entry) } - boundary = answer.Logs[0].Id + return nil } - answer, err := api.FetchLogs(invocation, fleetID, imeis, "last", count+offset) + // The newest logs, then follow + query.Set("last", "100") + + answer, err := api.FetchLogs(invocation, fleetID, query) if err != nil { return err } - printed := int64(0) + for _, entry := range answer.Logs { + printLog(invocation.Out, entry) + } + + query.Del("last") + cursor := answer.Next for { - for _, entry := range answer.Logs { - if printed == count || entry.Id >= boundary { - return nil - } + query.Set("after", strconv.FormatInt(cursor, 10)) - printLog(invocation.Out, entry) - printed++ - } + answer, err = api.FetchLogs(invocation, fleetID, query) - if printed == count || len(answer.Logs) < 1000 { - return nil - } + var unavailable api.Unavailable - answer, err = api.FetchLogs(invocation, fleetID, imeis, "after", answer.Next) + if errors.As(err, &unavailable) { + time.Sleep(time.Second) + continue + } if err != nil { return err } + + for _, entry := range answer.Logs { + printLog(invocation.Out, entry) + } + + cursor = answer.Next } } func printLog(out io.Writer, entry api.LogEntry) { - received := entry.ReceivedAt.Local().Format("2006-01-02T15:04:05-07:00") name := "" if entry.Name != nil { name = printableKeepingTabs(*entry.Name) } - for _, line := range strings.Split(entry.Text, "\n") { - fmt.Fprintf(out, "%s %s %s [%s] %s\n", received, entry.Imei, entry.Kind, name, printableKeepingTabs(line)) + fields := fmt.Sprintf("%s %s %s [%s] ", + entry.ReceivedAt.Local().Format("2006-01-02T15:04:05-07:00"), entry.Imei, entry.Kind, name) + lines := strings.Split(entry.Text, "\n") + + fmt.Fprintf(out, "%s%s\n", fields, printableKeepingTabs(lines[0])) + + for _, line := range lines[1:] { + fmt.Fprintf(out, "%s%s\n", strings.Repeat(" ", utf8.RuneCountInString(fields)), printableKeepingTabs(line)) } } diff --git a/internal/tail/tail_test.go b/internal/tail/tail_test.go index fa6a1a3..a00ea43 100644 --- a/internal/tail/tail_test.go +++ b/internal/tail/tail_test.go @@ -2,7 +2,6 @@ package tail import ( "encoding/json" - "fmt" "net/http" "slices" "strconv" @@ -17,12 +16,14 @@ import ( var testReceivedAt = time.Date(2026, 10, 6, 13, 4, 5, 678000000, time.UTC) -// Serves stored logs by the paging rules of GET /fleets/{id}/logs, and records each query it answers +// Serves stored logs by the rules of GET /fleets/{id}/logs, and records each query it answers. Each after +// request takes the next follow answer, a nil answer being a refusal because the server is unavailable, and +// one past the end is refused as no such fleet type testLogServer struct { mutex sync.Mutex logs []api.LogEntry queries []string - follow []api.LogsAnswer + follow []*api.LogsAnswer } func (server *testLogServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -49,29 +50,34 @@ func (server *testLogServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if query.Has("last") { last, _ := strconv.ParseInt(query.Get("last"), 10, 64) - window := selected[len(selected)-int(min(last, int64(len(selected)))):] - answer.Logs = window[:min(len(window), 1000)] + offset, _ := strconv.ParseInt(query.Get("offset"), 10, 64) + + if last < 1 || last > 1000 || offset < 0 { + http.Error(w, "the number of logs is 1 to 1000", http.StatusBadRequest) + return + } + + end := max(int64(len(selected))-offset, 0) + answer.Logs = selected[max(end-last, 0):end] if len(selected) > 0 { answer.Next = selected[len(selected)-1].Id } } else { - if len(server.follow) > 0 { - answer = server.follow[0] - server.follow = server.follow[1:] - } else if server.follow != nil { + if len(server.follow) == 0 { + http.Error(w, "no such fleet", http.StatusNotFound) + return + } + + next := server.follow[0] + server.follow = server.follow[1:] + + if next == nil { http.Error(w, "the server is restarting", http.StatusServiceUnavailable) return - } else { - after, _ := strconv.ParseInt(query.Get("after"), 10, 64) - answer.Next = after - - for _, entry := range selected { - if entry.Id > after && len(answer.Logs) < 1000 { - answer.Logs = append(answer.Logs, entry) - } - } } + + answer = *next } if len(answer.Logs) > 0 { @@ -126,10 +132,13 @@ func TestTailRefusesArguments(t *testing.T) { {[]string{"0"}, "number shown by fleet list"}, {[]string{"3", "12345"}, "12345 is not an IMEI"}, {[]string{"3", "-n"}, "-n needs a number"}, - {[]string{"3", "-n", "-1"}, "-n takes a whole number"}, - {[]string{"3", "-n", "ten"}, "-n takes a whole number"}, - {[]string{"3", "--offset", "5"}, "it needs -n"}, - {[]string{"3", "-n", "9223372036854775807", "--offset", "1"}, "more logs than can be counted"}, + {[]string{"3", "-o"}, "-o needs a number"}, + {[]string{"3", "-n", "0"}, "-n takes a number of logs from 1 to 1000"}, + {[]string{"3", "-n", "1001"}, "-n takes a number of logs from 1 to 1000"}, + {[]string{"3", "-n", "ten"}, "-n takes a number of logs from 1 to 1000"}, + {[]string{"3", "-n", "1", "-o", "0"}, "-o takes how many of the newest logs to skip"}, + {[]string{"3", "-n", "1", "-o", "-1"}, "-o takes how many of the newest logs to skip"}, + {[]string{"3", "-o", "5"}, "it needs -n"}, } for _, test := range tests { @@ -150,28 +159,27 @@ func TestTailRefusesArguments(t *testing.T) { } } -func TestTailPrintsTheNewestLogs(t *testing.T) { +func TestTailPrintsAWindow(t *testing.T) { tests := []struct { - name string - arguments []string - stored int - wantTexts []string - wantQueries []string + name string + arguments []string + stored int + wantFirst string + wantLast string + wantCount int + wantQuery string }{ - {"the newest two", []string{"3", "-n", "2"}, 5, []string{"log 4", "log 5"}, []string{"last=2"}}, - {"more than are stored", []string{"3", "-n", "10"}, 3, []string{"log 1", "log 2", "log 3"}, []string{"last=10"}}, - {"none asked", []string{"3", "-n", "0"}, 5, []string{}, nil}, - {"none stored", []string{"3", "-n", "5"}, 0, []string{}, []string{"last=5"}}, - {"one device", []string{"3", "111111111111111", "-n", "2"}, 5, []string{"log 3", "log 5"}, []string{"imei=111111111111111&last=2"}}, - {"two devices", []string{"3", "111111111111111", "222222222222222", "-n", "1"}, 5, []string{"log 5"}, []string{"imei=111111111111111&imei=222222222222222&last=1"}}, - {"skipping the newest", []string{"3", "-n", "2", "--offset", "1"}, 5, []string{"log 3", "log 4"}, []string{"last=1", "last=3"}}, - {"a page that runs out", []string{"3", "-n", "2", "--offset", "4"}, 5, []string{"log 1"}, []string{"last=4", "last=6"}}, - {"skipping every log", []string{"3", "-n", "2", "--offset", "5"}, 5, []string{}, []string{"last=5", "last=7"}}, - {"skipping more than are stored", []string{"3", "-n", "2", "--offset", "9"}, 5, []string{}, []string{"last=9"}}, - {"flags before the fleet", []string{"-n", "1", "3"}, 5, []string{"log 5"}, []string{"last=1"}}, - {"paging past one answer", []string{"3", "-n", "1200"}, 2500, nil, []string{"last=1200", "after=2300"}}, - {"paging past one answer with an offset", []string{"3", "-n", "1200", "--offset", "100"}, 2500, nil, []string{"last=100", "last=1300", "after=2200"}}, - {"stopping at an answer short of one thousand", []string{"3", "-n", "5000"}, 1500, nil, []string{"last=5000", "after=1000"}}, + {"the newest two", []string{"3", "-n", "2"}, 5, "log 4", "log 5", 2, "last=2"}, + {"more than are stored", []string{"3", "-n", "10"}, 3, "log 1", "log 3", 3, "last=10"}, + {"none stored", []string{"3", "-n", "5"}, 0, "", "", 0, "last=5"}, + {"one device", []string{"3", "111111111111111", "-n", "2"}, 5, "log 3", "log 5", 2, "imei=111111111111111&last=2"}, + {"two devices", []string{"3", "111111111111111", "222222222222222", "-n", "1"}, 5, "log 5", "log 5", 1, "imei=111111111111111&imei=222222222222222&last=1"}, + {"sitting back from the newest", []string{"3", "-n", "2", "-o", "1"}, 5, "log 3", "log 4", 2, "last=2&offset=1"}, + {"a window that runs out", []string{"3", "-n", "2", "-o", "4"}, 5, "log 1", "log 1", 1, "last=2&offset=4"}, + {"a window past every log", []string{"3", "-n", "2", "-o", "9"}, 5, "", "", 0, "last=2&offset=9"}, + {"flags before the fleet", []string{"-n", "1", "3"}, 5, "log 5", "log 5", 1, "last=1"}, + {"the largest window", []string{"3", "-n", "1000"}, 2500, "log 1501", "log 2500", 1000, "last=1000"}, + {"the largest window sitting back", []string{"3", "-n", "1000", "-o", "100"}, 2500, "log 1401", "log 2400", 1000, "last=1000&offset=100"}, } for _, test := range tests { @@ -187,38 +195,22 @@ func TestTailPrintsTheNewestLogs(t *testing.T) { texts := printedTexts(out.String()) - if test.wantTexts != nil && !slices.Equal(texts, test.wantTexts) { - t.Errorf("printed %q, want %q", texts, test.wantTexts) + if len(texts) != test.wantCount || (test.wantCount > 0 && (texts[0] != test.wantFirst || texts[len(texts)-1] != test.wantLast)) { + t.Errorf("printed %d logs %q, want %d from %q to %q", len(texts), texts, test.wantCount, test.wantFirst, test.wantLast) } - if !slices.Equal(server.queries, test.wantQueries) { - t.Errorf("asked %q, want %q", server.queries, test.wantQueries) + if !slices.Equal(server.queries, []string{test.wantQuery}) { + t.Errorf("asked %q, want only %q", server.queries, test.wantQuery) } }) } - - server := &testLogServer{logs: testLogs(2500)} - invocation, out := apitest.LoggedInInvocation(t, server) - - err := Tail(invocation, []string{"3", "-n", "1200", "--offset", "100"}) - - if err != nil { - t.Fatal(err) - } - - texts := printedTexts(out.String()) - - if len(texts) != 1200 || texts[0] != "log 1201" || texts[1199] != "log 2400" { - t.Fatalf("printed %d logs from %q to %q, want 1200 from log 1201 to log 2400", - len(texts), texts[0], texts[len(texts)-1]) - } } -func TestTailPrintsEachLineSafely(t *testing.T) { +func TestTailPrintsEachLogSafely(t *testing.T) { name := "roof \x1b[2Junit" server := &testLogServer{logs: []api.LogEntry{ {Id: 1, Imei: "111111111111111", Name: nil, Kind: "state", Text: "Code started", ReceivedAt: testReceivedAt}, - {Id: 2, Imei: "222222222222222", Name: &name, Kind: "print", Text: "21.5\tok\nsecond\x1b]0;title\a‮line", ReceivedAt: testReceivedAt}, + {Id: 2, Imei: "222222222222222", Name: &name, Kind: "print", Text: "21.5\tok\nsecond\x1b]0;title\a‮line\n\nlast", ReceivedAt: testReceivedAt}, }} invocation, out := apitest.LoggedInInvocation(t, server) @@ -229,9 +221,13 @@ func TestTailPrintsEachLineSafely(t *testing.T) { } received := testReceivedAt.Local().Format("2006-01-02T15:04:05-07:00") - want := fmt.Sprintf("%[1]s 111111111111111 state [] Code started\n"+ - "%[1]s 222222222222222 print [roof \\x1b[2Junit] 21.5\tok\n"+ - "%[1]s 222222222222222 print [roof \\x1b[2Junit] second\\x1b]0;title\\a\\u202eline\n", received) + fields := received + " 222222222222222 print [roof \\x1b[2Junit] " + indent := strings.Repeat(" ", len(fields)) + want := received + " 111111111111111 state [] Code started\n" + + fields + "21.5\tok\n" + + indent + "second\\x1b]0;title\\a\\u202eline\n" + + indent + "\n" + + indent + "last\n" if out.String() != want { t.Fatalf("printed\n%q\nwant\n%q", out.String(), want) @@ -246,8 +242,9 @@ func TestTailFollows(t *testing.T) { logs := testLogs(7) server := &testLogServer{ logs: logs[:5], - follow: []api.LogsAnswer{ + follow: []*api.LogsAnswer{ {Logs: logs[5:6]}, + nil, {Logs: []api.LogEntry{}, Next: 6}, {Logs: logs[6:7]}, }, @@ -256,21 +253,22 @@ func TestTailFollows(t *testing.T) { err := Tail(invocation, []string{"3", "222222222222222"}) - if err == nil || !strings.Contains(err.Error(), "the server is restarting") { - t.Fatalf("error = %v, want the server's refusal", err) + if err == nil || err.Error() != "no such fleet" { + t.Fatalf("error = %v, want the server's refusal that ends the follow", err) } texts := printedTexts(out.String()) - if !slices.Equal(texts, []string{"log 6", "log 7"}) { - t.Errorf("printed %q, want only the logs that arrived while following", texts) + if !slices.Equal(texts, []string{"log 2", "log 4", "log 6", "log 7"}) { + t.Errorf("printed %q, want the newest logs then the ones that arrived while following", texts) } wantQueries := []string{ - "imei=222222222222222&last=0", + "imei=222222222222222&last=100", "after=4&imei=222222222222222", "after=6&imei=222222222222222", "after=6&imei=222222222222222", + "after=6&imei=222222222222222", "after=7&imei=222222222222222", } diff --git a/main.go b/main.go index 610d52d..33e78b1 100644 --- a/main.go +++ b/main.go @@ -58,7 +58,7 @@ var sections = []dispatch.Section{ { Title: "Logs", Commands: []dispatch.Command{ - {Name: "tail", Arguments: " [imei ...] [-n num [--offset num]]", Summary: "Follow a fleet's log as it arrives, or print its newest logs with -n", Run: tail.Tail}, + {Name: "tail", Arguments: " [imei ...] [-n num [-o num]]", Summary: "Follow a fleet's log as it arrives, or print a window of its past logs with -n", Run: tail.Tail}, }, }, { From c733b665f46b231b7e34ee25c4727361bc5d1aa5 Mon Sep 17 00:00:00 2001 From: Raj Nakarja Date: Wed, 7 Oct 2026 15:16:03 +0200 Subject: [PATCH 3/3] Print quotes and backslashes in tail logs as they are --- internal/tail/tail.go | 17 +++++++++++++---- internal/tail/tail_test.go | 3 ++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/tail/tail.go b/internal/tail/tail.go index 57b5076..b3de5fd 100644 --- a/internal/tail/tail.go +++ b/internal/tail/tail.go @@ -154,11 +154,20 @@ func printLog(out io.Writer, entry api.LogEntry) { } func printableKeepingTabs(text string) string { - segments := strings.Split(text, "\t") + printable := strings.Builder{} - for index, segment := range segments { - segments[index] = api.Printable(segment) + for index := 0; index < len(text); { + character, size := utf8.DecodeRuneInString(text[index:]) + encoded := text[index : index+size] + index += size + + if character == '\t' || (strconv.IsGraphic(character) && !(character == utf8.RuneError && size == 1)) { + printable.WriteString(encoded) + continue + } + + printable.WriteString(api.Printable(encoded)) } - return strings.Join(segments, "\t") + return printable.String() } diff --git a/internal/tail/tail_test.go b/internal/tail/tail_test.go index a00ea43..37d47cd 100644 --- a/internal/tail/tail_test.go +++ b/internal/tail/tail_test.go @@ -210,7 +210,7 @@ func TestTailPrintsEachLogSafely(t *testing.T) { name := "roof \x1b[2Junit" server := &testLogServer{logs: []api.LogEntry{ {Id: 1, Imei: "111111111111111", Name: nil, Kind: "state", Text: "Code started", ReceivedAt: testReceivedAt}, - {Id: 2, Imei: "222222222222222", Name: &name, Kind: "print", Text: "21.5\tok\nsecond\x1b]0;title\a‮line\n\nlast", ReceivedAt: testReceivedAt}, + {Id: 2, Imei: "222222222222222", Name: &name, Kind: "print", Text: "21.5\tok\nsecond\x1b]0;title\a‮line\n\nsaid \"hi\" in C:\\temp\nlast", ReceivedAt: testReceivedAt}, }} invocation, out := apitest.LoggedInInvocation(t, server) @@ -227,6 +227,7 @@ func TestTailPrintsEachLogSafely(t *testing.T) { fields + "21.5\tok\n" + indent + "second\\x1b]0;title\\a\\u202eline\n" + indent + "\n" + + indent + "said \"hi\" in C:\\temp\n" + indent + "last\n" if out.String() != want {