diff --git a/README.md b/README.md index bcaac5e..d321742 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ One binary, no config to write, no browser. tasks that restart or run out of memory, clients that are down or draining, blocked evaluations and failed deployments, and opens the list of each with one key. -- Shows task logs and the files of an allocation, and follows them as they - grow. Shows the log of a task from every allocation of a job at once. +- Shows task logs and the files of an allocation, follows them as they grow, + and reads a log further back than its last 64 KiB. Shows the log of a task + from every allocation of a job at once. - Edits a job in your editor and shows the plan before it submits anything. - Shows the values of variables, hidden until you ask for them, and who holds a variable as a lock, and releases a lock left held. Creates, edits and diff --git a/docs/allocations.md b/docs/allocations.md index 9c059ea..4abf8d5 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -109,6 +109,12 @@ a task that has finished, it reads the log to the end and stops. When a task crashes and is placed again, the new allocation starts with an empty log: `p` opens the same log in the allocation it replaced. +`e` reads the 1 MiB before what is shown and puts it above, keeping the window +where it is. Press it again to go further back, as far as the cluster keeps +the log. The title then says how much of the log is read, for example `last +1.1 MiB of 5.3 MiB`. It stops at the 50,000 lines a screen keeps. It needs a +token that can read the files of the allocation, as `b` does. + A line under the title shows the state of three toggles: - **Autoscroll**: follow the end of the log. `s` turns it on or off. @@ -116,11 +122,13 @@ A line under the title shows the state of three toggles: - **Timestamps**: show when urga received each line. Nomad does not store a time for log lines, so this is the only time there is. `t` turns it on or off. -- **Wrap**: wrap long lines. `w` turns it on or off. +- **Wrap**: wrap long lines. `w` turns it on or off. With wrap off, `left` and + `right` (`h`, `l`) move sideways, and the line adds from which column the + log shows, for example `Column:41`. -`ctrl-e` switches between stdout and stderr. `ctrl-s` saves the log to a file -in the current directory, as the filter shows it. `c` copies it to the -clipboard. +`ctrl-e` switches between stdout and stderr. `ctrl-s` saves the log, as +the filter shows it, to a path you type: it starts with a name made of the +task, the output and the time. `c` copies it to the clipboard. `f` asks for a word and lights it in the log without hiding any line. `n` and `N` go to the next and the previous match. diff --git a/docs/keys.md b/docs/keys.md index a83137b..9b8cfcc 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -199,8 +199,8 @@ and highlights what matched. | `esc` | Go back to the previous screen, to the same row, filter and sort order. | | `up`, `k` / `down`, `j` | Move the cursor. | | `pgup`, `ctrl-b` / `pgdn`, `ctrl-f` | Move one page. | -| `g`, `home` / `G`, `end` | Go to the top or the bottom. | -| `left`, `right` | Choose a button in a dialog. | +| `g`, `home` / `G`, `end` | Go to the top or the bottom. On a text screen moved sideways, `home` first goes back to the start of the lines. | +| `left`, `h` / `right`, `l` | On a text screen with wrap off, move sideways by a quarter of the screen. In a dialog, and on a plan, choose a button. | | `q`, `ctrl-c` | Quit. | ## Marking rows @@ -267,7 +267,7 @@ On what was done to the count of a group, `d` describes its policy. | Key | What it does | Changes | | --- | --- | --- | -| `left`, `right`, `tab` | Choose Cancel or Submit | | +| `left`, `h`, `right`, `l`, `tab` | Choose Cancel or Submit | | | `enter` | Press the chosen button | | | `y` | Submit, or revert, at once, and watch the deploy of a job that runs itself | yes | | `r` | Plan again | | @@ -320,6 +320,7 @@ evaluation. | `w` | Wrap on or off | | `ctrl-e` | Switch between stdout and stderr | | `p` | Same log in the allocation this one replaced | +| `e` | Read 1 MiB further back and put it above the lines there. Shown while the cluster keeps more and the screen has room. | | `ctrl-s` | Save the log to a file | | `c` | Copy the log to the clipboard, the last 64 KiB at most | | `f` | Find a word: every line stays and the word is lit | @@ -555,6 +556,13 @@ On every text screen, logs, files and plans included, `c` copies up to 64 KiB: the last whole lines when the text is longer, so the start of a long log is left out. `ctrl-s` saves all of it. +`ctrl-s` asks for the path on the line at the top, with a name already typed, +made of what the text is and the time (for example +`server-stdout-20261019-101010.log`). `enter` keeps it. `~/` is your home +folder, a relative path is from the folder urga runs in, and missing folders +are made. urga asks before it overwrites a file, and the status line says +where the file was saved. + On every text screen, `f` asks for a word and keeps every line on the screen, lighting the word where it is. The window goes to the first match from its top, and the status line says which one it is. `n` and `N` go to the next and @@ -563,3 +571,11 @@ from a match, they start from what the window shows. An empty word drops the fin `esc` keeps the word as it was. The filter (`/`) still narrows the lines, and find looks within what it left. Like the filter, the word is a regular expression when it is one, and matches in any case. + +With wrap off, a long line is cut at the edge. `left` and `right`, or `h` and +`l`, move the window sideways by a quarter of its width; on a plan they choose +a button instead. On a log or a file, the line of toggles under the title says +from which column the window shows, for example `Column:41`. Other text +screens say it in the title, for example `from column 41`. `home` and turning +wrap on go back to the start of the lines. The filter and find light their +matches where they are. Saving and copying keep whole lines. diff --git a/internal/nomad/files.go b/internal/nomad/files.go index 9116480..411ed5a 100644 --- a/internal/nomad/files.go +++ b/internal/nomad/files.go @@ -98,7 +98,7 @@ func (c *Client) File(ctx context.Context, namespace, allocID, name string) (*Lo atEnd = func(frame *api.StreamFrame) bool { return frame.Offset >= info.Size } } - lines := streamLines(frames, func(*api.StreamFrame) bool { return from > 0 }, atEnd, cancel) + lines := streamLines(frames, func(*api.StreamFrame) bool { return from > 0 }, atEnd, nil, cancel) return &LogStream{Lines: lines, Err: failures(errs, cancel), Size: info.Size, From: from, cancel: cancel}, nil } diff --git a/internal/nomad/logback.go b/internal/nomad/logback.go new file mode 100644 index 0000000..b46ad48 --- /dev/null +++ b/internal/nomad/logback.go @@ -0,0 +1,263 @@ +package nomad + +import ( + "bytes" + "context" + "io" + "path" + "strconv" + "strings" + + "github.com/hashicorp/nomad/api" +) + +// logStep is how far back one read goes, as much as a big file opens with. +const logStep = 1 << 20 + +// logDir is the folder of an allocation its logs are kept in. +const logDir = "alloc/logs" + +// LogMark is where a byte of a log is: the file that holds it and its offset +// there. The cluster turns a log over into numbered files by size, so +// alloc/logs/server.stdout.3 is the fourth of them. +type LogMark struct { + File string + Offset int64 +} + +// AtStart says the mark is the first byte of the first file of a log. +func (m LogMark) AtStart() bool { + _, number, ok := splitLogFile(m.File) + + return ok && number == 0 && m.Offset == 0 +} + +// LogPart is a part of a log read from before a mark. From is where its +// first line begins, Before how much of the log the cluster keeps before +// that (0: nothing readable), and Size how big the log is. +type LogPart struct { + Lines []string + From LogMark + Before int64 + Size int64 +} + +// splitLogFile splits the name of a file of a log into what the files of that +// log share and the number of this one. +func splitLogFile(name string) (shared string, number int, ok bool) { + dot := strings.LastIndexByte(name, '.') + if dot < 0 { + return "", 0, false + } + + number, err := strconv.Atoi(name[dot+1:]) + + return name[:dot+1], number, err == nil && number >= 0 +} + +// logPiece is the bytes read from one file of a log, from an offset. +type logPiece struct { + file string + from int64 + data []byte +} + +// LogBefore reads the log of a task from the step before a mark, which is +// where a line begins, and gives back the last limit lines of it. A log +// grows while it is read from its end, so this reads the files by offset. +func (c *Client) LogBefore(ctx context.Context, namespace, allocID string, at LogMark, limit int) (LogPart, error) { + alloc := &api.Allocation{ID: allocID} + + // The client joins the path of a frame with its own separator, a + // backslash on Windows. + name := at.File[strings.LastIndexAny(at.File, `/\`)+1:] + + sizes, err := c.logSizes(ctx, namespace, alloc, name) + if err != nil { + return LogPart{}, err + } + + part := LogPart{From: at, Size: sumOf(sizes)} + + shared, number, _ := splitLogFile(path.Join(logDir, name)) + if _, listed := sizes[number]; !listed { + return part, nil + } + + pieces, err := c.readBack(ctx, namespace, alloc, shared, number, at.Offset, sizes) + if err != nil || len(pieces) == 0 { + return part, err + } + + return joined(pieces, sizes, limit, part), nil +} + +// logSizes are the sizes of the files of the log the file of that name +// belongs to, by their number. +func (c *Client) logSizes(ctx context.Context, namespace string, alloc *api.Allocation, name string) (map[int]int64, error) { + infos, _, err := c.api.AllocFS().List(alloc, logDir, c.query(ctx, namespace)) + if err != nil { + return nil, err + } + + shared, _, _ := splitLogFile(name) + sizes := map[int]int64{} + + for _, info := range infos { + if rest, ok := strings.CutPrefix(info.Name, shared); ok { + if number, err := strconv.Atoi(rest); err == nil { + sizes[number] = info.Size + } + } + } + + return sizes, nil +} + +// readBack reads up to logStep bytes back from offset in file number of the +// log whose files share a name, file by file down the numbers while the next +// one is listed. The pieces are in the order of the log. +func (c *Client) readBack(ctx context.Context, namespace string, alloc *api.Allocation, shared string, number int, offset int64, sizes map[int]int64) ([]logPiece, error) { + var pieces []logPiece + + for remaining := int64(logStep); ; { + take := min(remaining, offset) + + if take > 0 { + file := shared + strconv.Itoa(number) + + data, err := c.readAt(ctx, namespace, alloc, file, offset-take, take) + if err != nil { + return nil, err + } + + pieces = append([]logPiece{{file: file, from: offset - take, data: data}}, pieces...) + remaining -= take + } + + size, lower := sizes[number-1] + if remaining <= 0 || !lower { + return pieces, nil + } + + number, offset = number-1, size + } +} + +// readAt reads count bytes of a file from an offset. +func (c *Client) readAt(ctx context.Context, namespace string, alloc *api.Allocation, file string, offset, count int64) ([]byte, error) { + body, err := c.api.AllocFS().ReadAt(alloc, file, offset, count, c.query(ctx, namespace)) + if err != nil { + return nil, err + } + defer func() { _ = body.Close() }() + + return io.ReadAll(body) +} + +// joined makes the part out of what was read: its first line dropped when +// the read began inside a line, and only the last limit lines kept. part +// holds the mark it was asked for and the size of the log. +func joined(pieces []logPiece, sizes map[int]int64, limit int, part LogPart) LogPart { + var data []byte + for _, piece := range pieces { + data = append(data, piece.data...) + } + + begin := LogMark{File: pieces[0].file, Offset: pieces[0].from} + _, first, _ := splitLogFile(begin.File) + oldest := begin.Offset == 0 && !hasFile(sizes, first-1) + cut := cutLength(data, begin, oldest) + + // A read that reached the oldest file left cannot read the cut line any + // better later. + unreadable := 0 + if oldest { + unreadable = cut + } + + lines, skipped := lastLines(data[cut:], limit) + end := part.From + part.Lines = lines + part.From = markAt(pieces, cut+skipped, end) + + _, number, _ := splitLogFile(part.From.File) + part.Before = part.From.Offset - int64(unreadable) + + for other, size := range sizes { + if other < number { + part.Before += size + } + } + + return part +} + +// hasFile says the log has a file with the number. +func hasFile(sizes map[int]int64, number int) bool { + _, ok := sizes[number] + + return ok +} + +// cutLength is how many bytes of the start of a read are a line whose start +// was not read: none when it began where the log does, or has no line break +// before its last byte. A break at the last byte counts when the read reached +// the oldest file left, where it cannot have been a longer line. +func cutLength(data []byte, begin LogMark, oldest bool) int { + if begin.AtStart() || len(data) == 0 { + return 0 + } + + searched := data[:len(data)-1] + if oldest { + searched = data + } + + if at := bytes.IndexByte(searched, '\n'); at >= 0 { + return at + 1 + } + + return 0 +} + +// lastLines are the last limit lines of data, and how many bytes the lines +// left out take. +func lastLines(data []byte, limit int) (lines []string, skipped int) { + if len(data) == 0 { + return nil, 0 + } + + lines = strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + + for len(lines) > max(limit, 0) { + skipped += len(lines[0]) + 1 + lines = lines[1:] + } + + return lines, min(skipped, len(data)) +} + +// markAt is where the byte at index is, in the order of the pieces; end when +// it is past them. +func markAt(pieces []logPiece, index int, end LogMark) LogMark { + for _, piece := range pieces { + if index < len(piece.data) { + return LogMark{File: piece.file, Offset: piece.from + int64(index)} + } + + index -= len(piece.data) + } + + return end +} + +// sumOf is the sum of the sizes. +func sumOf(sizes map[int]int64) int64 { + var total int64 + for _, size := range sizes { + total += size + } + + return total +} diff --git a/internal/nomad/logs.go b/internal/nomad/logs.go index d53a7db..484f6e6 100644 --- a/internal/nomad/logs.go +++ b/internal/nomad/logs.go @@ -3,7 +3,7 @@ package nomad import ( "bytes" "context" - "strings" + "sync/atomic" "time" "github.com/hashicorp/nomad/api" @@ -53,6 +53,19 @@ type LogStream struct { OnClose func() cancel chan struct{} + + // start is written by the goroutine that reads the frames. + start atomic.Pointer[LogMark] +} + +// Start is where the first line the stream sent begins: the zero mark until +// it sent one. +func (s *LogStream) Start() LogMark { + if mark := s.start.Load(); mark != nil { + return *mark + } + + return LogMark{} } // Close stops the stream and the request behind it. @@ -88,9 +101,10 @@ func (c *Client) Logs(ctx context.Context, namespace, allocID, task, source stri frames, errs := c.api.AllocFS().Logs(alloc, !finished, task, source, "end", logTail, cancel, c.query(context.Background(), namespace)) - lines := streamLines(frames, startsCut, nil, cancel) + stream := &LogStream{Err: errs, Finished: finished, Previous: alloc.PreviousAllocation, cancel: cancel} + stream.Lines = streamLines(frames, startsCut, nil, &stream.start, cancel) - return &LogStream{Lines: lines, Err: errs, Finished: finished, Previous: alloc.PreviousAllocation, cancel: cancel}, nil + return stream, nil } // streamLines passes on what the frames hold, from the first whole line when @@ -100,7 +114,9 @@ func (c *Client) Logs(ctx context.Context, namespace, allocID, task, source stri // the stream ends, or after logTail bytes. atEnd, when not nil, says a frame // reached the end of what was there when the stream began, and what is held // is sent after it, once: a file does not end, so its last line gets no break. -func streamLines(frames <-chan *api.StreamFrame, startsCut, atEnd func(frame *api.StreamFrame) bool, cancel <-chan struct{}) <-chan string { +// start, when not nil, gets the mark of the first byte sent, before anything +// is sent. +func streamLines(frames <-chan *api.StreamFrame, startsCut, atEnd func(frame *api.StreamFrame) bool, start *atomic.Pointer[LogMark], cancel <-chan struct{}) <-chan string { lines := make(chan string) go func() { @@ -109,7 +125,7 @@ func streamLines(frames <-chan *api.StreamFrame, startsCut, atEnd func(frame *ap send := func(chunk []byte) bool { return sendChunk(lines, chunk, frames, cancel) } var ( - whole = wholeLines{first: true, startsCut: startsCut} + whole = wholeLines{first: true, startsCut: startsCut, start: start} frame *api.StreamFrame open bool ) @@ -163,6 +179,9 @@ type wholeLines struct { // a line whose start was not read is still to be dropped. first, cut bool startsCut func(first *api.StreamFrame) bool + + // start, when not nil, is set once, to where the first byte kept is. + start *atomic.Pointer[LogMark] } // add takes a frame and gives back what is whole up to its last line break, @@ -185,6 +204,8 @@ func (w *wholeLines) add(frame *api.StreamFrame) []byte { data, w.cut = data[at+1:], false } + w.noteStart(frame, data) + if w.unended && len(data) > 0 { w.unended = false @@ -206,6 +227,17 @@ func (w *wholeLines) add(frame *api.StreamFrame) []byte { return data[:end] } +// noteStart records where the first byte kept is, the end of data being the +// end of the frame. +func (w *wholeLines) noteStart(frame *api.StreamFrame, data []byte) { + if w.start == nil || len(data) == 0 || w.start.Load() != nil { + return + } + + mark := frameStart(frame, len(data)) + w.start.Store(&mark) +} + // rest gives back what is held and forgets it. func (w *wholeLines) rest() []byte { held := w.held @@ -258,7 +290,13 @@ func taskDead(alloc *api.Allocation, task string) bool { // writing, so its first line is likely cut. A frame ends at its Offset in its // File, and a log is turned over into a new file by size, not at a line break. func startsCut(frame *api.StreamFrame) bool { - start := frame.Offset - int64(len(frame.Data)) + return !frameStart(frame, len(frame.Data)).AtStart() +} - return start > 0 || !strings.HasSuffix(frame.File, ".0") +// frameStart is where the last kept bytes of a frame begin in its file. The +// cluster puts a read into the frame of the one before while that frame is +// not sent yet, under the Offset of the first read, so the count can come out +// below 0: the frame then starts at the start of the file. +func frameStart(frame *api.StreamFrame, kept int) LogMark { + return LogMark{File: frame.File, Offset: max(frame.Offset-int64(kept), 0)} } diff --git a/internal/nomad/logs_stream_test.go b/internal/nomad/logs_stream_test.go index 90c8628..b2a0393 100644 --- a/internal/nomad/logs_stream_test.go +++ b/internal/nomad/logs_stream_test.go @@ -31,7 +31,7 @@ func TestStreamLines_EndWhenCancelledAndFramesStayOpen(t *testing.T) { frames := make(chan *api.StreamFrame) cancel := make(chan struct{}) - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) close(cancel) @@ -41,7 +41,7 @@ func TestStreamLines_EndWhenCancelledAndFramesStayOpen(t *testing.T) { func TestStreamLines_EndWhenCancelledOnNoFrames(t *testing.T) { cancel := make(chan struct{}) - lines := streamLines(nil, neverCut, nil, cancel) + lines := streamLines(nil, neverCut, nil, nil, cancel) close(cancel) @@ -66,7 +66,7 @@ func TestStreamLines_UnblockAProducerWaitingOnFullFrames(t *testing.T) { close(frames) }() - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) close(cancel) @@ -91,7 +91,7 @@ func chunksOf(t *testing.T, data ...string) []string { close(frames) - lines := streamLines(frames, neverCut, nil, make(chan struct{})) + lines := streamLines(frames, neverCut, nil, nil, make(chan struct{})) var chunks []string @@ -137,7 +137,7 @@ func TestStreamLines_SendAHeldLineThatNeverEndsPastTheTail(t *testing.T) { frames := make(chan *api.StreamFrame, 1) cancel := make(chan struct{}) - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) frames <- &api.StreamFrame{Data: []byte(strings.Repeat("x", logTail+1))} @@ -157,7 +157,7 @@ func TestStreamLines_EndTheLineSentPastTheTailWithoutAnEmptyOne(t *testing.T) { defer close(cancel) - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) frames <- &api.StreamFrame{Data: []byte(strings.Repeat("x", logTail+1))} frames <- &api.StreamFrame{Data: []byte("\nnext\n")} @@ -171,7 +171,7 @@ func TestStreamLines_DropWhatIsHeldWhenCancelled(t *testing.T) { frames := make(chan *api.StreamFrame, 1) cancel := make(chan struct{}) - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) frames <- &api.StreamFrame{Data: []byte("part")} @@ -213,7 +213,7 @@ func TestStreamLines_SendTheEndOfAFileWhenItIsReadToItsSize(t *testing.T) { frames <- &api.StreamFrame{Data: []byte("a=1\nb=2"), Offset: 7} - lines := streamLines(frames, neverCut, atSize(7), cancel) + lines := streamLines(frames, neverCut, atSize(7), nil, cancel) require.Equal(t, "a=1\nb=2", strings.Join(collect(t, lines, 1), "")) } @@ -228,7 +228,7 @@ func TestStreamLines_EndTheLineSentAtTheSizeWithoutRepeatingIt(t *testing.T) { frames <- &api.StreamFrame{Data: []byte("\nc=3"), Offset: 11} frames <- &api.StreamFrame{Data: []byte("\n"), Offset: 12} - lines := streamLines(frames, neverCut, atSize(7), cancel) + lines := streamLines(frames, neverCut, atSize(7), nil, cancel) got := collect(t, lines, 2) @@ -251,7 +251,7 @@ func TestStreamLines_JoinALineAfterTheSizeSplitAcrossFrames(t *testing.T) { frames <- &api.StreamFrame{Data: []byte("first li"), Offset: 12} frames <- &api.StreamFrame{Data: []byte("ne\n"), Offset: 15} - lines := streamLines(frames, neverCut, atSize(4), cancel) + lines := streamLines(frames, neverCut, atSize(4), nil, cancel) require.Equal(t, []string{"a=1\n", "first line\n"}, collect(t, lines, 2)) } @@ -266,7 +266,7 @@ func TestStreamLines_NoEndOfAFileThatWasEmpty(t *testing.T) { frames <- &api.StreamFrame{Data: []byte("ne\nsec"), Offset: 14} frames <- &api.StreamFrame{Data: []byte("ond\n"), Offset: 18} - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) require.Equal(t, []string{"first line\n", "second\n"}, collect(t, lines, 2)) } @@ -277,7 +277,7 @@ func TestStreamLines_HoldALogLineUntilItsBreakWhileFramesStayOpen(t *testing.T) defer close(cancel) - lines := streamLines(frames, neverCut, nil, cancel) + lines := streamLines(frames, neverCut, nil, nil, cancel) frames <- &api.StreamFrame{Data: []byte("waiting")} diff --git a/internal/nomad/logs_test.go b/internal/nomad/logs_test.go index 9ba1f31..df3608c 100644 --- a/internal/nomad/logs_test.go +++ b/internal/nomad/logs_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "path" + "strconv" "strings" "testing" "time" @@ -127,6 +129,22 @@ func TestLogs_KeepTheFirstLineOfTheWholeLog(t *testing.T) { r.Equal("first\nsecond\n", readAll(t, stream)) } +func TestLogs_KeepTheFirstLineOfALogWhoseFrameHoldsTwoReads(t *testing.T) { + r := require.New(t) + + // A read that comes before the frame of the one before it is sent goes + // into that frame, which keeps the Offset of the first read: its data + // reaches back past the start of the file. + client, _ := logServer(t, "running", + frame{Data: []byte("first\nsecond\n"), File: "alloc/logs/server.stdout.0", Offset: 6}) + + stream, err := client.Logs(context.Background(), "production", "a1", "server", nomad.LogStdout) + r.NoError(err) + + r.Equal("first\nsecond\n", readAll(t, stream)) + r.Equal(nomad.LogMark{File: "alloc/logs/server.stdout.0"}, stream.Start()) +} + func TestLogs_ACutLineCanSpanFrames(t *testing.T) { r := require.New(t) @@ -196,3 +214,304 @@ func TestLogs_SayWhichAllocationCameBefore(t *testing.T) { // an empty log, and why it crashed is in the one it replaced. r.Equal("a0", stream.Previous) } + +// started reads a stream to its end and says where its first line began. +func started(t *testing.T, frames ...frame) nomad.LogMark { + t.Helper() + + client, _ := logServer(t, "running", frames...) + + stream, err := client.Logs(context.Background(), "production", "a1", "server", nomad.LogStdout) + require.NoError(t, err) + readAll(t, stream) + + return stream.Start() +} + +func TestLogs_StartIsWhereTheFirstLineBegins(t *testing.T) { + const log = "alloc/logs/server.stdout." + + tests := []struct { + name string + frames []frame + want nomad.LogMark + }{ + {"a whole log", []frame{{Data: []byte("first\nsecond\n"), File: log + "0", Offset: 13}}, nomad.LogMark{File: log + "0"}}, + {"a cut first line", []frame{{Data: []byte("of a line\nsecond\nthird\n"), File: log + "0", Offset: 70000}}, nomad.LogMark{File: log + "0", Offset: 69987}}, + {"a cut over two frames", []frame{ + {Data: []byte("abc"), File: log + "0", Offset: 70003}, + {Data: []byte("def\nwhole\n"), File: log + "0", Offset: 70013}, + }, nomad.LogMark{File: log + "0", Offset: 70007}}, + {"a rotated file", []frame{{Data: []byte("the rest\nwhole\n"), File: log + "3", Offset: 15}}, nomad.LogMark{File: log + "3", Offset: 9}}, + {"the first frame kept, not a later one", []frame{ + {Data: []byte("tail\nfirst\n"), File: log + "1", Offset: 100}, + {Data: []byte("second\n"), File: log + "1", Offset: 107}, + }, nomad.LogMark{File: log + "1", Offset: 94}}, + {"no frame", nil, nomad.LogMark{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, started(t, tt.frames...)) + }) + } +} + +func TestLogMark_AtStartIsTheFirstByteOfTheFirstFile(t *testing.T) { + r := require.New(t) + + r.True(nomad.LogMark{File: "alloc/logs/server.stdout.0"}.AtStart()) + r.False(nomad.LogMark{File: "alloc/logs/server.stdout.0", Offset: 4}.AtStart()) + r.False(nomad.LogMark{File: "alloc/logs/server.stdout.1"}.AtStart()) + r.False(nomad.LogMark{}.AtStart()) +} + +// backAsk is one read of a file the server saw. +type backAsk struct { + path string + offset, limit int64 + namespace string +} + +// backServer answers for the files of the allocation a1, by name: it lists +// the directory of its logs and reads a part of a file, and writes down what +// it was asked to read and the namespaces it was asked to list in. +func backServer(t *testing.T, files map[string]string) (*nomad.Client, *[]backAsk, *[]string) { + t.Helper() + + asked, listedIn := &[]backAsk{}, &[]string{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + + switch req.URL.Path { + case "/v1/client/fs/ls/a1": + *listedIn = append(*listedIn, query.Get("namespace")) + + var listed []map[string]any + + for name, content := range files { + if path.Dir(name) == query.Get("path") { + listed = append(listed, map[string]any{"Name": path.Base(name), "Size": len(content)}) + } + } + + _ = json.NewEncoder(w).Encode(listed) + + case "/v1/client/fs/readat/a1": + offset, _ := strconv.ParseInt(query.Get("offset"), 10, 64) + limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64) + *asked = append(*asked, backAsk{query.Get("path"), offset, limit, query.Get("namespace")}) + + content, ok := files[query.Get("path")] + if !ok { + http.NotFound(w, req) + + return + } + + _, _ = w.Write([]byte(content[offset:min(offset+limit, int64(len(content)))])) + + default: + http.NotFound(w, req) + } + })) + t.Cleanup(server.Close) + + client, err := nomad.New(nomad.Config{Address: server.URL}) + require.NoError(t, err) + + return client, asked, listedIn +} + +// numbered is n lines of 9 bytes each, so a step of 1 MiB lands inside one. +func numbered(n int) string { + var out strings.Builder + + for i := range n { + fmt.Fprintf(&out, "%08d\n", i) + } + + return out.String() +} + +const ( + backLog = "alloc/logs/server.stdout." + logSized = 1 << 20 +) + +func TestLogBefore_ReadsOneStepBackAndDropsTheCutLine(t *testing.T) { + r := require.New(t) + + older, newer := numbered(50_000), numbered(400_000) + client, asked, listedIn := backServer(t, map[string]string{backLog + "0": older, backLog + "1": newer}) + + at := nomad.LogMark{File: backLog + "1", Offset: 9 * 300_000} + part, err := client.LogBefore(context.Background(), "production", "a1", at, 200_000) + r.NoError(err) + + // One read, of a step, in the namespace given. + r.Equal([]backAsk{{backLog + "1", at.Offset - logSized, logSized, "production"}}, *asked) + r.Equal([]string{"production"}, *listedIn) + + // The step starts inside a line (the 6th byte of line 183491): that line + // is dropped, and the first one kept is whole. + r.Len(part.Lines, 299_999-183_492+1) + r.Equal("00183492", part.Lines[0]) + r.Equal("00299999", part.Lines[len(part.Lines)-1]) + r.Equal(nomad.LogMark{File: backLog + "1", Offset: 9 * 183_492}, part.From) + + // What the cluster keeps before it: all of .0 and what is of .1 before it. + r.Equal(int64(len(older))+9*183_492, part.Before) + r.Equal(int64(len(older)+len(newer)), part.Size) +} + +func TestLogBefore_JoinsTheFilesWhenTheStepCrossesOne(t *testing.T) { + r := require.New(t) + + client, asked, _ := backServer(t, map[string]string{ + backLog + "0": "alpha\nbeta\nsplit-", + backLog + "1": "line-over\nmid\nrest\n", + }) + + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "1", Offset: 14}, 1000) + r.NoError(err) + + // A line the rotation split is one line again, and the first line of + // the first file is kept: nothing was cut there. + r.Equal([]string{"alpha", "beta", "split-line-over", "mid"}, part.Lines) + r.Equal(nomad.LogMark{File: backLog + "0"}, part.From) + r.Zero(part.Before) + r.Equal([]backAsk{{backLog + "1", 0, 14, "production"}, {backLog + "0", 0, 17, "production"}}, *asked) +} + +func TestLogBefore_ReadsAPathWrittenWithBackslashes(t *testing.T) { + r := require.New(t) + + client, asked, _ := backServer(t, map[string]string{ + backLog + "0": "alpha\nbeta\nsplit-", + backLog + "1": "line-over\nmid\nrest\n", + }) + + // A client on Windows joins the path of a frame with backslashes. + at := nomad.LogMark{File: `alloc\logs\server.stdout.1`, Offset: 14} + part, err := client.LogBefore(context.Background(), "production", "a1", at, 1000) + r.NoError(err) + + r.Equal([]string{"alpha", "beta", "split-line-over", "mid"}, part.Lines) + r.Equal(nomad.LogMark{File: backLog + "0"}, part.From) + r.Zero(part.Before) + r.Equal([]backAsk{{backLog + "1", 0, 14, "production"}, {backLog + "0", 0, 17, "production"}}, *asked) +} + +func TestLogBefore_StopsAtTheOldestFileThatIsLeft(t *testing.T) { + r := require.New(t) + + // .0 and .1 were deleted: .2 starts in the middle of a line. + client, _, _ := backServer(t, map[string]string{backLog + "2": "cut-off\nwhole\nlines\n"}) + + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "2", Offset: 20}, 1000) + r.NoError(err) + + r.Equal([]string{"whole", "lines"}, part.Lines) + r.Equal(nomad.LogMark{File: backLog + "2", Offset: 8}, part.From) + + // The cut line is not readable, so there is nothing further back. + r.Zero(part.Before) +} + +func TestLogBefore_ACutLineAloneAtTheOldestFileIsDropped(t *testing.T) { + r := require.New(t) + + client, _, _ := backServer(t, map[string]string{backLog + "3": "the rest\nwhole\n"}) + + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "3", Offset: 9}, 1000) + r.NoError(err) + + r.Empty(part.Lines) + r.Zero(part.Before) +} + +func TestLogBefore_ReadsNothingFromAFileThatIsGone(t *testing.T) { + r := require.New(t) + + client, asked, _ := backServer(t, map[string]string{backLog + "2": "whole\n"}) + + at := nomad.LogMark{File: backLog + "1", Offset: 10} + part, err := client.LogBefore(context.Background(), "production", "a1", at, 1000) + r.NoError(err) + + r.Empty(part.Lines) + r.Zero(part.Before) + r.Equal(at, part.From) + r.Empty(*asked) +} + +func TestLogBefore_KeepsTheLastLinesOfALimit(t *testing.T) { + r := require.New(t) + + older := numbered(50_000) + client, _, _ := backServer(t, map[string]string{backLog + "0": older, backLog + "1": numbered(400_000)}) + + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "1", Offset: 9 * 300_000}, 3) + r.NoError(err) + + // The lines left out are still to be read: From and Before say so. + r.Equal([]string{"00299997", "00299998", "00299999"}, part.Lines) + r.Equal(nomad.LogMark{File: backLog + "1", Offset: 9 * 299_997}, part.From) + r.Equal(int64(len(older))+9*299_997, part.Before) +} + +func TestLogBefore_KeepsALineLongerThanAStepWhole(t *testing.T) { + r := require.New(t) + + long := strings.Repeat("x", 2<<20) + client, _, _ := backServer(t, map[string]string{backLog + "1": "head\n" + long + "\n"}) + + size := int64(5 + len(long) + 1) + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "1", Offset: size}, 1000) + r.NoError(err) + + // A step with no line break in it is one line, as a line longer than + // the tail of a log is when it opens. + r.Len(part.Lines, 1) + r.Len(part.Lines[0], logSized-1) + r.Equal(nomad.LogMark{File: backLog + "1", Offset: size - logSized}, part.From) +} + +func TestLogBefore_AStepThatEndsAtTheStartOfAFileDropsTheLineCutThere(t *testing.T) { + r := require.New(t) + + older := "aaa\nbb" + client, asked, _ := backServer(t, map[string]string{ + backLog + "0": older, + backLog + "1": "cc\n" + strings.Repeat("y", logSized-4) + "\n", + }) + + at := nomad.LogMark{File: backLog + "1", Offset: logSized} + part, err := client.LogBefore(context.Background(), "production", "a1", at, 1000) + r.NoError(err) + + // The step is all of .1. Its first line began in .0, which is still + // there: the next step reads it whole, so it counts as before. + r.Equal([]backAsk{{backLog + "1", 0, logSized, "production"}}, *asked) + r.Equal(nomad.LogMark{File: backLog + "1", Offset: 3}, part.From) + r.Equal(int64(3+len(older)), part.Before) +} + +func TestLogBefore_AStepThatEndsAtTheStartOfAFileKeepsALineWithNoBreakWhole(t *testing.T) { + r := require.New(t) + + client, _, _ := backServer(t, map[string]string{ + backLog + "0": "aaa\nbb", + backLog + "1": strings.Repeat("y", logSized-1) + "\n", + }) + + part, err := client.LogBefore(context.Background(), "production", "a1", nomad.LogMark{File: backLog + "1", Offset: logSized}, 1000) + r.NoError(err) + + // A file before it is still there, so the break at the end of the step + // ends the one line in it, which is kept. + r.Len(part.Lines, 1) + r.Positive(part.Before) +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 2960440..5d0fc4f 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -55,11 +55,19 @@ type fakeClient struct { // of a job, a namespace, metadata and a variable. refusals []error - describe string - spec nomad.JobSource - specErr error - logs *nomad.LogStream - usage nomad.Usage + describe string + spec nomad.JobSource + specErr error + logs *nomad.LogStream + usage nomad.Usage + + // logPart is what the log answers when read further back, and the rest + // what it was asked. + logPart nomad.LogPart + backNamespace string + backAllocID string + backAt nomad.LogMark + backLimit int use map[string]nomad.ResourceUse usageCalls int usageIDs []string @@ -543,6 +551,12 @@ func (f *fakeClient) Logs(_ context.Context, namespace, allocID, task, source st return stream, nil } +func (f *fakeClient) LogBefore(_ context.Context, namespace, allocID string, at nomad.LogMark, limit int) (nomad.LogPart, error) { + f.backNamespace, f.backAllocID, f.backAt, f.backLimit = namespace, allocID, at, limit + + return f.logPart, f.err +} + func (f *fakeClient) StartJob(_ context.Context, namespace, jobID string) error { f.wrote("StartJob") f.askedNamespace, f.askedID = namespace, jobID diff --git a/internal/ui/clusters_test.go b/internal/ui/clusters_test.go index d4727a8..1cf5ab5 100644 --- a/internal/ui/clusters_test.go +++ b/internal/ui/clusters_test.go @@ -39,7 +39,7 @@ func (c *twoClusters) connect(name string) (Connection, error) { func onDev(t *testing.T) (Model, *twoClusters) { t.Helper() - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) // Both clusters stream their events, like a cluster that allows it, so // no warning about polling hides the messages of the switch. diff --git a/internal/ui/feed_test.go b/internal/ui/feed_test.go index dea3f60..9d64c5f 100644 --- a/internal/ui/feed_test.go +++ b/internal/ui/feed_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/ingvarch/urga/internal/config" "github.com/ingvarch/urga/internal/nomad" ) @@ -289,7 +288,7 @@ func TestEvents_WhatWaitsArrivesAtOnce(t *testing.T) { func TestEvents_ASessionStartsOnThem(t *testing.T) { r := require.New(t) - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) cfg.Screen = "events" diff --git a/internal/ui/files.go b/internal/ui/files.go index 9de5550..fc0a115 100644 --- a/internal/ui/files.go +++ b/internal/ui/files.go @@ -179,12 +179,7 @@ type filePage struct { // title says which file of which allocation it is, and when only its end // was read, how much of it. func (p filePage) title(env, int) string { - what := p.path - if p.read.from > 0 { - what += fmt.Sprintf(", last %s of %s", sizeOf(p.read.size-p.read.from), sizeOf(p.read.size)) - } - - return fmt.Sprintf("File (Allocation: %s) [%s]", shortID(p.allocID), what) + return fmt.Sprintf("File (Allocation: %s) [%s%s]", shortID(p.allocID), p.path, p.read.span()) } func (filePage) titles() []string { return nil } diff --git a/internal/ui/files_test.go b/internal/ui/files_test.go index c3f50aa..abdc57f 100644 --- a/internal/ui/files_test.go +++ b/internal/ui/files_test.go @@ -558,7 +558,8 @@ func TestFile_SaveWhatIsOnTheScreen(t *testing.T) { client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), file: written("DB_HOST=10.0.0.5\n")} m := openedEnv(t, client) - m, cmd := m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) m = drain(m, cmd) // Named after the file, not after the screen it was read on. @@ -569,7 +570,7 @@ func TestFile_SaveWhatIsOnTheScreen(t *testing.T) { saved, err := os.ReadFile(files[0]) r.NoError(err) r.Equal("DB_HOST=10.0.0.5", string(saved)) - r.Contains(plain(m.render()), filepath.Base(files[0])) + r.Contains(m.flash.text, filepath.Base(files[0])) } func TestFile_Wraps(t *testing.T) { diff --git a/internal/ui/help.go b/internal/ui/help.go index 3cadd5e..aed0276 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -1,6 +1,7 @@ package ui import ( + "slices" "strings" "github.com/charmbracelet/x/ansi" @@ -35,6 +36,14 @@ var navigationHints = []hint{ {Key: "", Description: "Top/Bottom"}, } +// textNavigationHints are how to move a text sideways. A list leaves them +// out: there h and l are keys of some screens. +var textNavigationHints = []hint{ + {Key: "", Description: "Left"}, + {Key: "", Description: "Right"}, + {Key: "", Description: "Line Start"}, +} + // helpSections are what the help window holds: what this screen can do, then // what works everywhere. func (m Model) helpSections() []helpSection { @@ -48,9 +57,14 @@ func (m Model) helpSections() []helpSection { sections = append(sections, helpSection{title: "SORT", hints: hints}) } + navigation := navigationHints + if _, buttons := m.screen.page.(buttoned); m.readsAsText() && !buttons { + navigation = slices.Concat(navigationHints, textNavigationHints) + } + return append(sections, helpSection{title: "GENERAL", hints: generalHints}, - helpSection{title: "NAVIGATION", hints: navigationHints}, + helpSection{title: "NAVIGATION", hints: navigation}, ) } diff --git a/internal/ui/hints_test.go b/internal/ui/hints_test.go index 9e28d39..f6db571 100644 --- a/internal/ui/hints_test.go +++ b/internal/ui/hints_test.go @@ -430,6 +430,12 @@ func TestHints_EveryKeyThatDoesSomethingIsInTheHeader(t *testing.T) { } for _, press := range screenKeys() { + // On a text screen h and l move the window sideways, and help + // names them, as it names j and k. + if m.readsAsText() && (press.String() == "h" || press.String() == "l") { + continue + } + // The buttons at the foot of a plan are chosen and pressed the // way the buttons of a question are: those keys are the // dialog's, and help names them. @@ -463,7 +469,7 @@ func TestHints_EveryKeyIsNamedInOneOrTwoWords(t *testing.T) { } } - for _, h := range slices.Concat(generalHints, navigationHints) { + for _, h := range slices.Concat(generalHints, navigationHints, textNavigationHints) { named(h.Key+" in help", h.Description) } } diff --git a/internal/ui/joblogs_test.go b/internal/ui/joblogs_test.go index c588f6f..e6cd846 100644 --- a/internal/ui/joblogs_test.go +++ b/internal/ui/joblogs_test.go @@ -523,7 +523,8 @@ func TestJobLogs_SavedUnderTheJobTheTaskAndWhatItWrites(t *testing.T) { m, _ := oneTask(t) - m, cmd := m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) drain(m, cmd) files, err := filepath.Glob(filepath.Join(dir, "web-server-stdout-*.log")) diff --git a/internal/ui/logback_test.go b/internal/ui/logback_test.go new file mode 100644 index 0000000..b0b1e37 --- /dev/null +++ b/internal/ui/logback_test.go @@ -0,0 +1,447 @@ +package ui + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// midLog is where a log read from its last 64 KiB begins. +var midLog = nomad.LogMark{File: "alloc/logs/server.stdout.1", Offset: 70_000} + +// numberedLines are n lines named after their place, from first on. +func numberedLines(prefix string, first, n int) []string { + lines := make([]string, 0, n) + for i := first; i < first+n; i++ { + lines = append(lines, fmt.Sprintf("%s %05d", prefix, i)) + } + + return lines +} + +// sentLog opens a log and has its stream send the lines, the first of which +// begins at start. +func sentLog(t *testing.T, client *fakeClient, start nomad.LogMark, lines []string) (Model, *nomad.LogStream) { + t.Helper() + + stream := &nomad.LogStream{Lines: make(chan string)} + m := openLog(t, client, stream) + m, _ = m.update(logLineMsg{stream: stream, text: strings.Join(lines, "\n") + "\n", start: start}) + + return m, stream +} + +// pressEarlier presses e and runs the read it asks for. +func pressEarlier(m Model) Model { + m, cmd := m.update(key('e')) + + return drain(m, cmd) +} + +// shownRows are the rows of the text, as the window draws them. +func shownRows(m Model) []string { + rows := m.text.rows() + out := make([]string, 0, len(rows)) + + for _, row := range rows { + out = append(out, row.text) + } + + return out +} + +func TestLogEarlier_ALineOfAStreamCarriesWhereItsFirstLineBegins(t *testing.T) { + r := require.New(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/v1/allocation/a1": + _, _ = w.Write([]byte(`{"ID": "a1", "Namespace": "production", "NodeID": "n1", "TaskStates": {"server": {"State": "running"}}}`)) + case "/v1/client/fs/logs/a1": + _ = json.NewEncoder(w).Encode(map[string]any{"Data": []byte("cut\nwhole\n"), "File": midLog.File, "Offset": midLog.Offset}) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(server.Close) + + client, err := nomad.New(nomad.Config{Address: server.URL}) + r.NoError(err) + + stream, err := client.Logs(context.Background(), "production", "a1", "server", nomad.LogStdout) + r.NoError(err) + t.Cleanup(stream.Close) + + // The cut line is dropped: the text starts at the line after it. + start := nomad.LogMark{File: midLog.File, Offset: midLog.Offset - int64(len("whole\n"))} + r.Equal(logLineMsg{stream: stream, text: "whole\n", start: start}, waitForStream(stream)()) +} + +func TestLogEarlier_OfferedWhileTheLogStartsPastItsStart(t *testing.T) { + tests := []struct { + name string + start nomad.LogMark + want bool + }{ + {"read from the middle", midLog, true}, + {"read from the start", nomad.LogMark{File: "alloc/logs/server.stdout.0"}, false}, + {"no mark", nomad.LogMark{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, _ := sentLog(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}, tt.start, []string{"one", "two"}) + + require.Equal(t, tt.want, offersLabel(m, "e", "Read Earlier")) + }) + } +} + +func TestLogEarlier_AsksFromTheMarkWithTheRoomLeft(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 60)) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 10), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + pressEarlier(m) + + r.Equal(midLog, client.backAt) + r.Equal("production", client.backNamespace) + r.Equal("af1f37df-7b19-6b1c-da67-5e8f482b5a15", client.backAllocID) + r.Equal(maxTextLines-60, client.backLimit) +} + +func TestLogEarlier_PutsTheLinesAboveAndKeepsTheWindowOnItsLine(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 60)) + + m, _ = m.update(key('s')) + m.text.move(-100) + m.text.move(5) + r.Equal("late 00005", shownRows(m)[m.text.top]) + + from := nomad.LogMark{File: midLog.File, Offset: 60_000} + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 812), From: from, Before: 1_153_434, Size: 5_557_452} + m = pressEarlier(m) + + // The line at the top of the window is still at the top, with the lines + // read back above it. + r.Len(shownRows(m), 812+60) + r.Equal("early 00000", shownRows(m)[0]) + r.Equal("late 00005", shownRows(m)[m.text.top]) + + out := plain(m.render()) + r.Contains(out, "[stdout, last 4.2 MiB of 5.3 MiB]") + r.Contains(out, "Read 812 lines further back.") + r.Equal(from, logOf(m).before) + r.True(offersLabel(m, "e", "Read Earlier")) +} + +func TestLogEarlier_AWindowThatFollowsStaysAtTheEnd(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 60)) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 100), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + + r.True(m.text.following) + r.Len(shownRows(m), 160) + r.Contains(plain(m.render()), "late 00059") + r.Equal(m.text.length()-m.text.height, m.text.top) +} + +func TestLogEarlier_ALineAfterAReadKeepsWhereTheTextStarts(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, stream := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + from := nomad.LogMark{File: midLog.File, Offset: 60_000} + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 3), From: from, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + + // Every line of a stream carries the mark of its first line. + m, _ = m.update(logLineMsg{stream: stream, text: "more\n", start: midLog}) + r.Equal(from, logOf(m).before) + + pressEarlier(m) + r.Equal(from, client.backAt) +} + +func TestLogEarlier_TheStartOfTheLogTakesTheKeyAway(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + client.logPart = nomad.LogPart{Lines: numberedLines("middle", 0, 3), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + r.Contains(plain(m.render()), "[stdout, last") + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 3), From: nomad.LogMark{File: "alloc/logs/server.stdout.0"}, Size: 130_000} + m = pressEarlier(m) + + out := plain(m.render()) + r.False(offersLabel(m, "e", "Read Earlier")) + r.NotContains(out, "last") + r.Contains(out, "Read 3 lines further back, to the start of the log.") +} + +func TestLogEarlier_NothingFurtherBack(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + client.logPart = nomad.LogPart{From: midLog, Size: 130_000} + m = pressEarlier(m) + + r.Contains(plain(m.render()), "Nothing further back in the log.") + r.Len(shownRows(m), 5) +} + +func TestLogEarlier_AnAnswerForTheSameMarkTwiceIsTakenOnce(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + part := nomad.LogPart{Lines: numberedLines("early", 0, 3), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m, _ = m.update(logPartMsg{at: midLog, part: part}) + m, _ = m.update(logPartMsg{at: midLog, part: part}) + + r.Len(shownRows(m), 8) +} + +func TestLogEarlier_AnAnswerIsDroppedWhenTheStreamTookLinesOffTheFront(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, stream := sentLog(t, client, midLog, numberedLines("late", 0, maxTextLines-10)) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 10), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + + m, cmd := m.update(key('e')) + m, _ = m.update(logLineMsg{stream: stream, text: strings.Join(numberedLines("more", 0, 30), "\n") + "\n"}) + m = drain(m, cmd) + + r.Equal(maxTextLines, len(shownRows(m))) + r.Equal("late 00020", shownRows(m)[0]) + r.Equal(midLog, logOf(m).before) +} + +func TestLogEarlier_AnEmptyAnswerIsDroppedWhenTheStreamTookLinesOffTheFront(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, stream := sentLog(t, client, midLog, numberedLines("late", 0, maxTextLines-10)) + + client.logPart = nomad.LogPart{From: midLog, Size: 130_000} + + m, cmd := m.update(key('e')) + m, _ = m.update(logLineMsg{stream: stream, text: strings.Join(numberedLines("more", 0, 30), "\n") + "\n", start: midLog}) + m = drain(m, cmd) + + // The text no longer starts at the mark, so the answer says nothing about + // what is before it now. + r.NotContains(plain(m.render()), "Nothing further back in the log.") + + read := logOf(m) + r.Equal(midLog, read.before) + r.True(read.more) + r.Zero(read.size) + r.Zero(read.from) +} + +func TestLogEarlier_AnAnswerIsDroppedWhenItNoLongerFits(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, stream := sentLog(t, client, midLog, numberedLines("late", 0, maxTextLines-10)) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 10), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + + m, cmd := m.update(key('e')) + m, _ = m.update(logLineMsg{stream: stream, text: strings.Join(numberedLines("more", 0, 5), "\n") + "\n"}) + m = drain(m, cmd) + + r.Equal(maxTextLines-5, len(shownRows(m))) + r.Equal("late 00000", shownRows(m)[0]) + r.Equal(midLog, logOf(m).before) + r.True(offersLabel(m, "e", "Read Earlier")) + + // The press would otherwise show nothing. + r.Contains(status(m), "The lines read back no longer fit on the screen.") + r.Equal(flashWarn, m.flash.level) +} + +func TestLogEarlier_AFullTextOffersNoKey(t *testing.T) { + m, _ := sentLog(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}, midLog, numberedLines("late", 0, maxTextLines)) + + require.False(t, offersLabel(m, "e", "Read Earlier")) +} + +func TestLogEarlier_TheLinesReadBackAreCleaned(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + client.logPart = nomad.LogPart{Lines: []string{"a\tb", "\x1b]0;title\x07x"}, From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + + r.Equal([]string{"a b", "x", "late 00000"}, shownRows(m)[:3]) +} + +func TestText_LinesPutInFrontHaveNoTimeTagOrStyle(t *testing.T) { + r := require.New(t) + + own := &tag{text: "own │ "} + bold := lipgloss.NewStyle().Bold(true) + + text := textContent{} + text.add(linesFrom(10, 1), arrivedAt, nil, nil) + text.add(linesFrom(11, 2), arrivedAt, own, &bold) + text.addBefore(linesFrom(0, 3)) + + r.Equal([]string{"line 0", "line 1", "line 2", "line 10", "line 11", "line 12"}, text.lines) + + for i := range 4 { + r.Nil(text.tagAt(i), i) + r.Nil(text.paintAt(i), i) + } + + for i := range 3 { + r.Zero(text.stampAt(i), i) + } + + r.Equal(arrivedAt, text.stampAt(3)) + + for i := 4; i < 6; i++ { + r.Equal(arrivedAt, text.stampAt(i), i) + r.Same(own, text.tagAt(i), i) + r.Same(&bold, text.paintAt(i), i) + } +} + +func TestLogEarlier_TheLinesReadBackHaveNoTime(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + m, _ = m.update(key('t')) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 2), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + + rows := shownRows(m) + r.Equal(strings.Repeat(" ", len(logTimeFormat)+2)+"early 00000", rows[0]) + r.NotEqual(strings.Repeat(" ", len(logTimeFormat)+2), rows[2][:len(logTimeFormat)+2]) + r.Contains(rows[2], "late 00000") +} + +func TestLogEarlier_TheWindowStaysOnItsLineWithWrapAndAFilter(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + late := numberedLines("late", 0, 60) + m, _ := sentLog(t, client, midLog, late) + + m, _ = m.update(key('s')) + m, _ = m.update(key('w')) + m, _ = m.update(key('/')) + m = typeIn(m, "0") + m, _ = m.update(enter()) + m.text.move(-100) + m.text.move(7) + + before, rowsBefore := shownRows(m)[m.text.top], len(shownRows(m)) + + // Each line read back wraps into several rows, and some are filtered out. + long := strings.Repeat("x", 300) + client.logPart = nomad.LogPart{Lines: []string{"early 0 " + long, "skipped " + long, "early 00 " + long}, From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + + r.Greater(len(shownRows(m)), rowsBefore) + r.Equal(before, shownRows(m)[m.text.top]) + r.Greater(m.text.top, 7) +} + +func TestLogEarlier_NextGoesOnFromTheMatchItWasOn(t *testing.T) { + r := require.New(t) + + late := numberedLines("late", 0, 100) + for _, i := range []int{20, 50, 80} { + late[i] += " error" + } + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, late) + m = foundIn(windowOf(m, 10), "error") + + m, _ = m.update(key('n')) + r.Contains(status(m), `Match 2 of 3 for "error".`) + + client.logPart = nomad.LogPart{Lines: numberedLines("early", 0, 10), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000} + m = pressEarlier(m) + r.Equal("late 00050 error", shownRows(m)[m.text.at]) + + m, _ = m.update(key('n')) + r.Contains(status(m), `Match 3 of 3 for "error".`) + r.Equal("late 00080 error", shownRows(m)[m.text.at]) +} + +func TestLogEarlier_ARefusedReadLeavesTheLogAsItWas(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m, _ := sentLog(t, client, midLog, numberedLines("late", 0, 5)) + + client.err = errors.New("Permission denied") + m = pressEarlier(m) + + r.Contains(plain(m.render()), "Permission denied") + r.Len(shownRows(m), 5) + r.Equal(midLog, logOf(m).before) + r.True(offersLabel(m, "e", "Read Earlier")) +} + +func TestLogEarlier_TheLogsOfEveryAllocationAndAFileOfferNoKey(t *testing.T) { + r := require.New(t) + + client := &fakeClient{ + jobs: twoJobs(), + allocs: webAllocs("server"), + logsByAlloc: map[string]*nomad.LogStream{newer: writing("one\n"), older: writing("two\n"), backend: writing()}, + } + m := fromJobs(t, client) + m, _ = m.update(key('j')) + m, cmd := m.update(enter()) + m = playOut(m, cmd) + + r.IsType(jobLogsPage{}, m.screen.page) + r.False(offers(m, "e")) + + stream := &nomad.LogStream{Lines: make(chan string), Err: make(chan error)} + m = readingEnv(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}, stream) + m, _ = m.update(logLineMsg{stream: stream, text: "a\nb\n", start: midLog}) + + r.IsType(filePage{}, m.screen.page) + r.False(offers(m, "e")) +} diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 74acadf..58e4c24 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -26,6 +26,13 @@ type ( logLineMsg struct { stream *nomad.LogStream text string + start nomad.LogMark + } + + // logPartMsg is the part of a log read from before the mark at. + logPartMsg struct { + at nomad.LogMark + part nomad.LogPart } logEndMsg struct{ stream *nomad.LogStream } @@ -50,6 +57,11 @@ type logState struct { // reading began. size int64 from int64 + + // before is where the text starts in the log, and more says the cluster + // keeps more of the log before it. + before nomad.LogMark + more bool } // logsPage is what one task of an allocation writes to stdout or stderr, @@ -78,7 +90,7 @@ func (p logsPage) title(env, int) string { source += ", task finished" } - return fmt.Sprintf("Logs (Task: %s, Allocation: %s) [%s]", p.task, shortID(p.allocID), source) + return fmt.Sprintf("Logs (Task: %s, Allocation: %s) [%s%s]", p.task, shortID(p.allocID), source, p.read.span()) } func (logsPage) titles() []string { return nil } @@ -125,6 +137,12 @@ func (p logsPage) take(msg tea.Msg, _ env) (page, outcome, bool) { took bool ) + if part, ok := msg.(logPartMsg); ok { + p.read, out, took = p.read.earlier(part) + + return p, out, took + } + // A stream opened for a log that is no longer on the screen never gets // here: it was for a request that is over. if opened, ok := msg.(logStreamMsg); ok { @@ -145,6 +163,7 @@ var logsKeys = append([]pageKey[logsPage]{ {press: "ctrl+e", label: "Stderr", do: switchSource, offered: readsSource(nomad.LogStdout)}, {press: "ctrl+e", label: "Stdout", do: switchSource, offered: readsSource(nomad.LogStderr)}, {press: "p", label: "Previous Alloc", do: openPrevious, offered: hasPrevious}, + {press: "e", label: "Read Earlier", do: readEarlier, offered: hasEarlier}, wrapKey[logsPage](), timesKey[logsPage](), saveKey[logsPage](), @@ -182,6 +201,26 @@ func openPrevious(p logsPage, _ env) (logsPage, outcome) { return p, then(openMsg{previous}) } +// hasEarlier says the cluster keeps more of the log before what is shown, +// and the screen has room for it: a text that dropped lines is full. +func hasEarlier(p logsPage, _ env) bool { + return p.read.more && len(p.read.content.lines) < maxTextLines +} + +// readEarlier reads the step of the log before what is shown, to put above +// it. The answer says which mark it was read for, so that a second press +// while one is out does not put the same lines twice. +func readEarlier(p logsPage, e env) (logsPage, outcome) { + client, namespace, allocID := e.client, p.namespace, p.allocID + at, room := p.read.before, maxTextLines-len(p.read.content.lines) + + read := request(func(ctx context.Context) (nomad.LogPart, error) { + return client.LogBefore(ctx, namespace, allocID, at, room) + }, func(part nomad.LogPart) tea.Msg { return logPartMsg{at: at, part: part} }) + + return p, then(requestMsg(read)) +} + // waitForLog waits for the next thing the task writes. func (l logState) waitForLog() tea.Cmd { return waitForStream(l.stream) @@ -200,7 +239,7 @@ func waitForStream(stream *nomad.LogStream) tea.Cmd { return logEndMsg{stream: stream} } - return logLineMsg{stream: stream, text: line} + return logLineMsg{stream: stream, text: line, start: stream.Start()} case err := <-stream.Err: if err != nil { @@ -244,6 +283,11 @@ func (l logState) take(msg tea.Msg) (logState, outcome, bool) { return l, outcome{}, false } + // The stream's first line says where in the log the text starts. + if l.before == (nomad.LogMark{}) && msg.start != (nomad.LogMark{}) { + l.before, l.more = msg.start, !msg.start.AtStart() + } + // When each line arrived is written down: a task writes no time of // its own, and the time urga read it is the only one there is. l.content.add(linesOf(msg.text), time.Now(), nil, nil) @@ -261,6 +305,49 @@ func (l logState) take(msg tea.Msg) (logState, outcome, bool) { return l, outcome{}, false } +// earlier puts a part read from before the text in front of it, when it was +// read for the place the text starts at now. The answer is dropped if the +// stream took lines off the front meanwhile: the text no longer starts there. +// It is also dropped if the stream added so many lines meanwhile that it no +// longer fits under the limit, with a warning: the press shows nothing else. +func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) { + if msg.at != l.before || l.content.dropped > 0 { + return l, outcome{}, false + } + + if len(l.content.lines)+len(msg.part.Lines) > maxTextLines { + return l, outcome{now: []tea.Msg{warnMsg("The lines read back no longer fit on the screen.")}, reading: true}, true + } + + l.content.addBefore(msg.part.Lines) + l.before = msg.part.From + l.size, l.from = msg.part.Size, msg.part.Before + l.more = msg.part.Before > 0 + + said := sprintf("Read %s further back", plural(len(msg.part.Lines), "line")) + + switch { + case len(msg.part.Lines) == 0: + said = "Nothing further back in the log." + case !l.more: + said += ", to the start of the log." + default: + said += "." + } + + return l, outcome{now: []tea.Msg{linesBeforeMsg(len(msg.part.Lines)), sayMsg(said)}, reading: true}, true +} + +// span says how much of a log or a file was read when only its end was: +// ", last 1.1 MiB of 5.3 MiB". +func (l logState) span() string { + if l.from <= 0 { + return "" + } + + return fmt.Sprintf(", last %s of %s", sizeOf(l.size-l.from), sizeOf(l.size)) +} + // linesOf are the lines of a chunk a stream sent: the newline it ends with // ends its last line and starts none. func linesOf(chunk string) []string { @@ -333,6 +420,10 @@ func (m Model) logToggles(width int) string { cells = append(cells, styleLabel.Render(toggle.name+":")+state) } + if m.text.shift > 0 { + cells = append(cells, styleLabel.Render("Column:")+styleTitle.Render(sprintf("%d", m.text.shift+1))) + } + // Centered under the title, which is centered too. line := strings.Join(cells, strings.Repeat(" ", logToggleGap)) left := max((width-ansi.StringWidth(line))/2, 0) @@ -357,6 +448,21 @@ func (m Model) readsAStream() bool { return ok } +// keepPlace keeps the window on the lines it showed after n lines were put +// in front of the text. A window that follows the end stays at the end. +func keepPlace(m Model, n int) (Model, tea.Cmd) { + m.layout() + + for _, rows := range m.text.kept.counts[:min(n, len(m.text.kept.counts))] { + m.text.top += rows + m.text.at += rows + } + + m.text.follow() + + return m, nil +} + // showTimes puts when each line was read in front of it. Only a log has // times to show: they are when urga read a line, and nothing else on a text // screen has any. @@ -371,16 +477,12 @@ func showTimes(m Model) (Model, tea.Cmd) { // a task, a description, a job file. Pressed again, it unwraps them. func wrapLines(m Model) (Model, tea.Cmd) { m.text.wrap = !m.text.wrap + m.text.shift = 0 m.text.follow() return m, nil } -// saveScreen writes what is on a text screen to a file. -func saveScreen(m Model) (Model, tea.Cmd) { - return m, m.saveText() -} - // followStdout follows what the task under the cursor writes to stdout. func followStdout(p tasksPage, e env) (tasksPage, outcome) { return followLogs(p, e, nomad.LogStdout) diff --git a/internal/ui/logs_test.go b/internal/ui/logs_test.go index d6aa5c9..2b673e4 100644 --- a/internal/ui/logs_test.go +++ b/internal/ui/logs_test.go @@ -678,7 +678,8 @@ func TestLogs_SavedUnderTheTaskAndWhatItWrites(t *testing.T) { m, cmd := m.update(ctrlKey('e')) m = drain(m, cmd) - m, cmd = m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd = m.update(enter()) drain(m, cmd) files, err := filepath.Glob(filepath.Join(dir, "server-stderr-*.log")) diff --git a/internal/ui/logtools_test.go b/internal/ui/logtools_test.go index 1287d56..dadb731 100644 --- a/internal/ui/logtools_test.go +++ b/internal/ui/logtools_test.go @@ -197,7 +197,8 @@ func TestLogs_SaveWhatIsOnTheScreen(t *testing.T) { m, _ := onLogs(t, "ready to serve\n", "connection refused\n") - m, cmd := m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) m = drain(m, cmd) files, err := filepath.Glob(filepath.Join(dir, "*.log")) @@ -210,7 +211,7 @@ func TestLogs_SaveWhatIsOnTheScreen(t *testing.T) { r.Contains(string(saved), "connection refused") // The screen shows the file it was saved to. - r.Contains(plain(m.render()), filepath.Base(files[0])) + r.Contains(m.flash.text, filepath.Base(files[0])) } func TestLogs_SaveADescriptionToo(t *testing.T) { @@ -227,7 +228,8 @@ func TestLogs_SaveADescriptionToo(t *testing.T) { m, cmd := m.update(key('d')) m = drain(m, cmd) - m, cmd = m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd = m.update(enter()) drain(m, cmd) // The file is named after what was described. @@ -283,7 +285,8 @@ func TestLogs_SaveWhatTheFilterLeft(t *testing.T) { m = typeIn(m, "refused") m, _ = m.update(enter()) - m, cmd := m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) drain(m, cmd) files, _ := filepath.Glob(filepath.Join(dir, "*.log")) diff --git a/internal/ui/nodepools_test.go b/internal/ui/nodepools_test.go index 9d546eb..f62e964 100644 --- a/internal/ui/nodepools_test.go +++ b/internal/ui/nodepools_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/ingvarch/urga/internal/config" "github.com/ingvarch/urga/internal/nomad" ) @@ -42,7 +41,7 @@ func TestNodePools_ListsThePoolsOfTheCluster(t *testing.T) { func TestNodePools_ASessionStartsOnThem(t *testing.T) { r := require.New(t) - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) cfg.Screen = "nodepools" diff --git a/internal/ui/page.go b/internal/ui/page.go index 3c3b3d2..cfe7f5b 100644 --- a/internal/ui/page.go +++ b/internal/ui/page.go @@ -253,6 +253,10 @@ type ( followMsg struct{} timesMsg struct{} + // linesBeforeMsg says that many lines were put in front of the text: the + // window stays on the lines it showed. + linesBeforeMsg int + // findWordMsg asks for a word to find in the text, and matchMsg goes to the // next match of it, or the previous one when negative. findWordMsg struct{} diff --git a/internal/ui/plan_test.go b/internal/ui/plan_test.go index ba04fc7..eacafe1 100644 --- a/internal/ui/plan_test.go +++ b/internal/ui/plan_test.go @@ -598,7 +598,8 @@ func TestPlan_Saved(t *testing.T) { client := &fakeClient{jobs: twoJobs(), spec: nomad.JobSource{Source: "job \"web\" {}", Index: 42}, plan: changedEnv()} m := planned(t, client) - m, cmd := m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) drain(m, cmd) files, err := filepath.Glob(filepath.Join(dir, "urga-*.txt")) diff --git a/internal/ui/roles.go b/internal/ui/roles.go index 92e12e9..6666efa 100644 --- a/internal/ui/roles.go +++ b/internal/ui/roles.go @@ -103,6 +103,7 @@ type ( // of its allocation. filesClient interface { Logs(ctx context.Context, namespace, allocID, task, source string) (*nomad.LogStream, error) + LogBefore(ctx context.Context, namespace, allocID string, at nomad.LogMark, limit int) (nomad.LogPart, error) Files(ctx context.Context, namespace, allocID, path string) ([]nomad.File, error) File(ctx context.Context, namespace, allocID, path string) (*nomad.LogStream, error) } diff --git a/internal/ui/run.go b/internal/ui/run.go index 03f0a5d..30d12e3 100644 --- a/internal/ui/run.go +++ b/internal/ui/run.go @@ -33,26 +33,42 @@ func (m Model) run(path string) (Model, tea.Cmd) { // readJobFile reads the file at path, which can start with ~/ for the home // directory. func readJobFile(path string) (string, error) { - full := path + full, err := localPath(path) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", path, err) + } - if rest, ok := strings.CutPrefix(path, "~/"); ok { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("cannot read %s: %w", path, err) - } + content, err := os.ReadFile(full) + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", path, reason(err)) + } + + return string(content), nil +} - full = filepath.Join(home, rest) +// localPath is a path typed by a person: ~/ is the home directory, anything +// else is as typed, so a relative path is from where urga runs. +func localPath(typed string) (string, error) { + rest, ok := strings.CutPrefix(typed, "~/") + if !ok { + return typed, nil } - content, err := os.ReadFile(full) + home, err := os.UserHomeDir() if err != nil { - var pathErr *fs.PathError - if errors.As(err, &pathErr) { - err = pathErr.Err - } + return "", err + } - return "", fmt.Errorf("cannot read %s: %w", path, err) + return filepath.Join(home, rest), nil +} + +// reason leaves out the path a *fs.PathError repeats: the message already +// names it. +func reason(err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err } - return string(content), nil + return err } diff --git a/internal/ui/save.go b/internal/ui/save.go index 6d0763f..63d416f 100644 --- a/internal/ui/save.go +++ b/internal/ui/save.go @@ -2,8 +2,10 @@ package ui import ( "context" + "errors" "fmt" "os" + "path/filepath" "regexp" "strings" "time" @@ -14,23 +16,81 @@ import ( // savedMsg is the file the screen was saved to. type savedMsg struct{ path string } -// saveText writes what the screen shows to a file next to where urga runs, -// so that a log or a description can be read after urga is closed. -func (m Model) saveText() tea.Cmd { +// askSave asks where to save what the screen shows, with a name already +// typed, so that a log or a description can be read after urga is closed. +func (m Model) askSave() (Model, tea.Cmd) { if !m.readsAsText() { - return nil + return m, nil } - name := saveName(m.screen.page) - content := strings.Join(m.shownLines(), "\n") + return m.askLine(lineMsg{prefix: "save to: ", text: saveName(m.screen.page), answer: saveTo}) +} + +// saveTo saves what the screen shows when enter is pressed, not when the +// line was opened: a log goes on while it is typed. +func saveTo(m Model, typed string) (Model, tea.Cmd) { + path := strings.TrimSpace(typed) + if path == "" { + return m.warn("Saving needs a path."), nil + } - return request(func(context.Context) (savedMsg, error) { - if err := os.WriteFile(name, []byte(content), 0o600); err != nil { - return savedMsg{}, err + return m, saveFile(path, strings.Join(m.shownLines(), "\n"), false) +} + +// saveFile writes content to path. A file that is there is asked about +// first, unless overwrite says it was. +func saveFile(path, content string, overwrite bool) tea.Cmd { + return request(func(context.Context) (tea.Msg, error) { + abs, err := writeText(path, content, overwrite) + if errors.Is(err, errThere) { + return askMsg{question: "Really overwrite " + abs + "?", apply: saveFile(path, content, true)}, nil } - return savedMsg{path: name}, nil - }, func(msg savedMsg) tea.Msg { return msg }) + if errors.Is(err, errFolder) { + return nil, fmt.Errorf("cannot save to %s: %w", abs, reason(err)) + } + + if err != nil { + return nil, fmt.Errorf("cannot save to %s: %w", path, reason(err)) + } + + return savedMsg{path: abs}, nil + }, func(msg tea.Msg) tea.Msg { return msg }) +} + +var ( + errFolder = errors.New("it is a folder") + errThere = errors.New("a file is there") +) + +// writeText makes the folders of path and writes the file. It returns +// errThere when the file is there and overwrite is not set: fs.ErrExist +// also comes from MkdirAll, for a folder that is a broken link. +func writeText(path, content string, overwrite bool) (string, error) { + full, err := localPath(path) + if err != nil { + return "", err + } + + abs, err := filepath.Abs(full) + if err != nil { + return "", err + } + + info, err := os.Stat(abs) + + switch { + case err == nil && info.IsDir(): + return abs, errFolder + case err == nil && !overwrite: + return abs, errThere + } + + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return abs, err + } + + return abs, os.WriteFile(abs, []byte(content), 0o600) } // shownLines is the text as the screen shows it. What is saved or copied is diff --git a/internal/ui/save_test.go b/internal/ui/save_test.go new file mode 100644 index 0000000..6dde91f --- /dev/null +++ b/internal/ui/save_test.go @@ -0,0 +1,272 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// savedAt is the path the status line says the file went to. +func savedAt(t *testing.T, m Model) string { + t.Helper() + + text := m.flash.text + require.True(t, strings.HasPrefix(text, "Saved to "), text) + require.True(t, strings.HasSuffix(text, "."), text) + + return strings.TrimSuffix(strings.TrimPrefix(text, "Saved to "), ".") +} + +// sameFile checks that two paths name the same place: the temp folder of the +// system can be reached through a link. +func sameFile(t *testing.T, want, got string) { + t.Helper() + + dir, err := filepath.EvalSymlinks(filepath.Dir(want)) + require.NoError(t, err) + + other, err := filepath.EvalSymlinks(filepath.Dir(got)) + require.NoError(t, err) + + require.Equal(t, filepath.Join(dir, filepath.Base(want)), filepath.Join(other, filepath.Base(got))) +} + +// saveTyped presses ctrl+s, replaces the name with path, and presses enter. +func saveTyped(t *testing.T, m Model, path string) Model { + t.Helper() + + m, _ = m.update(ctrlKey('s')) + m, _ = m.update(ctrlKey('u')) + m = typeIn(m, path) + m, cmd := m.update(enter()) + + return drain(m, cmd) +} + +func TestSave_OpensTheLineWithTheNameAndWritesNothingYet(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m, _ := onLogs(t, "ready to serve\n") + + m, cmd := m.update(ctrlKey('s')) + r.Nil(cmd) + r.Equal(overlayAnswer, m.overlay) + r.Contains(plain(m.render()), "save to: server-stdout-") + r.Contains(plain(m.render()), ".log") + + files, err := os.ReadDir(dir) + r.NoError(err) + r.Empty(files) +} + +func TestSave_EnterKeepsTheName(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m, _ := onLogs(t, "ready to serve\n", "connection refused\n") + + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) + m = drain(m, cmd) + + files, err := filepath.Glob(filepath.Join(dir, "server-stdout-*.log")) + r.NoError(err) + r.Len(files, 1) + + saved, err := os.ReadFile(files[0]) + r.NoError(err) + r.Equal("ready to serve\nconnection refused", strings.TrimSpace(string(saved))) + + sameFile(t, files[0], savedAt(t, m)) + r.True(filepath.IsAbs(savedAt(t, m))) +} + +func TestSave_TheHomeFolderIsTheTilde(t *testing.T) { + r := require.New(t) + + t.Chdir(t.TempDir()) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + m, _ := onLogs(t, "ready to serve\n") + m = saveTyped(t, m, "~/out/web.log") + + saved, err := os.ReadFile(filepath.Join(home, "out", "web.log")) + r.NoError(err) + r.Contains(string(saved), "ready to serve") + sameFile(t, filepath.Join(home, "out", "web.log"), savedAt(t, m)) +} + +func TestSave_ARelativePathIsFromWhereUrgaRuns(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m, _ := onLogs(t, "ready to serve\n") + m = saveTyped(t, m, "logs/a/web.log") + + saved, err := os.ReadFile(filepath.Join(dir, "logs", "a", "web.log")) + r.NoError(err) + r.Contains(string(saved), "ready to serve") + sameFile(t, filepath.Join(dir, "logs", "a", "web.log"), savedAt(t, m)) +} + +func TestSave_AnExistingFileIsAskedAboutFirst(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + target := filepath.Join(dir, "web.log") + r.NoError(os.WriteFile(target, []byte("old"), 0o600)) + + m, _ := onLogs(t, "ready to serve\n") + m = saveTyped(t, m, "web.log") + + r.Contains(plain(m.render()), "Really overwrite ") + + // Read from the question: the window cuts a long path. + question := m.confirm.question + r.True(strings.HasPrefix(question, "Really overwrite "), question) + r.True(strings.HasSuffix(question, "?"), question) + + asked := strings.TrimSuffix(strings.TrimPrefix(question, "Really overwrite "), "?") + r.True(filepath.IsAbs(asked), asked) + sameFile(t, target, asked) + + m, _ = m.update(key('n')) + + kept, err := os.ReadFile(target) + r.NoError(err) + r.Equal("old", string(kept)) + + m = saveTyped(t, m, "web.log") + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + + saved, err := os.ReadFile(target) + r.NoError(err) + r.Contains(string(saved), "ready to serve") + sameFile(t, target, savedAt(t, m)) +} + +func TestSave_AFolderIsNotWrittenOver(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + r.NoError(os.Mkdir(filepath.Join(dir, "out"), 0o755)) + + m, _ := onLogs(t, "ready to serve\n") + m = saveTyped(t, m, "./out") + + r.Contains(m.flash.text, "cannot save to ") + r.Contains(m.flash.text, string(filepath.Separator)+"out: it is a folder") + r.NotContains(m.flash.text, "./out") + r.NotContains(plain(m.render()), "Really overwrite") + + entries, err := os.ReadDir(filepath.Join(dir, "out")) + r.NoError(err) + r.Empty(entries) +} + +func TestSave_AFolderThatIsABrokenLinkFailsWithoutAQuestion(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + r.NoError(os.Symlink(filepath.Join(dir, "missing"), filepath.Join(dir, "link"))) + + m, _ := onLogs(t, "ready to serve\n") + m = saveTyped(t, m, "link/x.log") + + r.True(strings.HasPrefix(m.flash.text, "cannot save to link/x.log: "), m.flash.text) + r.NotContains(m.flash.text, "mkdir", "the reason does not repeat the path") + r.NotEqual(overlayConfirm, m.overlay) + r.NotContains(plain(m.render()), "Really overwrite") +} + +func TestSave_EscSavesNothingAndAnEmptyLineWarns(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m, _ := onLogs(t, "ready to serve\n") + + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(escape()) + m = drain(m, cmd) + + r.Equal(overlayNone, m.overlay) + + files, err := os.ReadDir(dir) + r.NoError(err) + r.Empty(files) + + m = saveTyped(t, m, "") + r.Equal("Saving needs a path.", m.flash.text) + + files, err = os.ReadDir(dir) + r.NoError(err) + r.Empty(files) +} + +func TestSave_ALineThatArrivesWhileTheLineIsOpenIsInTheFile(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m, _ := onLogs(t, "ready to serve\n") + + m, _ = m.update(ctrlKey('s')) + m = withLines(m, []string{"connection refused\n"}) + + m, cmd := m.update(enter()) + drain(m, cmd) + + files, err := filepath.Glob(filepath.Join(dir, "*.log")) + r.NoError(err) + r.Len(files, 1) + + saved, err := os.ReadFile(files[0]) + r.NoError(err) + r.Contains(string(saved), "connection refused") +} + +func TestSave_ADescriptionSavesTheSameWay(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m := described(t, `{"ID": "web"}`) + + m, _ = m.update(ctrlKey('s')) + r.Contains(plain(m.render()), "save to: ") + + m, cmd := m.update(enter()) + m = drain(m, cmd) + + files, err := filepath.Glob(filepath.Join(dir, "*.txt")) + r.NoError(err) + r.Len(files, 1) + + saved, err := os.ReadFile(files[0]) + r.NoError(err) + r.Contains(string(saved), `"ID": "web"`) + sameFile(t, files[0], savedAt(t, m)) +} diff --git a/internal/ui/screen.go b/internal/ui/screen.go index aeb2fd4..885cb5f 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -201,7 +201,15 @@ func (m Model) offeredKey(press string) (keyHint, bool) { // title labels the box with what it holds and how much of it. The count is // what is on the screen, which the filter narrows. func (m Model) title() string { - return m.screen.page.title(m.env(), len(m.list.table.rows)) + title := m.screen.page.title(m.env(), len(m.list.table.rows)) + + // A screen with a line of toggles says the column there: a log or file + // title is long, and the edge would cut the column off. + if m.readsAsText() && m.toggleRows() == 0 && m.text.shift > 0 { + title += sprintf(", from column %d", m.text.shift+1) + } + + return title } // rows are what the open screen shows. diff --git a/internal/ui/scroll.go b/internal/ui/scroll.go index f41a195..237cdfe 100644 --- a/internal/ui/scroll.go +++ b/internal/ui/scroll.go @@ -19,8 +19,20 @@ func (m Model) scrollKey(msg tea.KeyPressMsg) (Model, bool) { return m.scroll(-page), true case "pgdown", "ctrl+f": return m.scroll(page), true - case "home", "g": + case "home": + if m.text.shift > 0 && m.readsAsText() { + m.text.shift = 0 + + return m, true + } + return m.scroll(-end), true + case "g": + return m.scroll(-end), true + case "left", "h": + return m.slide(-1) + case "right", "l": + return m.slide(1) case "end", "G": return m.scroll(end), true } @@ -28,6 +40,18 @@ func (m Model) scrollKey(msg tea.KeyPressMsg) (Model, bool) { return m, false } +// slide moves a text without wrap sideways by a quarter of its width. On a +// list, and with wrap on, the key is not taken. +func (m Model) slide(direction int) (Model, bool) { + if !m.readsAsText() || m.text.wrap { + return m, false + } + + m.text.slide(direction * max(m.text.width/4, 1)) + + return m, true +} + // scroll moves the screen by that many lines. func (m Model) scroll(by int) Model { if m.readsAsText() { diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go index 378078e..34f99e3 100644 --- a/internal/ui/session_test.go +++ b/internal/ui/session_test.go @@ -1,10 +1,12 @@ package ui import ( + "os" "path/filepath" "testing" "time" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/require" "github.com/ingvarch/urga/internal/config" @@ -20,7 +22,7 @@ func sessionModel(t *testing.T, client Client) (Model, *config.Config) { func sessionModelAt(t *testing.T, client Client, path string) (Model, *config.Config) { t.Helper() - cfg := config.Load(path) + cfg := sessionConfig(t, path) m := New(client, Options{Namespace: "production", Version: "v-test", Config: cfg, PollEvery: time.Millisecond}) m, _ = m.update(sizeMsg()) @@ -28,6 +30,45 @@ func sessionModelAt(t *testing.T, client Client, path string) (Model, *config.Co return m, cfg } +// sessionConfig loads the config at path for a test. A write the test left +// running, which a slow machine takes for a timer, finishes before the +// folder is removed, and one that starts later finds a newer snapshot +// written and writes nothing. +func sessionConfig(t *testing.T, path string) *config.Config { + t.Helper() + + cfg := config.Load(path) + + t.Cleanup(func() { + snapshot, err := cfg.Snapshot() + require.NoError(t, err) + require.NoError(t, cfg.Write(snapshot)) + }) + + return cfg +} + +func TestSessionModel_AWriteLeftRunningDoesNothingOnceTheTestEnds(t *testing.T) { + var ( + late tea.Cmd + dir string + ) + + t.Run("a test that leaves a write running", func(t *testing.T) { + dir = t.TempDir() + m, _ := sessionModelAt(t, &fakeClient{}, filepath.Join(dir, "config.json")) + late = m.remember() + }) + + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + // A slow machine runs it after the test ended and removed its folder. + late() + + _, err := os.Stat(dir) + require.ErrorIs(t, err, os.ErrNotExist, "the late write made the folder again") +} + func TestSession_RemembersTheNamespace(t *testing.T) { r := require.New(t) @@ -72,7 +113,7 @@ func TestSession_RemembersTheOrderOfTheKeys(t *testing.T) { func TestSession_StartsWhereItStopped(t *testing.T) { r := require.New(t) - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) cfg.UseNamespace(nomad.AllNamespaces) cfg.Screen = "nodes" @@ -90,7 +131,7 @@ func TestSession_StartsWhereItStopped(t *testing.T) { func TestSession_ANamespaceGivenOnTheCommandLineWins(t *testing.T) { r := require.New(t) - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) cfg.UseNamespace("staging") cfg.Screen = "nodes" @@ -169,7 +210,7 @@ func TestSession_RemembersTheScreenAfterGoingBack(t *testing.T) { func TestSession_EachClusterComesBackToItsOwn(t *testing.T) { r := require.New(t) - cfg := config.Load(filepath.Join(t.TempDir(), "config.json")) + cfg := sessionConfig(t, filepath.Join(t.TempDir(), "config.json")) cfg.Of("").UseNamespace("default") cfg.Of("prod").UseNamespace("batch") diff --git a/internal/ui/sideways_test.go b/internal/ui/sideways_test.go new file mode 100644 index 0000000..3726bcd --- /dev/null +++ b/internal/ui/sideways_test.go @@ -0,0 +1,448 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/ingvarch/urga/internal/nomad" + "github.com/stretchr/testify/require" +) + +// longLine is 300 cells: the letters in turn, then a marker at the end. +func longLine() string { + line := make([]byte, 295) + for i := range line { + line[i] = byte('a' + i%26) + } + + return string(line) + "\n" +} + +func onLongLine(t *testing.T) Model { + t.Helper() + + m, _ := onLogs(t, longLine()) + + return m +} + +func arrow(code rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: code} } + +func right(m Model) Model { + m, _ = m.update(arrow(tea.KeyRight)) + + return m +} + +func left(m Model) Model { + m, _ = m.update(arrow(tea.KeyLeft)) + + return m +} + +// firstRow is the first row of the text on the screen, without colours. +func firstRow(m Model) string { + first, _, _ := strings.Cut(plain(m.text.view()), "\n") + + return strings.TrimRight(first, " ") +} + +func TestSideways_RightMovesByAQuarterOfTheWidth(t *testing.T) { + r := require.New(t) + + m := onLongLine(t) + step := m.text.width / 4 + line := strings.TrimSpace(longLine()) + + r.True(strings.HasPrefix(firstRow(m), line[:5])) + r.NotContains(togglesOf(m), "Column") + + m = right(m) + r.True(strings.HasPrefix(firstRow(m), line[step:step+5]), firstRow(m)) + r.Contains(togglesOf(m), sprintf("Column:%d", step+1)) + + m, _ = m.update(key('l')) + r.True(strings.HasPrefix(firstRow(m), line[2*step:2*step+5]), firstRow(m)) + r.Contains(togglesOf(m), sprintf("Column:%d", 2*step+1)) +} + +func TestSideways_LeftAndHMoveBackAndStopAtColumnOne(t *testing.T) { + r := require.New(t) + + m := right(right(onLongLine(t))) + + m = left(m) + r.Contains(togglesOf(m), sprintf("Column:%d", m.text.width/4+1)) + + m, _ = m.update(key('h')) + r.NotContains(togglesOf(m), "Column") + + m = left(m) + r.NotContains(togglesOf(m), "Column") + r.Zero(m.text.shift) +} + +func TestSideways_StopsWhereTheWidestLineMeetsTheRightEdge(t *testing.T) { + r := require.New(t) + + m := onLongLine(t) + for range 50 { + m = right(m) + } + + r.Contains(plain(m.text.view()), "") + r.Equal(300-m.text.width, m.text.shift) + + shift := m.text.shift + m = right(m) + r.Equal(shift, m.text.shift) + + short, _ := onLogs(t, "short\n") + short = right(short) + r.Zero(short.text.shift) + r.NotContains(togglesOf(short), "Column") +} + +func TestSideways_AWideCharacterTheLeftEdgeCutsLeavesABlank(t *testing.T) { + r := require.New(t) + + line := strings.Repeat("日本", 100) + "" + + m, _ := onLogs(t, line+"\n") + for range 50 { + m = right(m) + } + + shift := m.text.shift + r.Equal(ansi.StringWidth(line)-m.text.width, shift) + r.Equal(1, shift%2, "the left edge falls inside a character") + + // The half that shows is a blank; the next character is column shift+2. + r.Equal(" "+string([]rune(line)[(shift+1)/2:]), firstRow(m)) + r.Contains(togglesOf(m), sprintf("Column:%d", shift+1)) +} + +func TestSideways_HomeGoesBackToTheStartOfTheLinesFirst(t *testing.T) { + r := require.New(t) + + lines := []string{} + for range 100 { + lines = append(lines, longLine()) + } + + m, _ := onLogs(t, lines...) + m, _ = m.update(key('G')) + m = right(m) + top := m.text.top + r.NotZero(top) + + m, _ = m.update(tea.KeyPressMsg{Code: tea.KeyHome}) + r.Zero(m.text.shift) + r.Equal(top, m.text.top) + + m, _ = m.update(tea.KeyPressMsg{Code: tea.KeyHome}) + r.Zero(m.text.top) +} + +func TestSideways_GGoesToTheTopAndKeepsTheShift(t *testing.T) { + r := require.New(t) + + lines := []string{} + for range 100 { + lines = append(lines, longLine()) + } + + m, _ := onLogs(t, lines...) + m, _ = m.update(key('G')) + m = right(m) + shift := m.text.shift + r.NotZero(shift) + r.NotZero(m.text.top) + + m, _ = m.update(key('g')) + r.Zero(m.text.top) + r.Equal(shift, m.text.shift) +} + +func TestSideways_WrapPutsItBackAndHoldsIt(t *testing.T) { + r := require.New(t) + + m := right(onLongLine(t)) + r.NotZero(m.text.shift) + + m, _ = m.update(key('w')) + r.Zero(m.text.shift) + r.True(m.text.wrap) + + m = right(m) + r.Zero(m.text.shift) + m = left(m) + r.Zero(m.text.shift) +} + +func TestSideways_FindAndFilterLightTheirMatchesWhereTheyAre(t *testing.T) { + r := require.New(t) + + m := onLongLine(t) + for range 50 { + m = right(m) + } + + found := foundIn(m, "END") + r.Contains(found.text.view(), styleMatch.Render("END")) + + m, _ = m.update(key('/')) + m = typeIn(m, "END") + m, _ = m.update(enter()) + r.Contains(m.text.view(), styleMatch.Render("END")) + + // A match the left edge cuts is lit on the part that shows. + cut := foundIn(onLongLine(t), "abcdefghij") + cut.text.shift = 3 + r.Contains(cut.text.view(), styleMatch.Render("defghij")) +} + +func TestSideways_ThePlanKeepsLeftAndRightForItsButtons(t *testing.T) { + r := require.New(t) + + wide := changedEnv() + wide.Diff = append(wide.Diff, nomad.DiffLine{Kind: nomad.DiffAdded, Indent: 3, Text: longLine()}) + + m := planned(t, &fakeClient{jobs: twoJobs(), spec: nomad.JobSource{Source: "job \"web\" {}", Index: 42}, plan: wide}) + r.Greater(m.text.width, 0) + + choice := func(m Model) int { return int(m.screen.page.(planPage).choice) } + + for _, press := range []struct { + key tea.KeyPressMsg + button int + }{ + {arrow(tea.KeyRight), int(buttonConfirm)}, + {arrow(tea.KeyLeft), int(buttonCancel)}, + {key('l'), int(buttonConfirm)}, + {key('h'), int(buttonCancel)}, + } { + m, _ = m.update(press.key) + r.Zero(m.text.shift) + r.Equal(press.button, choice(m)) + } + + r.NotContains(m.title(), "from column") +} + +func TestSideways_ADescriptionMovesToo(t *testing.T) { + r := require.New(t) + + m := everyScreen(t)["jobs"] + m, _ = m.update(describeMsg{label: "Job: web", content: longLine()}) + + r.Zero(m.toggleRows()) + + m = right(m) + + step := m.text.width/4 + 1 + r.Equal(step-1, m.text.shift) + r.True(strings.HasPrefix(firstRow(m), strings.TrimSpace(longLine())[step-1:step+4]), firstRow(m)) + r.Contains(m.title(), sprintf("from column %d", step)) +} + +// togglesOf is the line of toggles under the title, without colours. +func togglesOf(m Model) string { return plain(m.logToggles(m.innerWidth())) } + +func TestSideways_AStreamSaysTheColumnOnItsTogglesLine(t *testing.T) { + r := require.New(t) + + m := onEnv(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}) + m, cmd := m.update(fileMsg{ + stream: written(longLine()), + allocID: "af1f37df-7b19-6b1c-da67-5e8f482b5a15", + path: "/server/local/application.production.settings.properties", + }) + m = follow(m, cmd, 4) + r.IsType(filePage{}, m.screen.page) + + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 30}) + r.Greater(ansi.StringWidth(m.title()), 80, "the title is cut on the screen") + r.NotContains(togglesOf(m), "Column") + + m = right(m) + column := sprintf("Column:%d", m.text.shift+1) + + r.NotZero(m.text.shift) + r.Contains(togglesOf(m), column) + r.Contains(plain(m.render()), column) + r.NotContains(m.title(), "from column") +} + +func TestSideways_SavingAfterMovingSavesTheWholeLine(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m := right(onLongLine(t)) + r.NotZero(m.text.shift) + + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) + drain(m, cmd) + + files, err := filepath.Glob(filepath.Join(dir, "*.log")) + r.NoError(err) + r.Len(files, 1) + + saved, err := os.ReadFile(files[0]) + r.NoError(err) + r.Equal(strings.TrimSpace(longLine()), strings.TrimSpace(string(saved))) +} + +func TestSideways_HelpNamesTheKeysOnATextScreenOnly(t *testing.T) { + r := require.New(t) + + navigation := func(m Model) []hint { + sections := m.helpSections() + + return sections[len(sections)-1].hints + } + + r.Contains(navigation(onLongLine(t)), hint{Key: "", Description: "Line Start"}) + r.NotContains(navigation(everyScreen(t)["jobs"]), hint{Key: "", Description: "Line Start"}) + r.NotContains(navigation(everyScreen(t)["plan"]), hint{Key: "", Description: "Line Start"}) +} + +// farRight is a log of a 300-cell line and others, moved as far right as it +// goes. +func farRight(t *testing.T, lines ...string) Model { + t.Helper() + + m, _ := onLogs(t, append([]string{longLine()}, lines...)...) + for range 50 { + m = right(m) + } + + require.Equal(t, 300-m.text.width, m.text.shift) + + return m +} + +func TestSideways_ATextThatNowFitsMovesBackToColumnOne(t *testing.T) { + steps := []struct { + name string + do func(m Model) Model + first string + }{ + {"a filter that keeps a short line", func(m Model) Model { + m, _ = m.update(key('/')) + m = typeIn(m, "short") + m, _ = m.update(enter()) + + return m + }, "short"}, + {"a wider window", func(m Model) Model { + m, _ = m.update(tea.WindowSizeMsg{Width: 400, Height: 30}) + + return m + }, strings.TrimSpace(longLine())}, + } + + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + r := require.New(t) + + m := step.do(farRight(t, "short\n")) + + r.Zero(m.text.shift) + r.NotContains(togglesOf(m), "Column") + r.Equal(step.first, firstRow(m)) + }) + } +} + +func TestSideways_TheWidestLineLeavingTheFrontMovesBackToColumnOne(t *testing.T) { + r := require.New(t) + + m := farRight(t) + m, _ = m.update(lineOf(m, strings.Join(linesFrom(0, maxTextLines), "\n")+"\n")) + + r.Equal(1, m.text.dropped) + r.Zero(m.text.shift) + r.NotContains(togglesOf(m), "Column") +} + +// described is a description of content opened from the jobs. +func described(t *testing.T, content string) Model { + t.Helper() + + m, _ := everyScreen(t)["jobs"].update(describeMsg{label: "Job spec: web", content: content}) + + return m +} + +func TestSideways_ATabReadsAsTheFourSpacesItIsDrawnAs(t *testing.T) { + for _, wrap := range []bool{false, true} { + t.Run(sprintf("wrap %v", wrap), func(t *testing.T) { + m := described(t, "job {\t# x") + if wrap { + m, _ = m.update(key('w')) + } + + require.Equal(t, "job { # x", firstRow(m)) + }) + } +} + +func TestSideways_ATabIndentedLineShowsItsEndAsFarRightAsItGoes(t *testing.T) { + r := require.New(t) + + m := described(t, "\t"+strings.TrimSpace(longLine())) + for range 50 { + m = right(m) + } + + r.Equal(304-m.text.width, m.text.shift) + r.True(strings.HasSuffix(firstRow(m), ""), firstRow(m)) +} + +func TestSideways_SavingADescriptionKeepsItsTabs(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + m := described(t, "job {\t# x") + + m, _ = m.update(ctrlKey('s')) + m, cmd := m.update(enter()) + drain(m, cmd) + + files, err := filepath.Glob(filepath.Join(dir, "*.txt")) + r.NoError(err) + r.Len(files, 1) + + saved, err := os.ReadFile(files[0]) + r.NoError(err) + r.Equal("job {\t# x", strings.TrimSpace(string(saved))) +} + +func TestSideways_AListBackFromAMovedTextIsNotMoved(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), logs: &nomad.LogStream{Lines: make(chan string)}} + + m := openTasks(t, client) + m, _ = m.update(arrow(tea.KeyDown)) + m, cmd := m.update(enter()) + m = right(withLines(drain(m, cmd), []string{longLine()})) + r.NotZero(m.text.shift) + + m, _ = m.update(escape()) + r.Equal(1, m.list.table.cursor) + r.NotContains(m.title(), "from column") + + m, _ = m.update(tea.KeyPressMsg{Code: tea.KeyHome}) + r.Zero(m.list.table.cursor) +} diff --git a/internal/ui/text.go b/internal/ui/text.go index abe77f4..5fbe140 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -38,6 +38,9 @@ type textModel struct { top int + // shift is how many cells the window is moved right. + shift int + width int height int @@ -71,6 +74,9 @@ type rowCache struct { // counts are the rows of each of those lines: none when filtered out. counts []int rows []textRow + + // widest is the width of the widest row, in cells. + widest int } // maxTextLines is how many lines a text keeps. The tails of the logs fit, @@ -176,6 +182,35 @@ func (t *textContent) add(lines []string, arrived time.Time, label *tag, style * t.dropOld() } +// addBefore puts lines read from before the text in front of it. They have +// no time, tag or style, and the text gets a new id so that its rows are drawn +// afresh: the kept ones would take the new lines as added at the end. The +// caller keeps the text under the limit, so none are dropped. +func (t *textContent) addBefore(lines []string) { + n := len(lines) + front := make([]string, 0, n+len(t.lines)) + + for _, line := range lines { + front = append(front, cleanLine(line)) + } + + t.lines = append(front, t.lines...) + t.stamps = fronted(t.stamps, n, len(t.lines)-n) + t.tags = fronted(t.tags, n, len(t.lines)-n) + t.paint = fronted(t.paint, n, len(t.lines)-n) + t.id = &textID{} +} + +// fronted puts n unset items in front of s, after growing it to total items. +// A slice that was never set stays unset. +func fronted[T any](s []T, n, total int) []T { + if s == nil { + return nil + } + + return append(make([]T, n), grown(s, total)...) +} + // dropOld takes the lines past maxTextLines off the front. The slices are cut, // not copied: the next append grows them anew and frees the old front. func (t *textContent) dropOld() { @@ -235,6 +270,9 @@ type textRow struct { // width on screen. tagFrom, tagTo int tagStyle *lipgloss.Style + + // width is the cells the row takes on screen. + width int } // paintedLine is a line to put on a text screen, in a style of its own when @@ -297,6 +335,7 @@ func (t textModel) draw(from, to int) (rows []textRow, counts []int) { } if !t.wrap { + row.width = ansi.StringWidth(onScreen(row.text)) rows = append(rows, row) counts = append(counts, 1) @@ -307,7 +346,7 @@ func (t textModel) draw(from, to int) (rows []textRow, counts []int) { // tag is on the first of them. parts := wrapLine(row.text, t.width) for n, part := range parts { - out := textRow{text: part, style: row.style} + out := textRow{text: part, style: row.style, width: ansi.StringWidth(onScreen(part))} if n == 0 { out.tagFrom, out.tagTo, out.tagStyle = row.tagFrom, row.tagTo, row.tagStyle } @@ -396,6 +435,11 @@ func (t *textModel) refresh() { gone += count } + // The rows left are measured again only when the widest one left. + if gone > 0 && widestOf(k.rows[:gone]) == k.widest { + k.widest = widestOf(k.rows[gone:]) + } + k.rows = k.rows[gone:] k.counts = k.counts[t.dropped-first:] t.top = max(t.top-gone, 0) @@ -405,12 +449,23 @@ func (t *textModel) refresh() { k.rows = append(k.rows, rows...) k.counts = append(k.counts, counts...) k.lines = t.total() + k.widest = max(k.widest, widestOf(rows)) return } rows, counts := t.draw(0, len(t.lines)) - *k = rowCache{id: t.id, settings: settings, lines: t.total(), counts: counts, rows: rows} + *k = rowCache{id: t.id, settings: settings, lines: t.total(), counts: counts, rows: rows, widest: widestOf(rows)} +} + +// widestOf is the width of the widest of rows, in cells. +func widestOf(rows []textRow) int { + widest := 0 + for _, row := range rows { + widest = max(widest, row.width) + } + + return widest } // wrapLine breaks a line into rows of the given width, counted in cells on @@ -456,9 +511,11 @@ func (t *textModel) toEnd() { t.move(t.length()) } -// follow moves the window back inside the lines: a move by zero does that. +// follow moves the window back inside the lines: a move by zero does that, +// down and sideways. func (t *textModel) follow() { t.move(0) + t.slide(0) } func (t textModel) view() string { @@ -475,12 +532,6 @@ func (t textModel) view() string { // line draws one row in its colour, with the matches highlighted. func (t textModel) line(row textRow) string { - line := truncate(row.text, t.width) - gap := strings.Repeat(" ", max(t.width-ansi.StringWidth(line), 0)) - - // The edge may cut the row inside the tag, in the middle of the "…" - // mark: splitting the row by cells ends every piece on a whole - // character. style := styleText if row.style != nil { style = *row.style @@ -491,9 +542,48 @@ func (t textModel) line(row textRow) string { label = *row.tagStyle } - return t.lit(ansi.Cut(line, 0, row.tagFrom), style) + - t.lit(ansi.Cut(line, row.tagFrom, row.tagTo), label) + - t.lit(ansi.TruncateLeft(line, row.tagTo, ""), style) + style.Render(gap) + // The whole row is lit first, so that a match the left edge cuts is lit + // on the part that shows; TruncateLeft keeps the styles it drops. + // tagFrom and tagTo count cells: splitting the row by cells ends every + // piece on a whole character. + text := onScreen(row.text) + lit := t.lit(ansi.Cut(text, 0, row.tagFrom), style) + + t.lit(ansi.Cut(text, row.tagFrom, row.tagTo), label) + + t.lit(ansi.TruncateLeft(text, row.tagTo, ""), style) + + // TruncateLeft keeps a wide character whole when the edge cuts it in + // half. The half that shows is drawn as a blank, so the row starts at + // the shift. + cut := ansi.TruncateLeft(lit, t.shift, "") + if ansi.StringWidth(cut) > max(row.width-t.shift, 0) { + cut = style.Render(" ") + ansi.TruncateLeft(lit, t.shift+1, "") + } + + shown := truncate(cut, t.width) + gap := strings.Repeat(" ", max(t.width-ansi.StringWidth(shown), 0)) + + return shown + style.Render(gap) +} + +// tabSpaces is a tab as lipgloss draws it. +var tabSpaces = strings.Repeat(" ", tabWidth) + +// onScreen is a row as it is drawn, so that its width and the edges that cut +// it count the same cells. The lines keep their tabs for saving and copying. +func onScreen(text string) string { + return strings.ReplaceAll(text, "\t", tabSpaces) +} + +// slide moves the window sideways by that many cells, from the start of the +// lines to where the widest one meets the right edge. A wrapped text has no +// sides. +func (t *textModel) slide(by int) { + if t.wrap { + return + } + + t.refresh() + t.shift = max(0, min(t.shift+by, t.kept.widest-t.width)) } // lit is a piece of a row in its style, with the matches of the found word diff --git a/internal/ui/textrows_test.go b/internal/ui/textrows_test.go index d91ba96..8dd8414 100644 --- a/internal/ui/textrows_test.go +++ b/internal/ui/textrows_test.go @@ -8,6 +8,7 @@ import ( "time" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/require" "github.com/ingvarch/urga/internal/nomad" @@ -21,6 +22,16 @@ func drawnInFull(t textModel) []textRow { return rows } +// widestDrawn is the width in cells of the widest row of a text drawn in full. +func widestDrawn(t textModel) int { + widest := 0 + for _, row := range drawnInFull(t) { + widest = max(widest, ansi.StringWidth(row.text)) + } + + return widest +} + // arrivedAt is the time the test lines arrive: fixed, so the stamp never // holds what a filter looks for. var arrivedAt = time.Date(2026, 10, 15, 9, 30, 0, 0, time.UTC) @@ -107,6 +118,7 @@ func TestText_KeptRowsEqualRowsDrawnInFull(t *testing.T) { // The rows come from the cache, not from a draw in full. require.True(t, text.kept.matches(text), "%s: the rows are kept up to date", step.name) require.Equal(t, drawnInFull(text), text.kept.rows, step.name) + require.Equal(t, widestDrawn(text), text.kept.widest, step.name) } } @@ -222,6 +234,7 @@ func TestText_ALongTextHasItsFrontDroppedWithoutMissingRows(t *testing.T) { require.True(t, slices.Equal(drawnInFull(text), text.rows()), step.name) require.True(t, text.kept.matches(text), "%s: the rows are kept up to date", step.name) + require.Equal(t, widestDrawn(text), text.kept.widest, step.name) } } diff --git a/internal/ui/update.go b/internal/ui/update.go index 2e36553..dadd334 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -17,6 +17,7 @@ func (m Model) update(msg tea.Msg) (Model, tea.Cmd) { for _, route := range []func(Model, tea.Msg) (Model, tea.Cmd, bool){ Model.fromTerminal, Model.fromPage, + Model.fromWindow, Model.fromAction, Model.fromSession, Model.keepingUp, @@ -72,26 +73,11 @@ func (m Model) fromPage(msg tea.Msg) (Model, tea.Cmd, bool) { case forgetMsg: return m.forget(), nil, true - case wrapMsg: - return taken(wrapLines(m)) - - case followMsg: - return taken(toggleAutoscroll(m)) - - case timesMsg: - return taken(showTimes(m)) - - case findWordMsg: - return taken(m.askFind()) - - case matchMsg: - return taken(m.goToMatch(int(msg))) - case reopenMsg: return taken(m.openStream()) case saveMsg: - return taken(saveScreen(m)) + return taken(m.askSave()) case copyTextMsg: return taken(m.copyText()) @@ -112,6 +98,31 @@ func (m Model) fromPage(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, false } +// fromWindow takes what a page asks of the window over a text. +func (m Model) fromWindow(msg tea.Msg) (Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case wrapMsg: + return taken(wrapLines(m)) + + case followMsg: + return taken(toggleAutoscroll(m)) + + case timesMsg: + return taken(showTimes(m)) + + case linesBeforeMsg: + return taken(keepPlace(m, int(msg))) + + case findWordMsg: + return taken(m.askFind()) + + case matchMsg: + return taken(m.goToMatch(int(msg))) + } + + return m, nil, false +} + // fromAction takes what a key started, and how it ended. func (m Model) fromAction(msg tea.Msg) (Model, tea.Cmd, bool) { switch msg := msg.(type) { diff --git a/internal/ui/writes_test.go b/internal/ui/writes_test.go index 5cc96e3..d3f44cc 100644 --- a/internal/ui/writes_test.go +++ b/internal/ui/writes_test.go @@ -34,7 +34,7 @@ var ( "Address", "Region", "Agent", "Regions", "Datacenters", "AllocationChecks", "Files", "File", "Deployment", "JobDeployments", "DeploymentAllocations", "Token", "ServiceInstances", "Jobs", "NextLaunch", "JobSpec", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", "JobTarget", "JobCounts", "DescribeJob", "DescribeAllocation", "DescribeDeployment", "DescribeService", - "Allocations", "NodeAllocations", "Allocation", "Logs", + "Allocations", "NodeAllocations", "Allocation", "Logs", "LogBefore", "Usage", "AllocationUsage", "TaskUsage", "NodeUsage", "Node", "NodeDetail", "NodeMeta", "NodeMetaSpec", "Nodes", "NodePools", "PoolJobs", "PoolNodes", "NamespaceSpec", "Namespaces", "Deployments", "Services", "Evaluations", "Evaluation", "EvaluationAllocations", "FailedPlacement", "NewestEvaluation", "Variables", "Variable", "VariableSpec",