Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// Built on @profullstack/hqtui, the same library behind `coinpay finances`.

import type { Container, RenderArgs, Theme } from "@profullstack/hqtui";
import { markdownText } from "@profullstack/hqtui";

import { collectDashboard, type CoinPayAuth, type DashboardSnapshot, type SiteStats, type FeedName, type FeedProgress } from "../lib/dashboard/collect";
import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi";
Expand Down Expand Up @@ -1010,6 +1011,39 @@ function feedBusy(feed: FeedProgress): boolean {
return feed.status === "loading" || feed.status === "retrying";
}

/** Context travels with every copied pane, including partial/stale source warnings. */
export function dashboardMarkdownContext(state: State): string {
const snapshot = state.snapshot;
const sourceErrors = [
...Object.entries(snapshot?.errors ?? {}).map(([source, error]) => `${source}: ${error}`),
...Object.entries(snapshot?.finance?.errors ?? {}).map(([source, error]) => `CoinPay ${source}: ${error}`),
];
return [
`CrawlProof · ${state.domain ?? "Fleet"} · ${TABS[state.tab]}`,
`Displayed range: ${snapshot?.window.range ?? state.range} · ${snapshot?.window.who ?? state.who}`,
...(snapshot && (snapshot.window.range !== state.range || snapshot.window.who !== state.who) ? [`Requested range: ${state.range} · ${state.who} (refresh pending)`] : []),
`CoinPay window: ${snapshot?.window.financeDays ?? FINANCE_DAYS[state.range] ?? 30} days`,
`Snapshot: ${snapshot?.generatedAt ?? "not loaded"}`,
`Status: ${state.loading ? "refreshing" : state.paused ? "paused" : "live"}`,
...(state.error ? [`Refresh failed: ${state.error}`] : []),
...(snapshot?.adsStale ? [`Ads are stale: last successful read ${snapshot.adsUpdatedAt ?? snapshot.generatedAt}`] : []),
...sourceErrors,
...(snapshot?.roi.caveats ?? []).map((caveat) => `Note: ${caveat}`),
].join("\n");
}

export function dashboardStatusMarkdown(state: State): string {
return [
"## CrawlProof status",
markdownText(dashboardMarkdownContext(state)),
...(["traffic", "ads", "finance"] as const).map((name) => {
const feed = state.feeds[name];
return `- **${name === "finance" ? "CoinPay" : name === "ads" ? "Ads" : "Traffic"}:** ${markdownText(feed.status)} — ${markdownText(feed.detail)}`;
}),
...(state.refreshMessage ? [markdownText(state.refreshMessage)] : []),
].join("\n\n") + "\n";
}

