|
| 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