Skip to content

Commit 010300f

Browse files
authored
Add a TUI to cmd/fsck (#767)
Adds a simple UI to the fsck too which allows the user to visibly see the progress. When running against large logs, fsck can take a long time to complete, so it's important to show that the process is ongoing and progress is being made. This PR adds several combination status/progress bar widgets - one for entry bundles, and then one for each layer of tiles in the target log. The app uses the Status call from fsck.Fsck to periodically fetch updated information on the state of the check, and updates the bars accordingly, using a combination of colour and glyph to represent each of the possible states a range of static artifacts can be in. The UI that this PR adds doesn't completely take over the terminal/use the "alt screen", but works in a way which is complementary to the existing klog info lines and terminal scroll-back.
1 parent 9d485a3 commit 010300f

6 files changed

Lines changed: 490 additions & 8 deletions

File tree

cmd/fsck/internal/tui/app.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright 2025 The Tessera authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package tui provides a Bubbletea-based TUI for the fsck command.
16+
package tui
17+
18+
import (
19+
"bufio"
20+
"context"
21+
"flag"
22+
"fmt"
23+
"io"
24+
"time"
25+
26+
"github.com/transparency-dev/tessera/cmd/fsck/tui"
27+
"github.com/transparency-dev/tessera/fsck"
28+
"k8s.io/klog/v2"
29+
30+
tea "github.com/charmbracelet/bubbletea"
31+
"github.com/charmbracelet/lipgloss"
32+
"github.com/charmbracelet/x/ansi"
33+
)
34+
35+
// RunApp runs the TUI app, using the provided fsck instance to fetch updates from to populate the UI.
36+
func RunApp(ctx context.Context, f *fsck.Fsck) error {
37+
m := NewFsckAppModel()
38+
p := tea.NewProgram(m)
39+
40+
// Redirect logging so as to appear above the UI
41+
_ = flag.Set("logtostderr", "false")
42+
_ = flag.Set("alsologtostderr", "false")
43+
r, w := io.Pipe()
44+
klog.SetOutput(w)
45+
go func() {
46+
s := bufio.NewScanner(r)
47+
for s.Scan() {
48+
l := ansi.StringWidth(s.Text())
49+
txt := ansi.InsertLine(l) + s.Text()
50+
p.Send(tea.Println(txt)())
51+
}
52+
}()
53+
54+
// Send periodic status updates to the UI from fsck.
55+
go func() {
56+
for {
57+
select {
58+
case <-ctx.Done():
59+
// Have the UI update one last time to show where we got to (this helps ensure we see 100%
60+
// on the progress bars if we're exiting because the fsck has completed).
61+
p.Send(UpdateCmd(f.Status())())
62+
// Give the UI a bit of time to render...
63+
<-time.After(100 * time.Millisecond)
64+
// And then we're out.
65+
p.Send(tea.Quit())
66+
case <-time.After(100 * time.Millisecond):
67+
p.Send(UpdateCmd(f.Status())())
68+
}
69+
}
70+
}()
71+
72+
if _, err := p.Run(); err != nil {
73+
return err
74+
}
75+
return nil
76+
}
77+
78+
// NewFsckAppModel creates a new BubbleTea model for the TUI.
79+
func NewFsckAppModel() *FsckAppModel {
80+
r := &FsckAppModel{}
81+
return r
82+
}
83+
84+
// FsckAppModel represents the UI model for the FSCK TUI.
85+
type FsckAppModel struct {
86+
// entriesBar is the status/progress bar representing progress through the entry bundles.
87+
entriesBar *tui.LayerProgressModel
88+
// titlesBars is the list status/progress bars representing progress through the various levels of tiles in the log.
89+
// The zeroth entry corresponds to the tiles on level zero.
90+
tilesBars []*tui.LayerProgressModel
91+
92+
// width is the width of the app window
93+
width int
94+
}
95+
96+
// Init is called by Bubbleteam early on to set up the app.
97+
func (m *FsckAppModel) Init() tea.Cmd {
98+
return nil
99+
}
100+
101+
// Update is called by Bubbletea to handle events.
102+
func (m *FsckAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103+
switch msg := msg.(type) {
104+
case tea.KeyMsg:
105+
// Handle user input.
106+
// Quit if they pressed Q, escape, or CTRL-C.
107+
switch msg.String() {
108+
case "q", "esc", "ctrl+c":
109+
return m, tea.Quit
110+
}
111+
return m, nil
112+
case tea.WindowSizeMsg:
113+
m.width = msg.Width
114+
115+
var cmd tea.Cmd
116+
cmds := []tea.Cmd{}
117+
118+
if m.entriesBar != nil {
119+
_, cmd = m.entriesBar.Update(msg)
120+
cmds = append(cmds, cmd)
121+
}
122+
123+
for _, t := range m.tilesBars {
124+
_, cmd = t.Update(msg)
125+
cmds = append(cmds, cmd)
126+
}
127+
return m, tea.Batch(cmds...)
128+
case updateMsg:
129+
// Ignore empty updates
130+
if len(msg.s.TileRanges) == 0 {
131+
return m, nil
132+
}
133+
134+
// Create the range progress bars now that we know how details about the tree.
135+
if len(m.tilesBars) != len(msg.s.TileRanges) {
136+
m.entriesBar = tui.NewLayerProgressBar("Entry bundles", m.width, 0)
137+
138+
bs := make([]*tui.LayerProgressModel, 0, len(msg.s.TileRanges))
139+
for i := range msg.s.TileRanges {
140+
bs = append(bs, tui.NewLayerProgressBar(fmt.Sprintf("Tiles level %02d", i), m.width, i))
141+
}
142+
m.tilesBars = bs
143+
}
144+
145+
// Update all the range progress bars with the latest state.
146+
_, cmd := m.entriesBar.Update(tui.LayerUpdateMsg{Ranges: msg.s.EntryRanges})
147+
cmds := []tea.Cmd{cmd}
148+
for i := range m.tilesBars {
149+
_, cmd = m.tilesBars[i].Update(tui.LayerUpdateMsg{Ranges: msg.s.TileRanges[i]})
150+
cmds = append(cmds, cmd)
151+
}
152+
153+
return m, tea.Batch(cmds...)
154+
default:
155+
return m, nil
156+
}
157+
}
158+
159+
// View is called by Bubbletea to render the UI components.
160+
func (m *FsckAppModel) View() string {
161+
// Build the progress bars, we'll use this below.
162+
bars := []string{}
163+
for i := len(m.tilesBars) - 1; i >= 0; i-- {
164+
t := m.tilesBars[i]
165+
bars = append(bars, t.View())
166+
}
167+
if m.entriesBar != nil {
168+
bars = append(bars, m.entriesBar.View())
169+
}
170+
barsView := lipgloss.JoinVertical(lipgloss.Bottom, bars...)
171+
172+
content := lipgloss.NewStyle().
173+
Width(m.width).
174+
Height(lipgloss.Height(barsView)+1).
175+
Align(lipgloss.Center, lipgloss.Top).
176+
Border(lipgloss.NormalBorder(), true, false, false, false).
177+
Render(barsView)
178+
179+
return lipgloss.JoinVertical(lipgloss.Top, content)
180+
}
181+
182+
// updateMsg is used to tell the model about updated status from the fsck library.
183+
type updateMsg struct {
184+
s fsck.Status
185+
}
186+
187+
// UpdateCmd returns a Cmd which Bubbletea can execute in order to retrieve and updateMsg.
188+
func UpdateCmd(status fsck.Status) tea.Cmd {
189+
return func() tea.Msg {
190+
return updateMsg{s: status}
191+
}
192+
}

cmd/fsck/main.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/transparency-dev/merkle/rfc6962"
2828
"github.com/transparency-dev/tessera/api"
2929
"github.com/transparency-dev/tessera/client"
30+
"github.com/transparency-dev/tessera/cmd/fsck/internal/tui"
3031
"github.com/transparency-dev/tessera/fsck"
3132
"golang.org/x/mod/sumdb/note"
3233
"golang.org/x/time/rate"
@@ -40,12 +41,13 @@ var (
4041
origin = flag.String("origin", "", "Origin of the log to check, if unset, will use the name of the provided public key")
4142
pubKey = flag.String("public_key", "", "Path to a file containing the log's public key")
4243
qps = flag.Float64("qps", 0, "Max QPS to send to the target log. Set to zero for unlimited")
44+
ui = flag.Bool("ui", true, "Set to true to use a TUI to display progress, or false for logging")
4345
)
4446

4547
func main() {
4648
klog.InitFlags(nil)
4749
flag.Parse()
48-
ctx := context.Background()
50+
ctx, cancel := context.WithCancel(context.Background())
4951
logURL, err := url.Parse(*storageURL)
5052
if err != nil {
5153
klog.Exitf("Invalid --storage_url %q: %v", *storageURL, err)
@@ -77,16 +79,31 @@ func main() {
7779
*origin = v.Name()
7880
}
7981
f := fsck.New(*origin, v, src, defaultMerkleLeafHasher, fsck.Opts{N: *N})
82+
8083
go func() {
81-
for {
82-
time.Sleep(time.Second)
83-
klog.V(1).Infof("Ranges:\n%s", f.Status())
84+
if err := f.Check(ctx); err != nil {
85+
klog.Errorf("fsck failed: %v", err)
8486
}
87+
klog.V(1).Infof("Completed ranges:\n%s", f.Status())
88+
cancel()
8589
}()
86-
if err := f.Check(ctx); err != nil {
87-
klog.Exitf("fsck failed: %v", err)
90+
91+
if *ui {
92+
if err := tui.RunApp(ctx, f); err != nil {
93+
klog.Errorf("App exited: %v", err)
94+
}
95+
// User may have exited the UI, cancel the context to signal to everything else.
96+
cancel()
97+
} else {
98+
for {
99+
select {
100+
case <-ctx.Done():
101+
return
102+
case <-time.After(time.Second):
103+
klog.V(1).Infof("Ranges:\n%s", f.Status())
104+
}
105+
}
88106
}
89-
klog.V(1).Infof("Ranges:\n%s", f.Status())
90107
}
91108

92109
// defaultMerkleLeafHasher parses a C2SP tlog-tile bundle and returns the Merkle leaf hashes of each entry it contains.

0 commit comments

Comments
 (0)