/** Each request has a visible lifecycle on every screen, including the first load. */
export function renderFetchStatus(ui: Container, state: State, theme: Theme): void {
ui.row({ size: 1, gap: 2 }, (row) => {
Expand All @@ -1023,6 +1057,7 @@ export function renderFetchStatus(ui: Container, state: State, theme: Theme): vo
});
});
}
row.copyButton({ markdown: () => dashboardStatusMarkdown(state), width: 6 });
});
if (state.loading) {
const elapsed = state.refreshStartedAt ? Math.floor((Date.now() - state.refreshStartedAt.getTime()) / 1000) : 0;
Expand Down Expand Up @@ -1329,6 +1364,8 @@ export function createRefreshController(
export async function runDashboard(opts: DashboardOptions): Promise<void> {
const hqtui = await loadHqtui();
const app = await hqtui.createApp({
copyMarkdown: true,
markdownContext: () => dashboardMarkdownContext(state),
fps: 30,
theme: (opts.theme as never) || "dark",
quitKeys: ["ctrl+c", "q"],
Expand Down Expand Up @@ -1409,7 +1446,8 @@ export async function runDashboard(opts: DashboardOptions): Promise<void> {
width: 76,
height: 28,
message:
"1-5, Tab, ←/→ switch screens.\n" +
"1-5, ←/→ switch screens.\n" +
"⧉ MD copies a summary; Tab focuses, Enter copies.\n" +
`r refreshes now; it also refreshes every ${interval}s.\n` +
"w cycles the window: 1h → 4h → 1d → 1w → 1m.\n" +
"b cycles who counts: humans → all → bots.\n" +
Expand All @@ -1429,7 +1467,8 @@ export async function runDashboard(opts: DashboardOptions): Promise<void> {
" are the same account on both sides of our own network, so\n" +
" they are reported under Internal and counted as neither.\n\n" +
"Press any key to close.",
buttons: [{ label: "Close", focused: true }],
buttons: [{ label: "Close", onPress: () => { state.showHelp = false; } }],
onDismiss: () => { state.showHelp = false; },
});
}
});
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"@modelcontextprotocol/sdk": "^1.26.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af",
"@profullstack/coinpay": "^0.9.0",
"@profullstack/hqtui": "^0.6.2",
"@profullstack/hqtui": "^0.6.3",
"@profullstack/referrals": "^0.1.0",
"@profullstack/stack": "^0.1.3",
"@profullstack/x402-client": "^0.2.0",
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ and CoinPay for what the bank actually did.
| Money | Earnings, bank position, invoices, income vs spending by month |
| Spend | Who we pay, largest first, and burn by category |

`1`–`5` or Tab switches screens, `w` cycles the window, `b` cycles humans /
`1`–`5` or the left/right arrows switch screens, `w` cycles the window, `b` cycles humans /
all / bots, `r` refreshes, `?` explains the arithmetic, `q` quits.

Traffic, ads and CoinPay each show an animated spinner while fetching, with
Expand Down Expand Up @@ -136,3 +136,12 @@ neither.
## License

MIT

### Copy summaries as Markdown

Click **⧉ MD** on a summary pane or the feed-status strip. Tab / Shift+Tab
focus controls; Enter / Space copies the focused summary. Copied Markdown includes
the screen/domain, displayed range, CoinPay window, snapshot time, stale-source
warnings and source errors. The status export names failures such as CoinPay
payouts instead of just reporting the unavailable-source count. Data rows are
excluded. Clipboard delivery uses the terminal's OSC 52 support, including over SSH.
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/crawlproof",
"version": "0.2.0",
"version": "0.2.1",
"description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.",
"license": "MIT",
"type": "module",
Expand All @@ -21,7 +21,7 @@
},
"dependencies": {
"@profullstack/coinpay": "^0.9.0",
"@profullstack/hqtui": "^0.6.2"
"@profullstack/hqtui": "^0.6.3"
},
"devDependencies": {
"esbuild": "^0.25.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { collectDashboard } from "../../../lib/dashboard/collect";
import { renderStats } from "../../../lib/dashboard/stats-text";
import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard";

export const VERSION = "0.2.0";
export const VERSION = "0.2.1";

type Args = {
command: string;
Expand Down
61 changes: 61 additions & 0 deletions tests/dashboard-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { renderToScreen } from "@profullstack/hqtui/testing";
import { dashboardMarkdownContext, initialState, renderBody, type State } from "@/cli/dashboard";
import { buildRoi } from "@/lib/dashboard/roi";

function state(): State {
const finance = { errors: { payouts: "Failed to fetch payouts" }, windowDays: 30 };
return initialState({ range: "1m", snapshot: {
generatedAt: "2026-09-13T12:00:00Z",
window: { range: "1m", who: "humans", financeDays: 30 },
sites: [], fleet: { sources: [], pages: [], referrers: [] },
ads: null, finance, errors: { finance: "1 CoinPay sources unavailable" },
adsStale: true, adsUpdatedAt: "2026-09-12T12:00:00Z",
roi: buildRoi({ traffic: { range: "1m", who: "humans", sites: [] }, ads: null, finance }),
} });
}

const draw = (s: State) => renderToScreen(({ ui, theme }) => renderBody(ui, s, theme), {
width: 160, height: 44, copyMarkdown: true, markdownContext: dashboardMarkdownContext(s),
});

describe("dashboard Markdown copy", () => {
it("copies exact feed errors, displayed/requested ranges, timestamps and stale warnings", () => {
const s = state();
s.range = "1w";
s.who = "bots";
s.loading = true;
s.feeds.finance = { status: "error", detail: "1 CoinPay sources unavailable" };
const screen = draw(s);
const icon = screen.find("⧉ MD")!;
expect(icon).not.toBeNull();
screen.click(icon.x, icon.y);
const text = screen.copied[0];
expect(text).toContain("## CrawlProof status");
expect(text).toContain("Displayed range: 1m · humans");
expect(text).toContain("Requested range: 1w · bots (refresh pending)");
expect(text).toContain("CoinPay window: 30 days");
expect(text).toContain("CoinPay payouts: Failed to fetch payouts");
expect(text).toContain("Ads are stale: last successful read 2026-09-12T12:00:00Z");
});

it("makes ROI summaries independently copyable with caveats", () => {
const screen = draw(state());
for (const r of screen.regions) screen.click(r.rect.x, r.rect.y);
const summary = screen.copied.find((text) => text.startsWith("## The number"));
expect(summary).toBeDefined();
expect(summary).toContain("CrawlProof · Fleet · ROI");
expect(summary).toContain("Snapshot: 2026-09-13T12:00:00Z");
expect(summary).toContain("CoinPay payouts: Failed to fetch payouts");
expect(screen.copied.some((text) => text.startsWith("## Read this before quoting a number"))).toBe(true);
});

it("lets startup and failed refresh status be copied before a snapshot arrives", () => {
const s = initialState({ error: "Not connected <retry>" });
const screen = draw(s);
const icon = screen.find("⧉ MD")!;
screen.click(icon.x, icon.y);
expect(screen.copied[0]).toContain("Snapshot: not loaded");
expect(screen.copied[0]).toContain("Not connected \\<retry\\>");
});
});
Loading