Skip to content

Commit 1879641

Browse files
committed
optimize status: labelled facts, server frame with a connection meter, tables
The head line shows profile, BBR and nodes in sync with coloured marks; the server's facts sit in a frame with a fill meter for the connection table; the settings in effect and, with --details, every key per group are tables; the nodes table shows state and whether the tuning is applied.
1 parent 5df3502 commit 1879641

9 files changed

Lines changed: 3177 additions & 860 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ All notable changes to DEYROUTE are documented here. The format follows
2828

2929
### Changed — clearer status and tuning output
3030

31+
- `deyroute optimize status` is laid out like `status`: a line of labelled
32+
facts (profile, BBR, nodes in sync) with coloured marks, the server in a
33+
frame (memory and free memory, cores, kernel, network, queue, and a fill
34+
meter for the connection table), the settings in effect as a table, every
35+
key and value in its own table per group with `--details`, and the nodes
36+
with their state and whether the tuning is applied or waiting.
3137
- `deyroute status` opens with the name in large block letters and the
3238
version small under it, then one line of labelled facts (hub and address,
3339
mode, nodes online, tunnels UP): labels in gray, values in bold, the

‎docs/assets/screens/cli-optimize-status-dark.svg‎

Lines changed: 1390 additions & 382 deletions
Loading

‎docs/assets/screens/cli-optimize-status-light.svg‎

Lines changed: 1390 additions & 382 deletions
Loading

