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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/allocations.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ opens the same log in the allocation it replaced.
where it is. Press it again to go further back, as far as the cluster keeps
the log; while a read is still out, `e` waits for it. The title then says how
much of the log is read, for example `last 1.1 MiB of 5.3 MiB`, until the log
drops lines off its front. 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.
drops lines off its front. It stops at what a screen keeps: 50,000 lines or
64 MiB of text, whichever comes first. 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:

Expand Down
31 changes: 31 additions & 0 deletions internal/ui/logback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,12 +290,43 @@ func TestLogEarlier_AnAnswerIsDroppedWhenItNoLongerFits(t *testing.T) {
r.Equal(flashWarn, m.flash.level)
}

func TestLogEarlier_AnAnswerIsDroppedWhenItsBytesDoNotFit(t *testing.T) {
r := require.New(t)

eighth := maxTextBytes / 8
read := logState{before: midLog, more: true}
read.content.add(sizedLines(0, 7, eighth), arrivedAt, nil, nil)

part := nomad.LogPart{Lines: sizedLines(10, 2, eighth), From: nomad.LogMark{File: midLog.File, Offset: 60_000}, Before: 60_000, Size: 130_000}
read, out, ok := read.earlier(logPartMsg{at: midLog, part: part})

r.True(ok)
r.Equal(7, len(read.content.lines))
r.Equal(midLog, read.before)
r.Len(out.now, 1)
r.Equal(warnMsg("The lines read back no longer fit on the screen."), out.now[0])
}

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_ATextFullOfBytesOffersNoKey(t *testing.T) {
for name, lines := range map[string][]string{
"as many bytes as it keeps": sizedLines(0, 8, maxTextBytes/8),
"lines dropped for their bytes": sizedLines(0, 3, maxTextBytes*2/5),
} {
t.Run(name, func(t *testing.T) {
p := logsPage{read: logState{before: midLog, more: true}}
p.read.content.add(lines, arrivedAt, nil, nil)

require.False(t, hasEarlier(p, env{}))
})
}
}

