merge upstream: system status, health monitoring, connectivity caching

Merges xmrcheckout/xmrcheckout main (7 commits since 655d037):
- Add Monero system status and reconciler heartbeat
- Surface Monero health status in UI (dashboard + invoice pages)
- Harden reconciler status heartbeat startup
- Avoid false Monero outage states in UI
- Cache Monero connectivity in system status
- Next.js security patch (15.5.9 → 15.5.14)
- Bump Monero Docker defaults to 0.18.4.6

Resolved conflicts in reconciler.py (kept our advisory lock + upstream
heartbeat), routes.py (kept both imports), README.md (kept both sections).
This commit is contained in:
jwinterm
2026-03-28 12:20:32 -04:00
15 changed files with 780 additions and 76 deletions
+134 -1
View File
@@ -8,6 +8,7 @@ import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-styl
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
import InvoicePanel from "../../../components/invoice-panel";
import { formatRelativeTime } from "../../../components/relative-time";
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSecretSection from "../../../components/webhook-secret-section";
import WebhookSection from "../../../components/webhook-section";
@@ -75,6 +76,60 @@ type ProfileSummary = {
btcpay_checkout_style?: "standard" | "btcpay_classic";
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;
};
const walletRpcBadge = (systemStatus: SystemStatusSummary | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Wallet RPC unavailable",
};
}
if (systemStatus.wallet_rpc === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Wallet RPC ok",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Wallet RPC down",
};
};
const daemonBadge = (systemStatus: SystemStatusSummary | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon unavailable",
};
}
if (systemStatus.daemon === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Daemon connected",
};
}
if (systemStatus.daemon === "unknown") {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon not configured",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Daemon down",
};
};
export default async function DashboardPage({
searchParams,
}: {
@@ -205,6 +260,7 @@ export default async function DashboardPage({
const allInvoices = invoicesData?.items ?? [];
let profileData: ProfileSummary | null = null;
let systemStatus: SystemStatusSummary | null = null;
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
@@ -220,6 +276,14 @@ export default async function DashboardPage({
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 date = new Date(value);
@@ -240,7 +304,14 @@ export default async function DashboardPage({
const confirmedCount = allInvoices.filter(
(invoice) => invoice.status === "confirmed"
).length;
const walletStatusBadge = walletRpcBadge(systemStatus);
const daemonStatusBadge = daemonBadge(systemStatus);
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 =
"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 =
@@ -346,6 +417,68 @@ export default async function DashboardPage({
</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 ${walletStatusBadge.className}`}
>
{walletStatusBadge.label}
</span>
<span
className={`rounded-full px-3 py-1 ${daemonStatusBadge.className}`}
>
{daemonStatusBadge.label}
</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">
We never hold funds. All payments move from the customer to your
wallet.
+16
View File
@@ -48,6 +48,11 @@ const endpointGroups = [
path: "/api/core/public/invoice/{invoice_id}/continue",
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).",
],
},
{
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",
path: "/api/core/invoices",
+121 -4
View File
@@ -37,6 +37,59 @@ type InvoiceStatusResponse = {
detected_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 walletRpcBadge = (systemStatus: SystemStatusResponse | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Wallet RPC unavailable",
};
}
if (systemStatus.wallet_rpc === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Wallet RPC ok",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Wallet RPC down",
};
};
const daemonBadge = (systemStatus: SystemStatusResponse | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon unavailable",
};
}
if (systemStatus.daemon === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Daemon connected",
};
}
if (systemStatus.daemon === "unknown") {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon not configured",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Daemon down",
};
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
@@ -98,10 +151,12 @@ export default async function InvoiceStatusDetailPage({
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
const [response, systemStatusResponse] = await Promise.all([
fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
cache: "no-store",
}),
fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
]);
if (response.status === 404) {
return (
@@ -157,6 +212,9 @@ export default async function InvoiceStatusDetailPage({
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const systemStatus = systemStatusResponse.ok
? ((await systemStatusResponse.json()) as SystemStatusResponse)
: null;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -204,6 +262,9 @@ export default async function InvoiceStatusDetailPage({
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
const walletStatusBadge = walletRpcBadge(systemStatus);
const daemonStatusBadge = daemonBadge(systemStatus);
return (
<main className="px-[6vw] pb-20 pt-10 text-ink">
@@ -230,6 +291,9 @@ export default async function InvoiceStatusDetailPage({
</summary>
<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">
Current block height: {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
</p>
<p>
This status page is public. Anyone with the link can view the current
invoice state.
@@ -259,6 +323,59 @@ export default async function InvoiceStatusDetailPage({
<StatusRefreshButton label="Refresh" />
</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 ${walletStatusBadge.className}`}
>
{walletStatusBadge.label}
</span>
<span
className={`rounded-full px-3 py-1 ${daemonStatusBadge.className}`}
>
{daemonStatusBadge.label}
</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>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-ink-soft">
+118 -4
View File
@@ -52,6 +52,59 @@ type InvoiceStatusResponse = {
quoted_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 walletRpcBadge = (systemStatus: SystemStatusResponse | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Wallet RPC unavailable",
};
}
if (systemStatus.wallet_rpc === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Wallet RPC ok",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Wallet RPC down",
};
};
const daemonBadge = (systemStatus: SystemStatusResponse | null) => {
if (!systemStatus) {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon unavailable",
};
}
if (systemStatus.daemon === "ok") {
return {
className: "bg-emerald-100 text-emerald-900",
label: "Daemon connected",
};
}
if (systemStatus.daemon === "unknown") {
return {
className: "bg-amber-100 text-amber-900",
label: "Daemon not configured",
};
}
return {
className: "bg-red-100 text-red-700",
label: "Daemon down",
};
};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
@@ -93,10 +146,12 @@ export default async function BtcpayModalInvoicePage({
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
const response = await fetch(
`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
{ cache: "no-store" }
);
const [response, systemStatusResponse] = await Promise.all([
fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
cache: "no-store",
}),
fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
]);
if (response.status === 404) {
return (
@@ -139,6 +194,9 @@ export default async function BtcpayModalInvoicePage({
}
const invoice = (await response.json()) as InvoiceStatusResponse;
const systemStatus = systemStatusResponse.ok
? ((await systemStatusResponse.json()) as SystemStatusResponse)
: null;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -146,6 +204,9 @@ export default async function BtcpayModalInvoicePage({
invoice.status === "payment_detected" || invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
const walletStatusBadge = walletRpcBadge(systemStatus);
const daemonStatusBadge = daemonBadge(systemStatus);
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
@@ -246,6 +307,59 @@ export default async function BtcpayModalInvoicePage({
</p>
</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 ${walletStatusBadge.className}`}
>
{walletStatusBadge.label}
</span>
<span
className={`rounded-full px-3 py-1 ${daemonStatusBadge.className}`}
>
{daemonStatusBadge.label}
</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 ? (
<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"