Surface Monero health status in UI and docs

This commit is contained in:
xmrcheckout
2026-03-26 15:46:16 +00:00
parent d22f1ba0dd
commit ff96413d92
5 changed files with 337 additions and 9 deletions
+42
View File
@@ -147,6 +147,48 @@ If you’re running the bundled `monerod` service:
docker compose --profile local-daemon up --build -d docker compose --profile local-daemon up --build -d
``` ```
### Troubleshooting first payment detection
If an invoice stays at `0` confirmations or never moves out of `Awaiting funds`, check these in order:
1. Open the dashboard overview and confirm:
- `Wallet RPC` shows `ok`
- `Daemon` shows `connected`
- `Current daemon height` is visible and keeps increasing
- `Last successful reconcile` is recent
2. Open the public invoice page and confirm the same health indicators are present there. If block height is unavailable, the node path is broken before invoice detection can work.
3. If the bundled wallet-rpc containers keep restarting, inspect their logs:
```
docker compose logs wallet-rpc-reconciler-1
docker compose logs wallet-rpc-reconciler-2
docker compose logs wallet-rpc-reconciler-3
```
Common causes:
- wrong `MONERO_WALLET_RPC_WALLET_PASSWORD`
- wallet cache file does not match the `.keys` file
- daemon URL is unreachable from the wallet-rpc containers
4. If you are running the bundled `monerod`, check whether it is synced:
```
docker compose logs monerod
```
Initial sync can take a long time. Payment detection is not reliable until the daemon is caught up.
5. If the reconciler is not reporting a recent successful scan, inspect the API and reconciler logs:
```
docker compose logs api
docker compose logs reconciler
```
The reconciler is what updates invoice detection and confirmations. If it is down, invoices will not advance even when wallet-rpc and the daemon are healthy.
### Optional: Postgres backups (disabled by default) ### Optional: Postgres backups (disabled by default)
This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host. This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host.
+102 -1
View File
@@ -8,6 +8,7 @@ import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-styl
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section"; import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
import DefaultQrLogoSection from "../../../components/default-qr-logo-section"; import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
import InvoicePanel from "../../../components/invoice-panel"; import InvoicePanel from "../../../components/invoice-panel";
import { formatRelativeTime } from "../../../components/relative-time";
import WebhookHistoryPanel from "../../../components/webhook-history-panel"; import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSecretSection from "../../../components/webhook-secret-section"; import WebhookSecretSection from "../../../components/webhook-secret-section";
import WebhookSection from "../../../components/webhook-section"; import WebhookSection from "../../../components/webhook-section";
@@ -75,6 +76,15 @@ type ProfileSummary = {
btcpay_checkout_style?: "standard" | "btcpay_classic"; btcpay_checkout_style?: "standard" | "btcpay_classic";
created_at: string; created_at: string;
}; };
type SystemStatusSummary = {
wallet_rpc: "ok" | "unreachable";
daemon: "ok" | "unreachable" | "unknown";
daemon_height: number | null;
invoice_reconcile_interval_seconds: number;
last_reconcile_started_at: string | null;
last_reconcile_completed_at: string | null;
last_reconcile_error: string | null;
};
export default async function DashboardPage({ export default async function DashboardPage({
searchParams, searchParams,
}: { }: {
@@ -205,6 +215,7 @@ export default async function DashboardPage({
const allInvoices = invoicesData?.items ?? []; const allInvoices = invoicesData?.items ?? [];
let profileData: ProfileSummary | null = null; let profileData: ProfileSummary | null = null;
let systemStatus: SystemStatusSummary | null = null;
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") { if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
const response = await fetch(`${apiBaseUrl}/api/core/profile`, { const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
@@ -220,6 +231,14 @@ export default async function DashboardPage({
profileData = (await response.json()) as ProfileSummary; profileData = (await response.json()) as ProfileSummary;
} }
} }
if (activeTab === "overview") {
const response = await fetch(`${apiBaseUrl}/api/core/public/system/status`, {
cache: "no-store",
});
if (response.ok) {
systemStatus = (await response.json()) as SystemStatusSummary;
}
}
const isToday = (value: string) => { const isToday = (value: string) => {
const date = new Date(value); const date = new Date(value);
@@ -240,7 +259,12 @@ export default async function DashboardPage({
const confirmedCount = allInvoices.filter( const confirmedCount = allInvoices.filter(
(invoice) => invoice.status === "confirmed" (invoice) => invoice.status === "confirmed"
).length; ).length;
const lastReconcileCompleted = systemStatus?.last_reconcile_completed_at
? formatRelativeTime(systemStatus.last_reconcile_completed_at)
: null;
const lastReconcileStarted = systemStatus?.last_reconcile_started_at
? formatRelativeTime(systemStatus.last_reconcile_started_at)
: null;
const tabBaseClass = const tabBaseClass =
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5"; "inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const tabActiveClass = const tabActiveClass =
@@ -346,6 +370,83 @@ export default async function DashboardPage({
</div> </div>
)} )}
</div> </div>
<div className="mt-4 rounded-2xl border border-stroke bg-white/70 p-5 shadow-soft">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Monero connectivity
</p>
<div className="flex flex-wrap gap-2 text-[0.7rem] font-semibold uppercase tracking-[0.12em]">
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.wallet_rpc === "ok"
? "bg-emerald-100 text-emerald-900"
: "bg-red-100 text-red-700"
}`}
>
Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
</span>
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.daemon === "ok"
? "bg-emerald-100 text-emerald-900"
: systemStatus?.daemon === "unknown"
? "bg-amber-100 text-amber-900"
: "bg-red-100 text-red-700"
}`}
>
Daemon{" "}
{systemStatus?.daemon === "ok"
? "connected"
: systemStatus?.daemon === "unknown"
? "not configured"
: "down"}
</span>
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Current daemon height
</p>
<p className="mt-1 text-lg font-semibold">
{systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Detection poll interval
</p>
<p className="mt-1 text-lg font-semibold">
{systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Last successful reconcile
</p>
<p className="mt-1 text-lg font-semibold">
{lastReconcileCompleted ?? "Not yet recorded"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Last reconcile start
</p>
<p className="mt-1 text-lg font-semibold">
{lastReconcileStarted ?? "Not yet recorded"}
</p>
</div>
</div>
<p className="mt-3 text-sm text-ink-soft">
If payments stay at 0 confirmations, check that the daemon is connected
and that the height keeps advancing.
</p>
{systemStatus?.last_reconcile_error ? (
<p className="mt-3 rounded-xl bg-red-100 px-3 py-2 text-sm text-red-700">
Last reconciler error: {systemStatus.last_reconcile_error}
</p>
) : null}
</div>
<div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink"> <div className="mt-5 rounded-2xl border border-ink/10 bg-ink/10 px-4 py-3 text-sm font-semibold text-ink">
We never hold funds. All payments move from the customer to your We never hold funds. All payments move from the customer to your
wallet. wallet.
+16
View File
@@ -48,6 +48,11 @@ const endpointGroups = [
path: "/api/core/public/invoice/{invoice_id}/continue", path: "/api/core/public/invoice/{invoice_id}/continue",
description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).", description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
}, },
{
method: "GET",
path: "/api/core/public/system/status",
description: "Read daemon, wallet-rpc, block height, and reconciler heartbeat without auth.",
},
], ],
}, },
{ {
@@ -257,6 +262,17 @@ const endpointRequirements = [
"Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).", "Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).",
], ],
}, },
{
method: "GET",
path: "/api/core/public/system/status",
auth: "None",
required: [],
optional: [],
notes: [
"Returns wallet-rpc reachability, daemon reachability, current daemon height, and reconciler heartbeat timestamps.",
"Use this endpoint to debug setup issues before sending a real payment.",
],
},
{ {
method: "GET", method: "GET",
path: "/api/core/invoices", path: "/api/core/invoices",
@@ -37,6 +37,15 @@ type InvoiceStatusResponse = {
detected_at: string | null; detected_at: string | null;
confirmed_at: string | null; confirmed_at: string | null;
}; };
type SystemStatusResponse = {
wallet_rpc: "ok" | "unreachable";
daemon: "ok" | "unreachable" | "unknown";
daemon_height: number | null;
invoice_reconcile_interval_seconds: number;
last_reconcile_started_at: string | null;
last_reconcile_completed_at: string | null;
last_reconcile_error: string | null;
};
const formatStatus = (status: InvoiceStatus) => { const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") { if (status === "payment_detected") {
@@ -98,10 +107,12 @@ export default async function InvoiceStatusDetailPage({
params: Promise<{ invoiceId: string }>; params: Promise<{ invoiceId: string }>;
}) { }) {
const { invoiceId } = await params; const { invoiceId } = await params;
const response = await fetch( const [response, systemStatusResponse] = await Promise.all([
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
{ cache: "no-store" } cache: "no-store",
); }),
fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
]);
if (response.status === 404) { if (response.status === 404) {
return ( return (
@@ -157,6 +168,9 @@ export default async function InvoiceStatusDetailPage({
} }
const invoice = (await response.json()) as InvoiceStatusResponse; const invoice = (await response.json()) as InvoiceStatusResponse;
const systemStatus = systemStatusResponse.ok
? ((await systemStatusResponse.json()) as SystemStatusResponse)
: null;
const statusLabel = formatStatus(invoice.status); const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0); const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target); const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -204,6 +218,7 @@ export default async function InvoiceStatusDetailPage({
const showConfirmedStamp = invoice.status === "confirmed"; const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at); const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at); const expiresTimestamp = formatTimestamp(invoice.expires_at);
const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
return ( return (
<main className="px-[6vw] pb-20 pt-10 text-ink"> <main className="px-[6vw] pb-20 pt-10 text-ink">
@@ -230,6 +245,9 @@ export default async function InvoiceStatusDetailPage({
</summary> </summary>
<div className="mt-3 grid gap-2"> <div className="mt-3 grid gap-2">
<p className="font-mono text-xs text-ink">Invoice ID: {invoiceId}</p> <p className="font-mono text-xs text-ink">Invoice ID: {invoiceId}</p>
<p className="font-mono text-xs text-ink">
Current block height: {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
</p>
<p> <p>
This status page is public. Anyone with the link can view the current This status page is public. Anyone with the link can view the current
invoice state. invoice state.
@@ -259,6 +277,74 @@ export default async function InvoiceStatusDetailPage({
<StatusRefreshButton label="Refresh" /> <StatusRefreshButton label="Refresh" />
</div> </div>
</div> </div>
<div className="mt-4 rounded-xl border border-stroke bg-white/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Monero node
</p>
<div className="flex flex-wrap gap-2 text-[0.7rem] font-semibold uppercase tracking-[0.12em]">
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.wallet_rpc === "ok"
? "bg-emerald-100 text-emerald-900"
: "bg-red-100 text-red-700"
}`}
>
Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
</span>
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.daemon === "ok"
? "bg-emerald-100 text-emerald-900"
: systemStatus?.daemon === "unknown"
? "bg-amber-100 text-amber-900"
: "bg-red-100 text-red-700"
}`}
>
Daemon{" "}
{systemStatus?.daemon === "ok"
? "connected"
: systemStatus?.daemon === "unknown"
? "not configured"
: "down"}
</span>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Current block height
</p>
<p className="mt-1 text-sm font-semibold">
{systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Detection poll
</p>
<p className="mt-1 text-sm font-semibold">
{systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Last successful scan
</p>
<p
className="mt-1 text-sm font-semibold"
title={lastReconcileCompleted.relative ?? undefined}
>
{lastReconcileCompleted.label}
</p>
</div>
</div>
{systemStatus?.last_reconcile_error ? (
<p className="mt-3 rounded-xl bg-red-100 px-3 py-2 text-sm text-red-700">
Reconciler error: {systemStatus.last_reconcile_error}
</p>
) : null}
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-3"> <div className="mt-6 grid gap-4 sm:grid-cols-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
+87 -4
View File
@@ -52,6 +52,15 @@ type InvoiceStatusResponse = {
quoted_at: string; quoted_at: string;
} | null; } | null;
}; };
type SystemStatusResponse = {
wallet_rpc: "ok" | "unreachable";
daemon: "ok" | "unreachable" | "unknown";
daemon_height: number | null;
invoice_reconcile_interval_seconds: number;
last_reconcile_started_at: string | null;
last_reconcile_completed_at: string | null;
last_reconcile_error: string | null;
};
const formatStatus = (status: InvoiceStatus) => { const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") { if (status === "payment_detected") {
@@ -93,10 +102,12 @@ export default async function BtcpayModalInvoicePage({
params: Promise<{ invoiceId: string }>; params: Promise<{ invoiceId: string }>;
}) { }) {
const { invoiceId } = await params; const { invoiceId } = await params;
const response = await fetch( const [response, systemStatusResponse] = await Promise.all([
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
{ cache: "no-store" } cache: "no-store",
); }),
fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
]);
if (response.status === 404) { if (response.status === 404) {
return ( return (
@@ -139,6 +150,9 @@ export default async function BtcpayModalInvoicePage({
} }
const invoice = (await response.json()) as InvoiceStatusResponse; const invoice = (await response.json()) as InvoiceStatusResponse;
const systemStatus = systemStatusResponse.ok
? ((await systemStatusResponse.json()) as SystemStatusResponse)
: null;
const statusLabel = formatStatus(invoice.status); const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0); const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target); const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -146,6 +160,7 @@ export default async function BtcpayModalInvoicePage({
invoice.status === "payment_detected" || invoice.status === "confirmed"; invoice.status === "payment_detected" || invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at); const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at); const expiresTimestamp = formatTimestamp(invoice.expires_at);
const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency); const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout = const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic"; isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
@@ -246,6 +261,74 @@ export default async function BtcpayModalInvoicePage({
</p> </p>
</div> </div>
</div> </div>
<div className="mt-4 rounded-xl border border-stroke bg-white/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Monero node
</p>
<div className="flex flex-wrap gap-2 text-[0.7rem] font-semibold uppercase tracking-[0.12em]">
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.wallet_rpc === "ok"
? "bg-emerald-100 text-emerald-900"
: "bg-red-100 text-red-700"
}`}
>
Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
</span>
<span
className={`rounded-full px-3 py-1 ${
systemStatus?.daemon === "ok"
? "bg-emerald-100 text-emerald-900"
: systemStatus?.daemon === "unknown"
? "bg-amber-100 text-amber-900"
: "bg-red-100 text-red-700"
}`}
>
Daemon{" "}
{systemStatus?.daemon === "ok"
? "connected"
: systemStatus?.daemon === "unknown"
? "not configured"
: "down"}
</span>
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Current block height
</p>
<p className="mt-1 text-sm font-semibold">
{systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Detection poll
</p>
<p className="mt-1 text-sm font-semibold">
{systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
Last successful scan
</p>
<p
className="mt-1 text-sm font-semibold"
title={lastReconcileCompleted.relative ?? undefined}
>
{lastReconcileCompleted.label}
</p>
</div>
</div>
{systemStatus?.last_reconcile_error ? (
<p className="mt-3 rounded-xl bg-red-100 px-3 py-2 text-sm text-red-700">
Reconciler error: {systemStatus.last_reconcile_error}
</p>
) : null}
</div>
{checkoutContinueAvailable ? ( {checkoutContinueAvailable ? (
<a <a
className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5" className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"