ui: visualize lnd lightning report on the tab panel
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details

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.
pull/26/head
alex 1 year ago
parent 52a8c1fb1a
commit 05c89bbd1c
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -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)}),
},
}

@ -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});
}
}
}

@ -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) {

@ -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 });
}
}
}

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

@ -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);
}