‎internal/cli/box.go‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,17 @@ func (g *Globals) boxTable(cols []boxCol, rows [][]string, style func(row, col i
139139
return s.String()
140140
}
141141
out := []string{g.styleOut(styleGray, rule(b.tl, b.tm, b.tr, b.h))}
142-
head := make([]string, len(w))
143-
for c := range w {
144-
head[c] = g.styleOut(styleCyan, cell(cols[c].title, c))
142+
headed := false
143+
for _, col := range cols {
144+
headed = headed || col.title != ""
145+
}
146+
if headed { // a table whose columns have no titles is a plain frame
147+
head := make([]string, len(w))
148+
for c := range w {
149+
head[c] = g.styleOut(styleCyan, cell(cols[c].title, c))
150+
}
151+
out = append(out, line(head), g.styleOut(styleGray, rule(b.hml, b.hmm, b.hmr, b.hh)))
145152
}
146-
out = append(out, line(head), g.styleOut(styleGray, rule(b.hml, b.hmm, b.hmr, b.hh)))
147153
for i, r := range rows {
148154
cells := make([]string, len(w))
149155
for c := range w {

‎internal/cli/optimize_view.go‎

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package cli
2+
3+
import (
4+
"sort"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/localroot4/deyroute/internal/api"
9+
"github.com/localroot4/deyroute/internal/config"
10+
"github.com/localroot4/deyroute/internal/i18n"
11+
"github.com/localroot4/deyroute/internal/tui"
12+
)
13+
14+
// The layout of `deyroute optimize status` (and of apply / revert): a line
15+
// of labelled facts, the server in a frame with a fill meter for the
16+
// connection table, the settings in effect as a table of groups (every key
17+
// with --details) and the nodes with their tuning state.
18+
19+
// printOptimize prints an optimize answer; k is its first line.
20+
func (g *Globals) printOptimize(st api.OptimizeStatus, k i18n.Key) error {
21+
if g.JSON {
22+
return g.emitJSON(st)
23+
}
24+
if k != i18n.CLIOptimizeStatusLine { // status names the profile in its head
25+
g.say(k, orDash(st.Profile))
26+
g.println()
27+
}
28+
g.println(g.sectionHead(i18n.T(i18n.CLITuneStatusSection), ""))
29+
sep := " " + g.styleOut(styleGray, g.text("│")) + " "
30+
for _, l := range joinWrap(g.tuneHeadFacts(st), sep, g.lineWidth()-1) {
31+
g.println(" " + l)
32+
}
33+
if len(st.Warnings) > 0 {
34+
g.println()
35+
}
36+
for _, w := range st.Warnings {
37+
g.printWarn(g.Out, clean(w))
38+
}
39+
if rows := g.tuneServerRows(st.Facts); len(rows) > 0 {
40+
g.println()
41+
g.println(g.sectionHead(i18n.T(i18n.CLITuneServerTitle), ""))
42+
cols := []boxCol{{min: 6}, {min: 10, flex: true}}
43+
for _, l := range g.boxTable(cols, rows, func(_, c int) string {
44+
if c == 0 {
45+
return styleGray
46+
}
47+
return ""
48+
}) {
49+
g.println(l)
50+
}
51+
}
52+
if len(st.Applied) > 0 {
53+
g.println()
54+
g.println(g.sectionHead(i18n.T(i18n.CLITuneSettingsSection, tuneHostTitle(config.RoleHub)), ""))
55+
g.printTuneSettings(st.Applied)
56+
}
57+
if len(st.Nodes) > 0 {
58+
g.println()
59+
g.println(g.sectionHead(i18n.T(i18n.TUIDashNodes), ""))
60+
g.printTuneNodes(st.Nodes)
61+
}
62+
return nil
63+
}
64+
65+
// tuneHeadFacts are the labelled facts on top: profile, BBR and how many
66+
// nodes run the hub's tuning.
67+
func (g *Globals) tuneHeadFacts(st api.OptimizeStatus) []string {
68+
s := g.sym()
69+
fact := func(label, mark, style, value string) string {
70+
return g.text(g.styleOut(styleGray, label) + " " + g.styleOut(style, strings.TrimSpace(mark+" "+value)))
71+
}
72+
profStyle, profMark := styleGreen, s.up
73+
if st.Profile == "" || st.Profile == "off" || st.Profile == "none" {
74+
profStyle, profMark = styleGray, s.down
75+
}
76+
out := []string{fact(i18n.T(i18n.CLITuneHeadProfile), profMark, profStyle, orDash(st.Profile))}
77+
switch {
78+
case st.BBRActive:
79+
out = append(out, fact(i18n.T(i18n.CLITuneHeadBBR), s.up, styleGreen, i18n.T(i18n.CLIBBRValActive)))
80+
case st.BBRAvailable:
81+
out = append(out, fact(i18n.T(i18n.CLITuneHeadBBR), s.half, styleYellow, i18n.T(i18n.CLIBBRValInactive)))
82+
default:
83+
out = append(out, fact(i18n.T(i18n.CLITuneHeadBBR), s.down, styleRed, i18n.T(i18n.CLIBBRValMissing)))
84+
}
85+
if len(st.Nodes) > 0 {
86+
synced := 0
87+
for _, n := range st.Nodes {
88+
if n.Online && !n.Pending {
89+
synced++
90+
}
91+
}
92+
style, mark := styleGreen, s.up
93+
switch {
94+
case synced == 0:
95+
style, mark = styleRed, s.down
96+
case synced < len(st.Nodes):
97+
style, mark = styleYellow, s.half
98+
}
99+
out = append(out, fact(i18n.T(i18n.CLITuneHeadNodes), mark, style, i18n.T(i18n.CLITuneHeadInSync, synced, len(st.Nodes))))
100+
}
101+
return out
102+
}
103+
104+
// tuneServerRows are the measured facts of the hub, one per row.
105+
func (g *Globals) tuneServerRows(f *api.TuneFacts) [][]string {
106+
if f == nil {
107+
return nil
108+
}
109+
var rows [][]string
110+
add := func(label, value string) {
111+
if value != "" {
112+
rows = append(rows, []string{label, value})
113+
}
114+
}
115+
if f.MemBytes > 0 {
116+
mem := tui.FormatBytes(f.MemBytes)
117+
if f.MemAvailableBytes > 0 {
118+
mem = i18n.T(i18n.CLITuneSrvMemoryVal, mem, tui.FormatBytes(f.MemAvailableBytes))
119+
}
120+
add(i18n.T(i18n.CLITuneSrvMemory), mem)
121+
}
122+
if f.CPUs > 0 {
123+
cores := i18n.T(i18n.CLITuneSrvCores, f.CPUs)
124+
if f.CPUs == 1 {
125+
cores = i18n.T(i18n.CLITuneSrvCore)
126+
}
127+
add(i18n.T(i18n.CLITuneSrvCPU), cores)
128+
}
129+
add(i18n.T(i18n.CLITuneSrvKernel), clean(f.Kernel))
130+
if f.NIC != "" {
131+
nic := clean(f.NIC)
132+
if f.NICMTU > 0 {
133+
nic = i18n.T(i18n.CLITuneSrvNetVal, nic, f.NICMTU)
134+
}
135+
if f.NICSpeedMbps > 0 {
136+
nic = i18n.T(i18n.CLITuneSrvSpeed, nic, f.NICSpeedMbps)
137+
}
138+
add(i18n.T(i18n.CLITuneSrvNetwork), nic)
139+
}
140+
add(i18n.T(i18n.CLITuneSrvQdisc), clean(f.Qdisc))
141+
switch {
142+
case f.ConntrackLoaded && f.ConntrackMax > 0:
143+
frac := float64(f.ConntrackCount) / float64(f.ConntrackMax)
144+
add(i18n.T(i18n.CLITuneSrvConntrack), g.meter(frac)+" "+
145+
i18n.T(i18n.CLITuneSrvCTVal, thousands(f.ConntrackCount), thousands(f.ConntrackMax))+
146+
" ("+strconv.Itoa(int(frac*100+0.5))+"%)")
147+
case !f.ConntrackLoaded:
148+
add(i18n.T(i18n.CLITuneSrvConntrack), i18n.T(i18n.CLITuneSrvCTOff))
149+
}
150+
add(i18n.T(i18n.CLITuneSrvVirt), clean(f.Virt))
151+
return rows
152+
}
153+
154+
// meter is a 10-cell fill bar, green below 60 %, yellow below 85 %, red
155+
// above.
156+
func (g *Globals) meter(frac float64) string {
157+
const cells = 10
158+
frac = min(max(frac, 0), 1)
159+
n := int(frac*cells + 0.5)
160+
if frac > 0 && n == 0 {
161+
n = 1
162+
}
163+
full, empty := "█", "░"
164+
if !g.unicode() {
165+
full, empty = "#", "."
166+
}
167+
style := styleGreen
168+
switch {
169+
case frac >= 0.85:
170+
style = styleRed
171+
case frac >= 0.6:
172+
style = styleYellow
173+
}
174+
return g.styleOut(style, strings.Repeat(full, n)) + g.styleOut(styleGray, strings.Repeat(empty, cells-n))
175+
}
176+
177+
// thousands writes n with thousands separators: 65,536.
178+
func thousands(n int) string {
179+
s := strconv.Itoa(n)
180+
neg := strings.HasPrefix(s, "-")
181+
s = strings.TrimPrefix(s, "-")
182+
var b strings.Builder
183+
for i, r := range s {
184+
if i > 0 && (len(s)-i)%3 == 0 {
185+
b.WriteByte(',')
186+
}
187+
b.WriteRune(r)
188+
}
189+
if neg {
190+
return "-" + b.String()
191+
}
192+
return b.String()
193+
}
194+
195+
// printTuneSettings shows the settings in effect: one row per group with
196+
// what it means now, or with --details every key and value under its
197+
// group's title and explanation.
198+
func (g *Globals) printTuneSettings(applied map[string]string) {
199+
keys := make([]string, 0, len(applied))
200+
for key := range applied {
201+
keys = append(keys, key)
202+
}
203+
sort.Strings(keys)
204+
groups := tui.TuneGroups(keys)
205+
ok := g.sym().ok
206+
if !g.details {
207+
rows := make([][]string, 0, len(groups))
208+
for _, grp := range groups {
209+
rows = append(rows, []string{ok + " " + grp.Title, tui.TuneGroupSummary(grp, applied)})
210+
}
211+
cols := []boxCol{{title: i18n.T(i18n.CLITuneColGroup), min: 12}, {title: i18n.T(i18n.CLITuneColNow), min: 12, flex: true}}
212+
for _, l := range g.boxTable(cols, rows, func(_, c int) string {
213+
if c == 1 {
214+
return styleGreen
215+
}
216+
return ""
217+
}) {
218+
g.println(strings.Replace(l, ok, g.styleOut(styleGreen, ok), 1))
219+
}
220+
g.println(" " + g.styleOut(styleGray, g.text(i18n.T(i18n.CLITuneDetailsHint))))
221+
return
222+
}
223+
for i, grp := range groups {
224+
if i > 0 {
225+
g.println()
226+
}
227+
g.println(g.text(" " + g.styleOut(styleGreen, ok) + " " + g.styleOut(styleBold, grp.Title) + " " +
228+
g.styleOut(styleGray, g.sym().sep) + " " + g.styleOut(styleGreen, tui.TuneGroupSummary(grp, applied))))
229+
for _, l := range wrapText(tui.TuneGroupHelp(grp.ID), g.lineWidth()-4) {
230+
g.println(g.text(" " + g.styleOut(styleGray, l)))
231+
}
232+
rows := make([][]string, 0, len(grp.Keys))
233+
for _, key := range grp.Keys {
234+
rows = append(rows, []string{tui.TuneShortKey(key), orDash(tui.TuneValue(key, applied[key]))})
235+
}
236+
cols := []boxCol{{title: i18n.T(i18n.CLITuneColSetting), min: 10, flex: true}, {title: i18n.T(i18n.CLITuneColValue), min: 8, flex: true}}
237+
for _, l := range g.boxTable(cols, rows, nil) {
238+
g.println(l)
239+
}
240+
}
241+
}
242+
243+
// printTuneNodes is the nodes table: whether each is online, its profile
244+
// and whether it runs the hub's tuning.
245+
func (g *Globals) printTuneNodes(nodes []api.NodeTuneStatus) {
246+
s := g.sym()
247+
rows := make([][]string, 0, len(nodes))
248+
styles := make([][2]string, 0, len(nodes))
249+
for _, n := range nodes {
250+
online, onStyle := s.up+" "+i18n.T(i18n.TUIOnline), styleGreen
251+
if !n.Online {
252+
online, onStyle = s.down+" "+i18n.T(i18n.TUIOffline), styleRed
253+
}
254+
tuning, tStyle := s.ok+" "+i18n.T(i18n.CLITuneNodeSynced), styleGreen
255+
switch {
256+
case n.Pending:
257+
tuning, tStyle = s.half+" "+i18n.T(i18n.CLITuneNodeWaiting), styleYellow
258+
case n.Online && !n.AutoCapable:
259+
tuning, tStyle = s.warn+" "+i18n.T(i18n.CLITuneNodeBalanced), styleYellow
260+
}
261+
rows = append(rows, []string{n.Node, online, orDash(n.Profile), tuning})
262+
styles = append(styles, [2]string{onStyle, tStyle})
263+
}
264+
cols := []boxCol{
265+
{title: i18n.T(i18n.CLIColNode), min: 6, flex: true},
266+
{title: i18n.T(i18n.CLITuneColState)},
267+
{title: i18n.T(i18n.CLIColProfile)},
268+
{title: i18n.T(i18n.CLITuneColTuning), min: 8, flex: true},
269+
}
270+
for _, l := range g.boxTable(cols, rows, func(r, c int) string {
271+
switch c {
272+
case 1:
273+
return styles[r][0]
274+
case 3:
275+
return styles[r][1]
276+
}
277+
return ""
278+
}) {
279+
g.println(l)
280+
}
281+
}

0 commit comments

Comments
 (0)