Skip to content

Commit 8b261cb

Browse files
authored
Add throughput and ETA stats to fsck UI (#773)
Adds another line to the fsck TUI, showing the total and avg. throughput (bytes, resources, and errors) along with an ETA.
1 parent 208b2d5 commit 8b261cb

4 files changed

Lines changed: 226 additions & 23 deletions

File tree

cmd/fsck/tui/fsck_panel.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import (
2525

2626
// NewFsckPanel creates a new TUI panel showing information about an ongoing fsck operation.
2727
func NewFsckPanel() *FsckPanel {
28-
r := &FsckPanel{}
28+
r := &FsckPanel{
29+
statsView: NewStatsView(),
30+
}
2931
return r
3032
}
3133

@@ -37,6 +39,8 @@ type FsckPanel struct {
3739
// The zeroth entry corresponds to the tiles on level zero.
3840
tilesBars []*LayerProgressModel
3941

42+
statsView *StatsViewModel
43+
4044
// width is the width of the app window
4145
width int
4246
}
@@ -90,6 +94,9 @@ func (m *FsckPanel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9094
cmds = append(cmds, cmd)
9195
}
9296

97+
_, cmd = m.statsView.Update(msg)
98+
cmds = append(cmds, cmd)
99+
93100
return m, tea.Batch(cmds...)
94101
default:
95102
return m, nil
@@ -110,14 +117,17 @@ func (m *FsckPanel) View() string {
110117
bars = append(bars, LayerProgressKey())
111118
barsView := lipgloss.JoinVertical(lipgloss.Bottom, bars...)
112119

113-
content := lipgloss.NewStyle().
120+
progress := lipgloss.NewStyle().
114121
Width(m.width).
115122
Height(lipgloss.Height(barsView)+1).
116-
Align(lipgloss.Center, lipgloss.Top).
117123
Border(lipgloss.NormalBorder(), true, false, false, false).
118124
Render(barsView)
119125

120-
return lipgloss.JoinVertical(lipgloss.Top, content)
126+
stats := lipgloss.NewStyle().Align(lipgloss.Left).Render(m.statsView.View())
127+
128+
return lipgloss.NewStyle().Align(lipgloss.Center).Render(
129+
lipgloss.JoinVertical(lipgloss.Top, progress, stats),
130+
)
121131
}
122132

123133
// FsckPanelUpdateMsg is used to tell the FsckPanel about updated status from the fsck operation.

cmd/fsck/tui/stats_view.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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
16+
17+
import (
18+
"fmt"
19+
"time"
20+
21+
"github.com/dustin/go-humanize"
22+
"github.com/transparency-dev/tessera/fsck"
23+
24+
tea "github.com/charmbracelet/bubbletea"
25+
"github.com/charmbracelet/lipgloss"
26+
27+
movingaverage "github.com/RobinUS2/golang-moving-average"
28+
)
29+
30+
// NewStatsView returns a widet which displays fsck statistics.
31+
func NewStatsView() *StatsViewModel {
32+
m := &StatsViewModel{
33+
bytesAvg: movingaverage.New(10),
34+
resourcesAvg: movingaverage.New(10),
35+
errorsAvg: movingaverage.New(10),
36+
}
37+
return m
38+
}
39+
40+
// StatsViewModel is the UI model for a stats widget.
41+
type StatsViewModel struct {
42+
// width available to this component.
43+
width int
44+
45+
totalBytes uint64
46+
totalResources uint64
47+
totalErrors uint64
48+
49+
bytesAvg *movingaverage.MovingAverage
50+
resourcesAvg *movingaverage.MovingAverage
51+
errorsAvg *movingaverage.MovingAverage
52+
53+
startTime time.Time
54+
lastUpdate time.Time
55+
eta time.Time
56+
}
57+
58+
func (m *StatsViewModel) Init() tea.Cmd {
59+
m.startTime = time.Now()
60+
61+
return nil
62+
}
63+
64+
// Update is used to update the widget.
65+
func (m *StatsViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
66+
switch msg := msg.(type) {
67+
case tea.WindowSizeMsg:
68+
m.width = msg.Width
69+
return m, nil
70+
case FsckPanelUpdateMsg:
71+
now := time.Now()
72+
d := now.Sub(m.lastUpdate)
73+
m.lastUpdate = now
74+
75+
b := msg.Status.BytesFetched
76+
m.totalBytes += b
77+
bytesPerSecond := float64(b) / d.Seconds()
78+
m.bytesAvg.Add(bytesPerSecond)
79+
80+
r := msg.Status.ResourcesFetched
81+
m.totalResources += r
82+
resourcesPerSecond := float64(r) / d.Seconds()
83+
m.resourcesAvg.Add(resourcesPerSecond)
84+
85+
e := msg.Status.ErrorsEncountered
86+
m.totalErrors += e
87+
errorsPerSecond := float64(e) / d.Seconds()
88+
m.errorsAvg.Add(errorsPerSecond)
89+
90+
totalResources := totalSize(msg.Status.EntryRanges)
91+
for _, rs := range msg.Status.TileRanges {
92+
totalResources += totalSize(rs)
93+
}
94+
if totalResources > 0 {
95+
elapsedTime := float64(now.Sub(m.startTime).Seconds())
96+
complete := float64(m.totalResources) / float64(totalResources)
97+
m.eta = now.Add(time.Duration(elapsedTime / complete))
98+
}
99+
100+
return m, nil
101+
default:
102+
return m, nil
103+
}
104+
}
105+
106+
// totalSize returns the total number of elements covered by the provided ranges.
107+
func totalSize(rs []fsck.Range) uint64 {
108+
tot := uint64(0)
109+
for _, r := range rs {
110+
tot += r.N
111+
}
112+
return tot
113+
}
114+
115+
func (m *StatsViewModel) View() string {
116+
bytesFetched := lipgloss.NewStyle().Width(27).Render(formatTotalAndAverage("Bytes", m.totalBytes, m.bytesAvg.Avg()))
117+
resourcesFetched := lipgloss.NewStyle().Width(30).Render(formatTotalAndAverage("Resources", m.totalResources, m.resourcesAvg.Avg()))
118+
errorsEncountered := lipgloss.NewStyle().Width(23).Render(formatTotalAndAverage("Errors", m.totalErrors, m.errorsAvg.Avg()))
119+
120+
eta := lipgloss.NewStyle().Width(55).Render(fmt.Sprintf("ETA: %s", humanize.Time(m.eta)))
121+
122+
return lipgloss.NewStyle().
123+
Width(m.width).Render(
124+
lipgloss.JoinHorizontal(
125+
lipgloss.Right,
126+
bytesFetched,
127+
resourcesFetched,
128+
errorsEncountered,
129+
eta),
130+
)
131+
}
132+
133+
func formatTotalAndAverage(label string, total uint64, avg float64) string {
134+
si, p := humanize.ComputeSI(float64(total))
135+
avgSI, avgP := humanize.ComputeSI(avg)
136+
137+
return fmt.Sprintf("%s: %0.1f %s (%03.1f%s/s)", label, si, p, avgSI, avgP)
138+
}

fsck/fsck.go

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ type Fetcher interface {
4444
type Fsck struct {
4545
origin string
4646
verifier note.Verifier
47-
fetcher Fetcher
47+
fetcher *countingFetcher
4848
bundleHasher func([]byte) ([][]byte, error)
4949
rangeTracker *rangeTracker
5050
opts Opts
51+
52+
fsckTree *fsckTree
5153
}
5254

5355
type Opts struct {
@@ -72,13 +74,15 @@ func New(origin string, verifier note.Verifier, f Fetcher, bundleHasher func([]b
7274
return &Fsck{
7375
origin: origin,
7476
verifier: verifier,
75-
fetcher: f,
77+
fetcher: newCountingFetcher(f),
7678
opts: opts,
7779
bundleHasher: bundleHasher,
7880
}
7981
}
8082

8183
// Check performs an integrity check against the log.
84+
//
85+
// Check may only be called once per instance of Fsck.
8286
func (f *Fsck) Check(ctx context.Context) error {
8387
cp, cpRaw, _, err := client.FetchCheckpoint(ctx, f.fetcher.ReadCheckpoint, f.verifier, f.origin)
8488
if err != nil {
@@ -90,7 +94,7 @@ func (f *Fsck) Check(ctx context.Context) error {
9094

9195
f.rangeTracker = newRangeTracker(cp.Size)
9296

93-
fTree := fsckTree{
97+
f.fsckTree = &fsckTree{
9498
fetcher: f.fetcher,
9599
bundleHasher: f.bundleHasher,
96100
tree: (&compact.RangeFactory{Hash: rfc6962.DefaultHasher.HashChildren}).NewEmptyRange(0),
@@ -106,17 +110,17 @@ func (f *Fsck) Check(ctx context.Context) error {
106110

107111
// Kick off resource comparing workers
108112
for range f.opts.N {
109-
eg.Go(fTree.resourceCheckWorker(ctx))
113+
eg.Go(f.fsckTree.resourceCheckWorker(ctx))
110114
}
111115

112116
trackBundle := func(ctx context.Context, idx uint64, p uint8) ([]byte, error) {
113-
fTree.rangeTracker.Update(-1, idx, Fetching)
117+
f.fsckTree.rangeTracker.Update(-1, idx, Fetching)
114118
r, err := f.fetcher.ReadEntryBundle(ctx, idx, p)
115119
if err != nil {
116-
fTree.rangeTracker.Update(-1, idx, FetchError)
120+
f.fsckTree.rangeTracker.Update(-1, idx, FetchError)
117121
return nil, err
118122
}
119-
fTree.rangeTracker.Update(-1, idx, Fetched)
123+
f.fsckTree.rangeTracker.Update(-1, idx, Fetched)
120124
return r, nil
121125
}
122126

@@ -127,27 +131,27 @@ func (f *Fsck) Check(ctx context.Context) error {
127131
if err != nil {
128132
return fmt.Errorf("error while streaming bundles: %v", err)
129133
}
130-
fTree.rangeTracker.Update(-1, b.RangeInfo.Index, OK)
131-
if err := fTree.AppendBundle(b.RangeInfo, b.Data); err != nil {
134+
f.fsckTree.rangeTracker.Update(-1, b.RangeInfo.Index, OK)
135+
if err := f.fsckTree.AppendBundle(b.RangeInfo, b.Data); err != nil {
132136
return fmt.Errorf("failure calling AppendBundle(%v): %v", b.RangeInfo, err)
133137
}
134-
if fTree.tree.End() >= cp.Size {
138+
if f.fsckTree.tree.End() >= cp.Size {
135139
break
136140
}
137141
}
138142

139143
// Ensure we see process any partial tiles too.
140-
fTree.flushPartialTiles()
144+
f.fsckTree.flushPartialTiles()
141145
// Signal that there will be no more resource checking jobs coming so workers can exit when the channel is drained.
142-
close(fTree.expectedResources)
146+
close(f.fsckTree.expectedResources)
143147

144148
// Wait for all the work to be done.
145149
if err := eg.Wait(); err != nil {
146150
return fmt.Errorf("failed: %v", err)
147151
}
148152

149153
// Finally, check that the claimed root hash matches what we calculated.
150-
gotRoot, err := fTree.GetRootHash()
154+
gotRoot, err := f.fsckTree.GetRootHash()
151155
switch {
152156
case err != nil:
153157
return fmt.Errorf("failed to calculate root: %v", err)
@@ -160,19 +164,34 @@ func (f *Fsck) Check(ctx context.Context) error {
160164
return nil
161165
}
162166

167+
// Status represents the current status of an ongoing fsck check.
163168
type Status struct {
169+
// EntryRanges describes the status of the entrybundles in the target log.
164170
EntryRanges []Range
165-
TileRanges [][]Range
171+
// TileRanges describes the status of the tiles in the target log.
172+
// The zeroth entry in the slice represents the lower-most tile level, just above the entry bundles.
173+
TileRanges [][]Range
174+
175+
// BytesFetched is the total number of bytes fetched from the target log since the last time Status() was called.
176+
BytesFetched uint64
177+
// ResourcesFetched is the total number of resources fetched from the target log since the last time Status() was called.
178+
ResourcesFetched uint64
179+
// ErrorsEncountered is the total number of errors encountered since the last time Status() was called.
180+
ErrorsEncountered uint64
166181
}
167182

183+
// Status returns a struct representing the current status of the fsck operation.
168184
func (f *Fsck) Status() Status {
169185
if f.rangeTracker == nil {
170186
return Status{}
171187
}
172188
e, t := f.rangeTracker.Ranges()
173189
r := Status{
174-
EntryRanges: e,
175-
TileRanges: t,
190+
EntryRanges: e,
191+
TileRanges: t,
192+
BytesFetched: f.fsckTree.fetcher.bytesFetched.Swap(0),
193+
ResourcesFetched: f.fsckTree.fetcher.resourcesFetched.Swap(0),
194+
ErrorsEncountered: f.fsckTree.fetcher.errorsEncountered.Swap(0),
176195
}
177196
return r
178197
}
@@ -206,7 +225,7 @@ type resource struct {
206225
// fsckTree represents the tree we're currently checking.
207226
type fsckTree struct {
208227
// fetcher knows how to retrieve static tlog-tile resources.
209-
fetcher Fetcher
228+
fetcher *countingFetcher
210229
// bundleHasher knows how to convert entry bundles into leaf hashes.
211230
bundleHasher func([]byte) ([][]byte, error)
212231
// tree contains the running state of the leaves we've appended so far.
@@ -332,3 +351,39 @@ func (f *fsckTree) resourceCheckWorker(ctx context.Context) func() error {
332351
return nil
333352
}
334353
}
354+
355+
// countingFetcher is a Fetcher which keeps track of the total number of bytes and resources fetched.
356+
type countingFetcher struct {
357+
f Fetcher
358+
bytesFetched atomic.Uint64
359+
resourcesFetched atomic.Uint64
360+
errorsEncountered atomic.Uint64
361+
}
362+
363+
func newCountingFetcher(f Fetcher) *countingFetcher {
364+
return &countingFetcher{
365+
f: f,
366+
}
367+
}
368+
369+
func (c *countingFetcher) count(r []byte, err error) ([]byte, error) {
370+
if err != nil {
371+
c.errorsEncountered.Add(1)
372+
return nil, err
373+
}
374+
c.bytesFetched.Add(uint64(len(r)))
375+
c.resourcesFetched.Add(1)
376+
return r, nil
377+
}
378+
379+
func (c *countingFetcher) ReadCheckpoint(ctx context.Context) ([]byte, error) {
380+
return c.count(c.f.ReadCheckpoint(ctx))
381+
}
382+
383+
func (c *countingFetcher) ReadEntryBundle(ctx context.Context, i uint64, p uint8) ([]byte, error) {
384+
return c.count(c.f.ReadEntryBundle(ctx, i, p))
385+
}
386+
387+
func (c *countingFetcher) ReadTile(ctx context.Context, l, i uint64, p uint8) ([]byte, error) {
388+
return c.count(c.f.ReadTile(ctx, l, i, p))
389+
}

fsck/fsck_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ func TestTrimFullToPartial(t *testing.T) {
6565

6666
f := &fsckTree{
6767
expectedResources: make(chan resource, 1),
68-
fetcher: &fakeFetcher{
68+
fetcher: newCountingFetcher(&fakeFetcher{
6969
tile: test.storedTile,
70-
},
70+
}),
7171
rangeTracker: newRangeTracker(1),
7272
}
7373

0 commit comments

Comments
 (0)