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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions docs/allocations.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,26 @@ 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.
Scrolling up turns it off.
- **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.
Expand Down
22 changes: 19 additions & 3 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion internal/nomad/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
263 changes: 263 additions & 0 deletions internal/nomad/logback.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading