@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
441 lines (395 loc) • 12.8 kB
text/typescript
import BigNumber from "bignumber.js";
import groupBy from "lodash/groupBy";
import { findCryptoCurrencyById, formatCurrencyUnit } from "../../currencies";
import { getDefaultExplorerView, getAddressExplorer } from "../../explorers";
import { AppSpec } from "../types";
import { Report, SpecPerBot } from "./types";
function round(n?: number): number | undefined {
if (n === undefined) return undefined;
return Math.round(n);
}
function makeCSV(data: (number | undefined | string)[][]): string {
return data
.map(row => row.map(cell => `"${typeof cell === "number" ? cell : cell || ""}"`).join(","))
.join("\n");
}
export function csvReports(
reports: Report[],
specsPerBots: SpecPerBot[],
): Array<{ filename: string; content: string }> {
const data: Datapoint[] = reports.map((report, i) => ({
report,
...specsPerBots[i],
}));
const columns: Array<{
label: string;
get: (d: Datapoint) => number | string | undefined;
}> = [
{
label: "seed",
get: (d: Datapoint) => d.seed,
},
{
label: "family",
get: (d: Datapoint) => d.family,
},
{
label: "coin",
get: (d: Datapoint) => d.key,
},
{
label: "Accounts count",
get: (d: Datapoint) => d.report.accountBalances?.length,
},
{
label: "Operations count",
get: (d: Datapoint) => d.report.accountOperationsLength?.reduce((a, b) => a + b, 0),
},
{
label: "Sync Time (ms)",
get: (d: Datapoint) => d.report.auditResult?.totalTime,
},
{
label: "cpuUserTime (ms)",
get: (d: Datapoint) => round(d.report.auditResult?.cpuUserTime),
},
{
label: "networkBandwidth (bytes)",
get: (d: Datapoint) => d.report.auditResult?.network.totalResponseSize,
},
{
label: "networkCount",
get: (d: Datapoint) => d.report.auditResult?.network.totalCount,
},
{
label: "accountsJSONSize (bytes)",
get: (d: Datapoint) => d.report.auditResult?.accountsJSONSize,
},
];
const csvs: Array<{ filename: string; content: string }> = [];
csvs.push({
filename: "csvs/all.csv",
content: makeCSV([columns.map(c => c.label), ...data.map(d => columns.map(c => c.get(d)))]),
});
const byFamily = groupBy(data, d => d.family);
for (const [family, data] of Object.entries(byFamily)) {
csvs.push({
filename: `csvs/by-family/${family}.csv`,
content: makeCSV([columns.map(c => c.label), ...data.map(d => columns.map(c => c.get(d)))]),
});
}
const byCurrency = groupBy(data, d => d.spec.currency.id);
for (const [currencyId, data] of Object.entries(byCurrency)) {
csvs.push({
filename: `csvs/by-currency/${currencyId}.csv`,
content: makeCSV([columns.map(c => c.label), ...data.map(d => columns.map(c => c.get(d)))]),
});
}
return csvs;
}
export function finalMarkdownReport(reports: Report[], specsPerBots: SpecPerBot[]): string {
const data = reports.map((report, i) => ({ report, ...specsPerBots[i] }));
const { table, title } = markdownHelpers(data);
let md = "";
md += title("Portfolio");
md += table<Datapoint>({
title: "Funds status",
formatValue: d => {
const { spec } = d;
if (!spec) return;
const viableAccounts = (d.report?.accountBalances || []).filter(b =>
BigNumber(b).gt(spec.minViableAmount || 0),
).length;
if (viableAccounts > 0) return "✅";
const explorerView = getDefaultExplorerView(d.spec.currency);
const refillAddress = d.report.refillAddress || "";
const url = !refillAddress
? ""
: explorerView && refillAddress
? getAddressExplorer(explorerView, refillAddress)
: "data:text," + refillAddress;
const value = url;
if (url) {
return `[🙏](${url})`;
}
return value || "⚠️";
},
});
md += table({
title: "Balances",
lenseValue: d =>
(d.report?.accountBalances || []).reduce((acc, r) => acc.plus(r), BigNumber(0)),
reduce: (d, map) => d.reduce((acc, r) => acc.plus(map(r)), BigNumber(0)),
formatValue: (v, ctx) => formatCurrencyUnit(ctx.spec.currency.units[0], v, { showCode: true }),
totalPerCurrency: true,
});
md += table({
title: "Accounts count",
lenseValue: d => d.report?.accountBalances?.length || 0,
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "Operations count",
lenseValue: d => d.report?.accountOperationsLength?.reduce((a, b) => a + b || 0, 0) || 0,
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += title("Performance");
md += table({
title: "Sync Times",
lenseValue: d => d.report?.auditResult?.totalTime,
formatValue: v => formatTime(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "HTTP Calls Count",
lenseValue: d => d.report?.auditResult?.network?.totalCount || 0,
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "HTTP Bandwidth",
lenseValue: d => d.report?.auditResult?.network?.totalResponseSize || 0,
formatValue: v => formatSize(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "Duplicate HTTP Calls",
lenseValue: d => d.report?.auditResult?.network?.totalDuplicateRequests || 0,
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "Accounts Data Size",
lenseValue: d => d.report?.auditResult?.accountsJSONSize,
formatValue: v => formatSize(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "Currency Preloaded Data Size",
lenseValue: d => d.report?.auditResult?.preloadJSONSize,
formatValue: v => formatSize(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
md += table({
title: "CPU user time",
lenseValue: d => d.report?.auditResult?.cpuUserTime,
formatValue: v => formatTime(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
/*
md += table({
title: "CPU system time",
lenseValue: (d) => d.report?.auditResult?.cpuSystemTime,
formatValue: (v) => formatTime(v),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
*/
md += table({
title: "Memory RSS",
lenseValue: d => d.report?.auditResult?.memoryEnd.rss,
formatValue: v => formatSize(v),
});
md += table({
title: "JS Boot Time",
lenseValue: d => d.report?.auditResult?.jsBootTime,
formatValue: v => formatTime(v),
});
md += table<{ count: number; duration: number } | undefined>({
title: "JS Slow Frames",
reduce: (d, map) =>
d.reduce(
(acc, r) => ({
count: acc.count + (map(r)?.count || 0),
duration: acc.duration + (map(r)?.duration || 0),
}),
{ count: 0, duration: 0 },
),
lenseValue: d => d.report?.auditResult?.slowFrames,
formatValue: v => (v ? (v.count === 0 ? "✅" : `${v.count} (${formatTime(v.duration)})`) : ""),
totalPerCurrency: true,
totalPerSeed: true,
totalPerFamily: true,
});
const errors = data.filter(d => d.report.error);
if (errors.length) {
md += "\n\n# Errors\n";
const sortKey = d => `${d.spec.name}: ${d.report.error}`;
md += errors
.slice(0)
.sort((a, b) => sortKey(a).localeCompare(sortKey(b)))
.map(d => `- ${d.seed}: ${d.spec.name}: ${d.report.error}`)
.join("\n");
}
return md;
}
function formatTime(ms: number | undefined): string | undefined {
if (!ms) return;
const s = ms / 1000;
return s < 9 ? `${s.toFixed(2)}s` : `${s.toFixed(0)}s`;
}
function formatSize(bytes: number | undefined): string | undefined {
if (!bytes) return;
const kb = bytes / 1024;
return kb < 1024 ? `${kb.toFixed(0)}kb` : `${(kb / 1024).toFixed(0)}mb`;
}
type Datapoint = {
seed: string;
env: unknown;
spec: AppSpec<any>;
family: string;
key: string;
report: Report;
};
type FormatDatapoint = (_: Datapoint) => string | number | undefined;
type FormatDatapointMulti = (_: Datapoint[]) => string | number | undefined;
type TableF = <V>(opts: {
title: string;
lenseValue?: (_: Datapoint) => V;
formatValue?: (_: V, ctx: Datapoint) => string | undefined;
reduce?: (_: Datapoint[], map: (v: Datapoint) => V) => V;
totalPerFamily?: boolean;
totalPerCurrency?: boolean;
totalPerSeed?: boolean;
}) => string;
function markdownHelpers(data: Datapoint[]): {
table: TableF;
title: (txt: string) => string;
strong: (txt: string) => string;
} {
const seedNames = Array.from(new Set(data.map(d => d.seed)));
seedNames.sort();
// specs are assumed to be grouped by family already
const specs = Array.from(new Set(data.map(d => d.spec)));
function strong(txt: string): string {
return "**" + txt + "**";
}
function title(txt: string): string {
return "\n# " + txt + "\n\n";
}
function genTable(
f: FormatDatapoint,
opts: {
totalPerFamily?: FormatDatapointMulti;
totalPerCurrency?: FormatDatapointMulti;
totalPerSeed?: FormatDatapointMulti;
} = {},
): string {
const { totalPerFamily, totalPerCurrency, totalPerSeed } = opts;
let md = "\n\n";
md += "| Currency |" + seedNames.join(" | ");
if (totalPerCurrency) {
md += "| Total ";
}
md += "|\n";
md += "|--|" + Array.from(seedNames).fill("--|").join("");
if (totalPerCurrency) {
md += "--|";
}
md += "\n";
let lastFamily = "";
for (const spec of specs) {
let soloInFamilyFactorisation = false;
if (totalPerFamily && spec.currency.family !== lastFamily) {
lastFamily = spec.currency.family;
soloInFamilyFactorisation = !specs.some(
s => s !== spec && s.currency.family === lastFamily,
);
if (!soloInFamilyFactorisation) {
const familyName =
findCryptoCurrencyById(spec.currency.family)?.name || spec.currency.family;
md += "| " + strong(familyName) + " family | ";
md += seedNames
.map(seed => {
const all = data.filter(
d => d.seed === seed && d.spec.currency.family === lastFamily,
);
return (totalPerFamily(all) || "") + " |";
})
.join("");
if (totalPerCurrency) {
const all = data.filter(d => d.spec.currency.family === lastFamily);
md += (totalPerCurrency(all) || "") + " |";
}
md += "\n";
}
}
const name = spec.currency.name;
md += "| " + (soloInFamilyFactorisation ? strong(name) : name) + " | ";
md +=
seedNames
.map(seed => {
const d = data.find(d => d.seed === seed && d.spec === spec);
if (!d) return "?";
if (d.report.error) return "❌";
return f(d) || "";
})
.join(" | ") + " |";
if (totalPerCurrency) {
md += (totalPerCurrency(data.filter(d => d.spec === spec)) || "") + " |";
}
md += "\n";
}
if (totalPerSeed) {
md += "| **Total** | ";
md += seedNames
.map(seed => {
const all = data.filter(d => d.seed === seed);
return (totalPerSeed(all) || "") + " |";
})
.join("");
if (totalPerCurrency) {
const all = data;
md += (totalPerCurrency(all) || "") + " |";
}
md += "\n";
}
md += "\n";
return md;
}
// as any is a hack to make a "defaults" that would work for most case. we will need to see how to make typing better
const defaults = {
lenseValue: (d: Datapoint) => d as any,
formatValue: <V>(v: V) => String(v),
reduce: <V>(v: Datapoint[], map: (v: Datapoint) => V): V =>
v.reduce((sum: number, v) => sum + Number(map(v)), 0) as any,
};
const table: TableF = opts => {
const {
title,
lenseValue,
formatValue,
totalPerFamily,
totalPerCurrency,
totalPerSeed,
reduce,
} = { ...defaults, ...opts };
const total = d => formatValue(reduce(d, lenseValue), d[0]);
let md = `\n<details><summary><b>${title}</b></summary>\n`;
md += genTable(d => formatValue(lenseValue(d), d), {
totalPerFamily: totalPerFamily ? total : undefined,
totalPerCurrency: totalPerCurrency ? total : undefined,
totalPerSeed: totalPerSeed ? total : undefined,
});
md += "</details>\n\n";
return md;
};
return { table, strong, title };
}