From 05c89bbd1c1538aa6094702e6aad224c1d36d662 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 23 Aug 2023 11:30:09 +0200 Subject: [PATCH] ui: visualize lnd lightning report on the tab panel similarly to 0260d477, the lightning tab has now some basic info including channels list. the gui playground is updated to send some stub data via comms periodically. --- src/ngui.zig | 21 ++++- src/test/guiplay.zig | 72 +++++++++++++++- src/ui/c/ui.c | 18 ++-- src/ui/lightning.zig | 198 +++++++++++++++++++++++++++++++++++++++++++ src/ui/ui.zig | 9 ++ src/xfmt.zig | 40 +++++++++ 6 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 src/ui/lightning.zig diff --git a/src/ngui.zig b/src/ngui.zig index 0055b14..01682e2 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -42,6 +42,7 @@ var last_report: struct { mu: std.Thread.Mutex = .{}, network: ?comm.ParsedMessage = null, // NetworkReport bitcoind: ?comm.ParsedMessage = null, // BitcoinReport + lightning: ?comm.ParsedMessage = null, // LightningReport fn deinit(self: *@This()) void { self.mu.lock(); @@ -54,6 +55,10 @@ var last_report: struct { v.deinit(); self.bitcoind = null; } + if (self.lightning) |v| { + v.deinit(); + self.lightning = null; + } } fn replace(self: *@This(), new: comm.ParsedMessage) void { @@ -73,6 +78,12 @@ var last_report: struct { } self.bitcoind = new; }, + .lightning_report => { + if (self.lightning) |old| { + old.deinit(); + } + self.lightning = new; + }, else => |t| logger.err("last_report: replace: unhandled tag {}", .{t}), } } @@ -220,8 +231,10 @@ fn commThreadLoopCycle() !void { switch (state) { .standby => switch (msg.value) { .ping => try comm.write(gpa, stdout, comm.Message.pong), - .network_report => last_report.replace(msg), - .bitcoind_report => last_report.replace(msg), + .network_report, + .bitcoind_report, + .lightning_report, + => last_report.replace(msg), else => logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}), }, .active, .alert => switch (msg.value) { @@ -238,6 +251,10 @@ fn commThreadLoopCycle() !void { ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, + .lightning_report => |rep| { + ui.lightning.updateTabPanel(rep) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + last_report.replace(msg); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg.value)}), }, } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 29e3999..d8802e5 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -128,7 +128,7 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var sectimer = try time.Timer.start(); - var block_count: u64 = 801365; + var block_count: u32 = 801365; while (true) { time.sleep(time.ns_per_s); @@ -164,6 +164,76 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { }, }; comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + + if (block_count % 2 == 0) { + const lndrep: comm.Message.LightningReport = .{ + .version = "0.16.4-beta commit=v0.16.4-beta", + .pubkey = "142874abcdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac982de822a", + .alias = "testnode", + .npeers = 15, + .height = block_count, + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .sync = .{ .chain = true, .graph = true }, + .uris = &.{}, // TODO + .totalbalance = .{ .local = 10123567, .remote = 4239870, .unsettled = 0, .pending = 430221 }, + .totalfees = .{ .day = 13, .week = 132, .month = 1321 }, + .channels = &.{ + .{ + .id = null, + .state = .pending_open, + .private = false, + .point = "1b332afe982befbdcbadff33099743099eef00bcdbaef788320db328efeaa91b:0", + .closetxid = null, + .peer_pubkey = "def3829fbdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229aaabc2", + .peer_alias = "chan-peer-alias1", + .capacity = 900000, + .balance = .{ .local = 1123456, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = null, + .state = .pending_close, + .private = false, + .point = "932baef3982befbdcbadff33099743099eef00bcdbaef788320db328e82afdd7:0", + .closetxid = "fe829832982befbdcbadff33099743099eef00bcdbaef788320db328eaffeb2b", + .peer_pubkey = "01feba38fe8adbeef8839bdfaf8439fac9b0327bf78acdee8928efbac2abfec831", + .peer_alias = "chan-peer-alias2", + .capacity = 800000, + .balance = .{ .local = 10000, .remote = 788000, .unsettled = 0, .limbo = 10000 }, + .totalsats = .{ .sent = 0, .received = 0 }, + .fees = .{ .base = 0, .ppm = 0 }, + }, + .{ + .id = "848352385882718209", + .state = .active, + .private = false, + .point = "36277666abcbefbdcbadff33099743099eef00bcdbaef788320db328e828e00d:1", + .closetxid = null, + .peer_pubkey = "e7287abcfdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229acddbe", + .peer_alias = "chan-peer-alias3", + .capacity = 1000000, + .balance = .{ .local = 1000000 / 2, .remote = 1000000 / 2, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 3287320, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 400 }, + }, + .{ + .id = "134439885882718428", + .state = .inactive, + .private = false, + .point = "abafe483982befbdcbadff33099743099eef00bcdbaef788320db328e828339c:0", + .closetxid = null, + .peer_pubkey = "20398287fdeadbeef8839bdfaf8439fac9b0327bf78acdee8928efbac229a03928", + .peer_alias = "chan-peer-alias4", + .capacity = 900000, + .balance = .{ .local = 900000, .remote = 0, .unsettled = 0, .limbo = 0 }, + .totalsats = .{ .sent = 328732, .received = 2187482 }, + .fees = .{ .base = 1000, .ppm = 500 }, + }, + }, + }; + comm.write(gpa, w, .{ .lightning_report = lndrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } } } diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index bb12fb9..eb28f4c 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -25,6 +25,11 @@ int nm_create_info_panel(lv_obj_t *parent); */ int nm_create_bitcoin_panel(lv_obj_t *parent); +/** + * creates the lightning tab panel. + */ +int nm_create_lightning_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -108,15 +113,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_lnd_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "lightning tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static struct { lv_obj_t *wifi_spinner_obj; /* lv_spinner_create */ lv_obj_t *wifi_status_obj; /* lv_label_create */ @@ -342,7 +338,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_lnd == NULL) { return -1; } - create_lnd_panel(tab_lnd); + if (nm_create_lightning_panel(tab_lnd) != 0) { + return -1; + } lv_obj_t *tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS " SETTINGS"); if (tab_settings == NULL) { diff --git a/src/ui/lightning.zig b/src/ui/lightning.zig new file mode 100644 index 0000000..3b7100a --- /dev/null +++ b/src/ui/lightning.zig @@ -0,0 +1,198 @@ +//! lightning main tab panel and other functionality. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const xfmt = @import("../xfmt.zig"); + +const logger = std.log.scoped(.ui_lnd); +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; + +var tab: struct { + info: struct { + alias: lvgl.Label, + blockhash: lvgl.Label, + currblock: lvgl.Label, + npeers: lvgl.Label, + pubkey: lvgl.Label, + version: lvgl.Label, + }, + balance: struct { + avail: lvgl.Bar, // local vs remote + local: lvgl.Label, + remote: lvgl.Label, + unsettled: lvgl.Label, + pending: lvgl.Label, + fees: lvgl.Label, // day, week, month + }, + channels_cont: lvgl.FlexLayout, +} = undefined; + +/// creates the tab content with all elements. +/// must be called only once at UI init. +pub fn initTabPanel(cont: lvgl.Container) !void { + const parent = cont.flex(.column, .{}); + + // info section + { + const card = try lvgl.Card.new(parent, "INFO"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setHeightToContent(); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setHeightToContent(); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.info.alias = try lvgl.Label.new(left, "ALIAS\n", .{ .recolor = true }); + tab.info.pubkey = try lvgl.Label.new(left, "PUBKEY\n", .{ .recolor = true }); + tab.info.version = try lvgl.Label.new(left, "VERSION\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setHeightToContent(); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.info.currblock = try lvgl.Label.new(right, "HEIGHT\n", .{ .recolor = true }); + tab.info.blockhash = try lvgl.Label.new(right, "BLOCK HASH\n", .{ .recolor = true }); + tab.info.npeers = try lvgl.Label.new(right, "CONNECTED PEERS\n", .{ .recolor = true }); + } + // balance section + { + const card = try lvgl.Card.new(parent, "BALANCE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.balance.avail = try lvgl.Bar.new(left); + tab.balance.avail.setWidth(lvgl.sizePercent(90)); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(90)); + subrow.setHeightToContent(); + tab.balance.local = try lvgl.Label.new(subrow, "LOCAL\n", .{ .recolor = true }); + tab.balance.remote = try lvgl.Label.new(subrow, "REMOTE\n", .{ .recolor = true }); + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.balance.pending = try lvgl.Label.new(right, "PENDING\n", .{ .recolor = true }); + tab.balance.unsettled = try lvgl.Label.new(right, "UNSETTLED\n", .{ .recolor = true }); + // bottom + tab.balance.fees = try lvgl.Label.new(card, "ACCUMULATED FORWARDING FEES\n", .{ .recolor = true }); + } + // channels section + { + const card = try lvgl.Card.new(parent, "CHANNELS"); + tab.channels_cont = try lvgl.FlexLayout.new(card, .column, .{}); + tab.channels_cont.setHeightToContent(); + tab.channels_cont.setWidth(lvgl.sizePercent(100)); + tab.channels_cont.clearFlag(.scrollable); + tab.channels_cont.setPad(10, .row, .{}); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.LightningReport) !void { + var buf: [512]u8 = undefined; + + // info section + try tab.info.alias.setTextFmt(&buf, cmark ++ "ALIAS#\n{s}", .{rep.alias}); + try tab.info.pubkey.setTextFmt(&buf, cmark ++ "PUBKEY#\n{s}\n{s}", .{ rep.pubkey[0..33], rep.pubkey[33..] }); + try tab.info.version.setTextFmt(&buf, cmark ++ "VERSION#\n{s}", .{rep.version}); + try tab.info.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.height}); + try tab.info.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + try tab.info.npeers.setTextFmt(&buf, cmark ++ "CONNECTED PEERS#\n{d}", .{rep.npeers}); + + // balance section + const local_pct: i32 = pct: { + const total = rep.totalbalance.local + rep.totalbalance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(rep.totalbalance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + tab.balance.avail.setValue(local_pct); + try tab.balance.local.setTextFmt(&buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(rep.totalbalance.local)}); + try tab.balance.remote.setTextFmt(&buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(rep.totalbalance.remote)}); + try tab.balance.pending.setTextFmt(&buf, cmark ++ "PENDING#\n{} sat", .{xfmt.imetric(rep.totalbalance.pending)}); + try tab.balance.unsettled.setTextFmt(&buf, cmark ++ "UNSETTLED#\n{}", .{xfmt.imetric(rep.totalbalance.unsettled)}); + try tab.balance.fees.setTextFmt(&buf, cmark ++ "ACCUMULATED FORWARDING FEES#\nDAY: {} sat WEEK: {} sat MONTH: {} sat", .{ + xfmt.umetric(rep.totalfees.day), + xfmt.umetric(rep.totalfees.week), + xfmt.umetric(rep.totalfees.month), + }); + + // channels section + tab.channels_cont.deleteChildren(); + for (rep.channels) |ch| { + const chbox = (try lvgl.Container.new(tab.channels_cont)).flex(.column, .{}); + chbox.setWidth(lvgl.sizePercent(100)); + chbox.setHeightToContent(); + _ = try switch (ch.state) { + // TODO: sanitize peer_alias? + .active => lvgl.Label.newFmt(chbox, &buf, "{s}", .{ch.peer_alias}, .{}), + .inactive => lvgl.Label.newFmt(chbox, &buf, "#ff0000 [INACTIVE]# {s}", .{ch.peer_alias}, .{ .recolor = true }), + .pending_open => lvgl.Label.new(chbox, "#00ff00 [PENDING OPEN]#", .{ .recolor = true }), + .pending_close => lvgl.Label.new(chbox, "#ffff00 [PENDING CLOSE]#", .{ .recolor = true }), + }; + const row = try lvgl.FlexLayout.new(chbox, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(46)); + left.setHeightToContent(); + left.setPad(10, .row, .{}); + const bbar = try lvgl.Bar.new(left); + bbar.setWidth(lvgl.sizePercent(100)); + const chan_local_pct: i32 = pct: { + const total = ch.balance.local + ch.balance.remote; + if (total == 0) { + break :pct 0; + } + const v = @as(f64, @floatFromInt(ch.balance.local)) / @as(f64, @floatFromInt(total)); + break :pct @intFromFloat(v * 100); + }; + bbar.setValue(chan_local_pct); + const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); + subrow.setWidth(lvgl.sizePercent(100)); + subrow.setHeightToContent(); + const subcol1 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol1.setPad(10, .row, .{}); + subcol1.setHeightToContent(); + const subcol2 = try lvgl.FlexLayout.new(subrow, .column, .{}); + subcol2.setPad(10, .row, .{}); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "LOCAL#\n{} sat", .{xfmt.imetric(ch.balance.local)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "RECEIVED#\n{} sat", .{xfmt.imetric(ch.totalsats.received)}, .{ .recolor = true }); + if (ch.state == .active or ch.state == .inactive) { + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "BASE FEE#\n{} msat", .{xfmt.imetric(ch.fees.base)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol1, &buf, cmark ++ "FEE PPM#\n{d}", .{ch.fees.ppm}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "REMOTE#\n{} sat", .{xfmt.imetric(ch.balance.remote)}, .{ .recolor = true }); + _ = try lvgl.Label.newFmt(subcol2, &buf, cmark ++ "SENT#\n{} sat", .{xfmt.imetric(ch.totalsats.sent)}, .{ .recolor = true }); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(54)); + right.setHeightToContent(); + right.setPad(10, .row, .{}); + if (ch.id) |id| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "ID#\n{s}", .{id}, .{ .recolor = true }); + } + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "FUNDING TX#\n{s}\n{s}", .{ ch.point[0..32], ch.point[32..] }, .{ .recolor = true }); + if (ch.closetxid) |tx| { + _ = try lvgl.Label.newFmt(right, &buf, cmark ++ "CLOSING TX#\n{s}\n{s}", .{ tx[0..32], tx[32..] }, .{ .recolor = true }); + } + } +} diff --git a/src/ui/ui.zig b/src/ui/ui.zig index e23f37b..5940bb9 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -8,6 +8,7 @@ const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); pub const bitcoin = @import("bitcoin.zig"); +pub const lightning = @import("lightning.zig"); const logger = std.log.scoped(.ui); @@ -43,6 +44,14 @@ export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { + lightning.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createLightningPanel: {any}", .{err}); + return -1; + }; + return 0; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; diff --git a/src/xfmt.zig b/src/xfmt.zig index db89cf4..0460f86 100644 --- a/src/xfmt.zig +++ b/src/xfmt.zig @@ -8,6 +8,16 @@ pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { return .{ .data = sec }; } +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn imetric(val: i64) std.fmt.Formatter(formatMetricI) { + return .{ .data = val }; +} + +/// returns a metric formatter, outputting the value with SI unit suffix. +pub fn umetric(val: u64) std.fmt.Formatter(formatMetricU) { + return .{ .data = val }; +} + fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { _ = fmt; // unused _ = opts; @@ -29,3 +39,33 @@ fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w daysec.getSecondsIntoMinute(), }); } + +fn formatMetricI(value: i64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const uval: u64 = std.math.absCast(value); + const base: u64 = 1000; + if (uval < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + if (value < 0) { + try w.writeByte('-'); + } + return formatMetricU(uval, fmt, opts, w); +} + +/// based on `std.fmt.fmtIntSizeDec`. +fn formatMetricU(value: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + const lossyCast = std.math.lossyCast; + const base: u64 = 1000; + if (value < base) { + return std.fmt.formatIntValue(value, fmt, opts, w); + } + + const mags_si = " kMGTPEZY"; + const log2 = std.math.log2(value); + const m = @min(log2 / comptime std.math.log2(base), mags_si.len - 1); + const newval = lossyCast(f64, value) / std.math.pow(f64, lossyCast(f64, base), lossyCast(f64, m)); + const suffix = mags_si[m]; + try std.fmt.formatFloatDecimal(newval, opts, w); + try w.writeByte(suffix); +}