func TestLogEarlier_TheLinesReadBackAreCleaned(t *testing.T) {
r := require.New(t)

Expand Down
13 changes: 5 additions & 8 deletions internal/ui/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,9 @@ func openPrevious(p logsPage, _ env) (logsPage, outcome) {
}

// hasEarlier says the cluster keeps more of the log before what is shown,
// no read of it is out, and the screen has room for it: a text that dropped
// lines is full.
// no read of it is out, and the screen has room for it.
func hasEarlier(p logsPage, _ env) bool {
return p.read.more && !p.read.asking && len(p.read.content.lines) < maxTextLines
return p.read.more && !p.read.asking && !p.read.content.full()
}

// readEarlier reads the step of the log before what is shown, to put above
Expand Down Expand Up @@ -343,9 +342,8 @@ func (l logState) take(msg tea.Msg) (logState, outcome, bool) {
// 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. A failed read shows its error.
// 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.
// text no longer starts there. It is also dropped if it does not fit under
// the limits of the text, with a warning: the press shows nothing else.
func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) {
l.asking = false

Expand All @@ -357,11 +355,10 @@ func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) {
return l, outcome{reading: true}, true
}

if len(l.content.lines)+len(msg.part.Lines) > maxTextLines {
if !l.content.addBefore(msg.part.Lines) {
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
Expand Down
60 changes: 48 additions & 12 deletions internal/ui/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ type rowCache struct {
// or more.
const maxTextLines = 50_000

// maxTextBytes is how many bytes the lines of a text hold at most. A task can
// write a line of 64 KiB, and 50,000 of them would take gigabytes. 50,000 lines
// of up to 1.3 KiB still fit.
const maxTextBytes = 64 << 20

// textContent is what a text holds, apart from how it is shown: a text page
// keeps it, and the root model keeps the window over it.
type textContent struct {
Expand All @@ -93,6 +98,9 @@ type textContent struct {

lines []string

// size is the bytes of lines.
size int

// dropped are the lines taken off the front so far.
dropped int

Expand Down Expand Up @@ -167,7 +175,9 @@ func (t *textContent) add(lines []string, arrived time.Time, label *tag, style *
}

for _, line := range lines {
t.lines = append(t.lines, cleanLine(line))
clean := cleanLine(line)
t.lines = append(t.lines, clean)
t.size += len(clean)
t.stamps = append(t.stamps, arrived)

if tagged {
Expand All @@ -191,23 +201,39 @@ func (t *textContent) addChunk(lines []string, old int, arrived time.Time, label
t.add(lines[old:], arrived, label, style)
}

// 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)
// addBefore puts lines read from before the text in front of it, and says
// whether they fit under the limits of the text: lines that do not fit are
// not added, since they would be the first ones dropped. 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.
func (t *textContent) addBefore(lines []string) bool {
n, size := len(lines), t.size
front := make([]string, 0, n+len(t.lines))

for _, line := range lines {
front = append(front, cleanLine(line))
clean := cleanLine(line)
front = append(front, clean)
size += len(clean)
}

if n+len(t.lines) > maxTextLines || size > maxTextBytes {
return false
}

t.lines = append(front, t.lines...)
t.lines, t.size = append(front, t.lines...), size
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{}

return true
}

// full says no lines can be put in front of the text: it holds as many lines
// or bytes as it keeps, or it dropped lines and no longer starts where the
// lines before it end.
func (t textContent) full() bool {
return t.dropped > 0 || len(t.lines) >= maxTextLines || t.size >= maxTextBytes
}

// fronted puts n unset items in front of s, after growing it to total items.
Expand All @@ -220,11 +246,21 @@ func fronted[T any](s []T, n, total int) []T {
return append(make([]T, n), grown(s, total)...)
}

// dropOld takes the lines past maxTextLines off the front. The slices are cut,
// dropOld takes lines off the front while there are more than maxTextLines
// or more than maxTextBytes, and keeps the newest line. The slices are cut,
// not copied: the next append grows them anew and frees the old front.
func (t *textContent) dropOld() {
n := len(t.lines) - maxTextLines
if n <= 0 {
n := max(0, len(t.lines)-maxTextLines)
for _, line := range t.lines[:n] {
t.size -= len(line)
}

for n < len(t.lines)-1 && t.size > maxTextBytes {
t.size -= len(t.lines[n])
n++
}

if n == 0 {
return
}

Expand Down
91 changes: 91 additions & 0 deletions internal/ui/textrows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,97 @@ func TestText_AStreamKeepsItsLastLines(t *testing.T) {
r.Equal(maxTextLines, len(text.stamps))
}

// sizedLines are n lines of size bytes, each starting with its place, from
// first on.
func sizedLines(first, n, size int) []string {
lines := make([]string, 0, n)
for i := first; i < first+n; i++ {
place := fmt.Sprintf("%02d ", i)
lines = append(lines, place+strings.Repeat("x", size-len(place)))
}

return lines
}

// bytesOf is the bytes of the lines.
func bytesOf(lines []string) int {
n := 0
for _, line := range lines {
n += len(line)
}

return n
}

func TestText_CountsTheBytesOfItsLines(t *testing.T) {
r := require.New(t)

// A tab is spaces up to the next stop: the text holds more bytes than
// it was given.
text := textModel{}
text.add([]string{"a\tb", "cd"}, arrivedAt, nil, nil)
r.Equal(7, text.size)

r.True(text.addBefore([]string{"\tx"}))
r.Equal(bytesOf(text.lines), text.size)

// Lines past maxTextLines leave with their bytes.
text.add(linesFrom(0, maxTextLines), arrivedAt, nil, nil)
r.Equal(bytesOf(text.lines), text.size)
}

func TestText_AStreamKeepsItsLastBytes(t *testing.T) {
r := require.New(t)

old := &tag{text: "old │ "}
own := &tag{text: "own │ "}
bold := lipgloss.NewStyle().Bold(true)
quarter := maxTextBytes / 4

text := textModel{}
text.add(sizedLines(0, 1, quarter), arrivedAt.Add(-time.Hour), old, nil)

// The second line is the first one kept, with what it came with.
text.add(sizedLines(1, 1, quarter), arrivedAt, own, &bold)
text.add(sizedLines(2, 3, quarter), arrivedAt.Add(time.Hour), old, nil)

r.Equal(4, len(text.lines))
r.True(strings.HasPrefix(text.lines[0], "01 "))
r.True(strings.HasPrefix(text.lines[3], "04 "))
r.Equal(1, text.dropped)
r.Equal(5, text.total())
r.Equal(bytesOf(text.lines), text.size)

r.Equal(arrivedAt, text.stampAt(0))
r.Same(own, text.tagAt(0))
r.Same(&bold, text.paintAt(0))
r.Same(old, text.tagAt(1))
r.Nil(text.paintAt(1))
r.Len(text.stamps, 4)
r.Len(text.tags, 4)
r.Len(text.paint, 4)
}

func TestText_KeepsItsNewestLineEvenOverTheByteLimit(t *testing.T) {
r := require.New(t)

text := textModel{}
text.add([]string{"first", strings.Repeat("x", maxTextBytes+1)}, arrivedAt, nil, nil)

r.Equal(1, len(text.lines))
r.Equal(maxTextBytes+1, len(text.lines[0]))
r.Equal(1, text.dropped)
r.Len(text.stamps, 1)

// The next line takes its place.
text.add([]string{"next"}, arrivedAt, nil, nil)

r.Equal(1, len(text.lines))
r.Equal("next", text.lines[0])
r.Equal(2, text.dropped)
r.Equal(4, text.size)
}

func TestText_ALongTextHasItsFrontDroppedWithoutMissingRows(t *testing.T) {
text := textModel{wrap: true, filter: "5"}
text.setSize(8, 6)
Expand Down
Loading