Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
68 changes: 68 additions & 0 deletions internal/api/logs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package api

import (
"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"`
}

// 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
}

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)

if err != nil {
return LogsAnswer{}, err
}

response, err := invocation.Client.Do(request)

if err != nil {
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)
}

answer := LogsAnswer{}

err = Decode(response, &answer)

if err != nil {
return LogsAnswer{}, err
}

return answer, nil
}
173 changes: 173 additions & 0 deletions internal/tail/tail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package tail

import (
"errors"
"fmt"
"io"
"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(0)
offset := int64(0)

for index := 0; index < len(arguments); index++ {
switch arguments[index] {
case "-n", "-o":
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 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
}

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")
}

query := url.Values{}

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))
}

query.Add("imei", imei)
}

if offset > 0 && count == 0 {
return errors.New("-o sits the window back from the newest logs, so it needs -n")
}

// A window of past logs
if count > 0 {
query.Set("last", strconv.FormatInt(count, 10))

if offset > 0 {
query.Set("offset", strconv.FormatInt(offset, 10))
}

answer, err := api.FetchLogs(invocation, fleetID, query)

if err != nil {
return err
}

for _, entry := range answer.Logs {
printLog(invocation.Out, entry)
}

return nil
}

// The newest logs, then follow
query.Set("last", "100")

answer, err := api.FetchLogs(invocation, fleetID, query)

if err != nil {
return err
}

for _, entry := range answer.Logs {
printLog(invocation.Out, entry)
}

query.Del("last")
cursor := answer.Next

for {
query.Set("after", strconv.FormatInt(cursor, 10))

answer, err = api.FetchLogs(invocation, fleetID, query)

var unavailable api.Unavailable

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) {
name := ""

if entry.Name != nil {
name = printableKeepingTabs(*entry.Name)
}

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))
}
}

func printableKeepingTabs(text string) string {
printable := strings.Builder{}

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 printable.String()
}
Loading
Loading