import { Router, type IRouter } from "express"; import { execFile } from "child_process"; import { promisify } from "util"; const execFileAsync = promisify(execFile); const router: IRouter = Router(); interface NodeStatus { timestamp: string; lnbits: LNbitsStatus; lnd?: LNDStatus; bitcoin?: BitcoinStatus; overall: "ok" | "degraded" | "down"; issues: string[]; } interface LNbitsStatus { reachable: boolean; url: string; walletBalance?: number; error?: string; } interface LNDStatus { synced: boolean; blockHeight: number; activeChannels: number; walletBalance: number; channelBalance: number; } interface BitcoinStatus { blocks: number; headers: number; syncPct: number; pruned: boolean; peers: number; } async function probeLNbits(): Promise { const url = process.env.LNBITS_URL ?? ""; const key = process.env.LNBITS_API_KEY ?? ""; if (!url || !key) { return { reachable: false, url: url || "(not configured)", error: "LNBITS_URL or LNBITS_API_KEY not set" }; } try { const res = await fetch(`${url}/api/v1/wallet`, { headers: { "X-Api-Key": key }, signal: AbortSignal.timeout(5000), }); if (!res.ok) { return { reachable: false, url, error: `HTTP ${res.status}` }; } const data = (await res.json()) as { balance: number }; return { reachable: true, url, walletBalance: Math.round((data.balance ?? 0) / 1000) }; } catch (e) { return { reachable: false, url, error: String(e) }; } } async function probeLNbitsNodeInfo(): Promise<{ lnd?: LNDStatus; bitcoin?: BitcoinStatus }> { const url = process.env.LNBITS_URL ?? ""; const key = process.env.LNBITS_API_KEY ?? ""; if (!url || !key) return {}; try { const res = await fetch(`${url}/api/v1/extension/lndhub/api/v1/getinfo`, { headers: { "X-Api-Key": key }, signal: AbortSignal.timeout(5000), }); if (!res.ok) return {}; const data = (await res.json()) as Record; const lnd: LNDStatus = { synced: Boolean(data["synced_to_chain"]), blockHeight: Number(data["block_height"] ?? 0), activeChannels: Number(data["num_active_channels"] ?? 0), walletBalance: 0, channelBalance: 0, }; return { lnd }; } catch { return {}; } } router.get("/api/admin/node-status", async (_req, res) => { const issues: string[] = []; const [lnbits, nodeInfo] = await Promise.all([ probeLNbits(), probeLNbitsNodeInfo(), ]); if (!lnbits.reachable) { issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`); } if (nodeInfo.lnd && !nodeInfo.lnd.synced) { issues.push("LND not yet synced to chain tip"); } const overall: NodeStatus["overall"] = !lnbits.reachable ? "down" : issues.length > 0 ? "degraded" : "ok"; const status: NodeStatus = { timestamp: new Date().toISOString(), lnbits, ...nodeInfo, overall, issues, }; res.json(status); }); router.get("/api/admin/node-status/html", async (_req, res) => { const issues: string[] = []; const [lnbits, nodeInfo] = await Promise.all([probeLNbits(), probeLNbitsNodeInfo()]); if (!lnbits.reachable) issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`); if (nodeInfo.lnd && !nodeInfo.lnd.synced) issues.push("LND not yet synced"); const overall = !lnbits.reachable ? "down" : issues.length > 0 ? "degraded" : "ok"; const color = overall === "ok" ? "#22c55e" : overall === "degraded" ? "#f59e0b" : "#ef4444"; res.setHeader("Content-Type", "text/html"); res.send(` Timmy — Node Status

⚡ Timmy Node Status

Overall: ${overall.toUpperCase()}

LNbits

${lnbits.walletBalance !== undefined ? `` : ""} ${lnbits.error ? `` : ""}
URL${lnbits.url}
Reachable${lnbits.reachable ? "✓ yes" : "✗ no"}
Wallet Balance${lnbits.walletBalance} sats
Error${lnbits.error}
${nodeInfo.lnd ? `

LND

Synced${nodeInfo.lnd.synced ? "✓ yes" : "⏳ syncing"}
Block Height${nodeInfo.lnd.blockHeight.toLocaleString()}
Active Channels${nodeInfo.lnd.activeChannels}
` : ""} ${issues.length > 0 ? `

Issues

` : ""}

Refreshed at ${new Date().toISOString()}  ·  JSON  ·  Timmy UI

`); }); export default router;