diff --git a/apps/api/src/datum/digest.ts b/apps/api/src/datum/digest.ts index 153f612..ca7be93 100644 --- a/apps/api/src/datum/digest.ts +++ b/apps/api/src/datum/digest.ts @@ -83,6 +83,7 @@ export async function digestFetch(opts: DigestFetchOptions): Promise { const uri = url.pathname + url.search; const baseInit: RequestInit = { method, + redirect: "manual", headers: { ...(opts.headers ?? {}) }, ...(opts.signal ? { signal: opts.signal } : {}), }; diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts index 27eddbd..e848a12 100644 --- a/apps/api/src/datum/poller.ts +++ b/apps/api/src/datum/poller.ts @@ -62,6 +62,20 @@ function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () = return { signal: ctrl.signal, cancel: () => clearTimeout(t) }; } +function datumHttpMessage(res: Response, path: string): string { + const location = res.headers.get("location"); + if (res.status >= 300 && res.status < 400) { + return location + ? `Datum ${path} redirected to ${location}; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API` + : `Datum ${path} redirected; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`; + } + return `Datum ${path} returned ${res.status}`; +} + +function isUmbrelShellHtml(html: string): boolean { + return /\s*Umbrel\s*<\/title>/i.test(html) || /<div\s+id=["']root["']/i.test(html); +} + async function pollOnce(): Promise<DatumSnapshot> { const fetchedAt = Date.now(); @@ -76,7 +90,7 @@ async function pollOnce(): Promise<DatumSnapshot> { creds: { username: config.datum.adminUser, password: config.datum.adminPassword }, signal: clientsTimeout.signal, }), - fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal }), + fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }), ]); clientsTimeout.cancel(); threadsTimeout.cancel(); @@ -104,18 +118,48 @@ async function pollOnce(): Promise<DatumSnapshot> { }; } if (clientsRes.status !== 200) { + const message = datumHttpMessage(clientsRes, "/clients"); + logger.warn( + { status: clientsRes.status, url: `${config.datum.url}/clients`, location: clientsRes.headers.get("location") }, + "datum_clients_bad_status", + ); return { ...lastSnapshot, ok: false, fetchedAt, - error: { code: "DATUM_HTTP", message: `Datum /clients returned ${clientsRes.status}` }, + error: { code: "DATUM_HTTP", message }, }; } const clientsHtml = await clientsRes.text(); + if (isUmbrelShellHtml(clientsHtml)) { + const message = + "DATUM_URL returned the Umbrel web shell, not Datum /clients; use the Datum container admin API URL"; + logger.warn({ url: `${config.datum.url}/clients` }, "datum_clients_wrong_html"); + return { + ...lastSnapshot, + ok: false, + fetchedAt, + error: { code: "DATUM_BAD_RESPONSE", message }, + }; + } + let threadsHtml = ""; if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) { threadsHtml = await threadsResSettled.value.text(); + } else if ( + threadsResSettled.status === "fulfilled" && + threadsResSettled.value.status >= 300 && + threadsResSettled.value.status < 400 + ) { + logger.warn( + { + status: threadsResSettled.value.status, + url: `${config.datum.url}/threads`, + location: threadsResSettled.value.headers.get("location"), + }, + "datum_threads_redirected", + ); } else if (threadsResSettled.status === "rejected") { logger.warn( { reason: formatErr(threadsResSettled.reason) }, @@ -125,6 +169,12 @@ async function pollOnce(): Promise<DatumSnapshot> { const miners = parseClientsHtml(clientsHtml); const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; + if (miners.length === 0) { + logger.warn( + { url: `${config.datum.url}/clients`, bytes: clientsHtml.length }, + "datum_clients_no_miners_parsed", + ); + } const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0); const totalSubscriptions = diff --git a/apps/web/src/strings.ts b/apps/web/src/strings.ts index c8140da..134715e 100644 --- a/apps/web/src/strings.ts +++ b/apps/web/src/strings.ts @@ -17,17 +17,17 @@ export const STATUS_LINES = { export const LOTTERY_LINES = [ (oddsPerDay: number) => - `P(block today): ${formatPct(oddsPerDay)}. P(struck by lightning today): 0.000028%. hash on, brave little board.`, + `today's chance: ${formatPct(oddsPerDay)}. small number, enormous main-character energy.`, (oddsPerDay: number) => - `at this hashrate, a block is expected once every ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. compound those vibes.`, + `statistically, the next block arrives in ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. emotionally, any minute now.`, () => - `lifetime tickets purchased: many. winners: 0. hope: priceless.`, + `the math says no. the dashboard has chosen to hear "not yet."`, (oddsPerDay: number) => - `you're ${ratioVsLightning(oddsPerDay)}× as likely to find a block as get hit by lightning today. almost too easy.`, + `versus lightning today: ${ratioVsLightning(oddsPerDay)}x. bad weather has better marketing.`, () => - `the network does not know you exist. and yet, you persist.`, + `four tiny tickets in a planetary raffle. extremely unserious. deeply respected.`, () => - `every share is a ticket bought. nobody is buying with more conviction.`, + `every share is a receipt for optimism with terrible accounting.`, ]; export const BLOCK_CELEBRATION_LINES = [