feat(apps): add Gashboard with native signing and viewer access

This commit is contained in:
yaya
2026-10-06 06:42:25 +01:00
parent cedfbb2b07
commit 908e366006
100 changed files with 11072 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useStatsStore } from "./stores/stats";
import ChatDrawer from "./components/ChatDrawer.vue";
import ReleaseNotesDrawer from "./components/ReleaseNotesDrawer.vue";
const auth = useAuthStore();
const stats = useStatsStore();
const route = useRoute();
const crt = ref(false);
const chatOpen = ref(false);
const releaseOpen = ref(false);
const LOW_HASHRATE_THS = 10;
const TOP_HASHRATE_THS = 70;
const heatLevel = computed(() => {
const total = stats.snapshot?.pool.combinedHashrateThs ?? 0;
return Math.max(0, Math.min(1, (total - LOW_HASHRATE_THS) / (TOP_HASHRATE_THS - LOW_HASHRATE_THS)));
});
onMounted(() => {
const stored = localStorage.getItem("gashboard.crt") === "1";
crt.value = stored;
if (stored) document.body.classList.add("crt");
});
function toggleCrt(): void {
crt.value = !crt.value;
document.body.classList.toggle("crt", crt.value);
localStorage.setItem("gashboard.crt", crt.value ? "1" : "0");
}
function shortNpub(n: string): string {
return n.length > 16 ? `${n.slice(0, 8)}…${n.slice(-4)}` : n;
}
watch(
heatLevel,
(level) => {
document.documentElement.style.setProperty("--heat", level.toFixed(3));
document.documentElement.style.setProperty("--heat-color", level > 0.75 ? "#ff4f78" : "#ff3df0");
document.body.classList.toggle("hot-hash", level > 0.75);
},
{ immediate: true },
);
</script>
<template>
<header class="topbar">
<div class="brand glow-magenta flicker">
⌬ GASHBOARD
<span class="muted">// solo lottery dashboard</span>
</div>
<nav v-if="auth.isLoggedIn" class="nav">
<RouterLink to="/">status</RouterLink>
<RouterLink to="/graphs">graphs</RouterLink>
<RouterLink to="/access">access</RouterLink>
</nav>
<div class="actions">
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
<button class="thin" @click="releaseOpen = !releaseOpen">notes</button>
<template v-if="auth.isLoggedIn">
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
<button class="thin" @click="auth.logout()">logout</button>
</template>
</div>
</header>
<nav v-if="auth.isLoggedIn" class="mobile-tabbar" aria-label="mobile navigation">
<RouterLink to="/">status</RouterLink>
<RouterLink to="/graphs">graphs</RouterLink>
<RouterLink to="/access">access</RouterLink>
<button type="button" @click="releaseOpen = !releaseOpen">notes</button>
<button type="button" @click="chatOpen = !chatOpen">chat</button>
</nav>
<main :class="{ shellLogin: route.name === 'login' }">
<RouterView />
</main>
<footer class="footbar muted">
P(block) is a lifestyle, not a number · gashboard v0.1 · {{ new Date().getFullYear() }}
</footer>
<ChatDrawer v-if="auth.isLoggedIn" :open="chatOpen" @close="chatOpen = false" />
<ReleaseNotesDrawer :open="releaseOpen" @close="releaseOpen = false" />
</template>
<style scoped>
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--line);
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(6px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
letter-spacing: 0.18em;
font-size: 15px;
}
.brand .muted {
margin-left: 12px;
letter-spacing: 0.04em;
font-weight: 400;
font-size: 11px;
}
.nav {
display: flex;
gap: 8px;
align-items: center;
margin-left: auto;
margin-right: 16px;
}
.nav a {
color: var(--fg-1);
border: 1px solid var(--line);
padding: 4px 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nav a:hover,
.nav a.router-link-active {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
.mobile-tabbar {
display: none;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
}
button.thin {
padding: 4px 10px;
font-size: 11px;
}
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
main.shellLogin {
display: grid;
place-items: center;
min-height: calc(100vh - 110px);
}
.footbar {
padding: 14px 24px;
border-top: 1px solid var(--line);
font-size: 11px;
text-align: center;
letter-spacing: 0.08em;
}
@media (max-width: 760px) {
.topbar {
align-items: center;
padding: 10px 14px;
}
.brand .muted,
.nav {
display: none;
}
.actions {
width: auto;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.actions button:nth-of-type(n + 2) {
display: none;
}
.actions .muted {
display: none;
}
.mobile-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 12;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: rgba(7, 9, 15, 0.94);
backdrop-filter: blur(6px);
}
.mobile-tabbar a,
.mobile-tabbar button {
min-width: 0;
border: 0;
border-right: 1px solid var(--line);
color: var(--fg-1);
padding: 10px 4px;
text-align: center;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
background: transparent;
}
.mobile-tabbar a:last-child,
.mobile-tabbar button:last-child {
border-right: 0;
}
.mobile-tabbar a.router-link-active,
.mobile-tabbar button:hover {
color: var(--neon-cyan);
box-shadow: inset 0 -1px 0 var(--neon-cyan), var(--shadow-cyan);
}
main {
padding: 16px 16px calc(76px + env(safe-area-inset-bottom));
}
.footbar {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,143 @@
export type ChatSticker = {
label: string;
text: string;
kind?: "sticker" | "gif";
image?: string;
};
export const STICKER_PREFIX = "[gashboard sticker]";
const GIF_MARKER = "[gif]";
const IMAGE_MARKER = "[img:";
export const CHAT_STICKERS: ChatSticker[] = [
{ label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." },
{ label: "no hashes", text: "NO HASHES. JUST GHEI. just fiat and a pension." },
{ label: "cope block", text: "BLOCK FOUND: not by us, obviously. carry on pretending." },
{ label: "desk miner", text: "DESK MINER ENERGY: tiny fan, enormous delusion." },
{ label: "pool bully", text: "BIG POOL ENTERED CHAT: scaled up to not shame the children." },
{ label: "paper hands", text: "PAPER HANDS, PLASTIC HEATER, ZERO SATS." },
{ label: "nonce cult", text: "NONCE CULT MEETING: attendance low, conviction insane." },
{ label: "fiat friend", text: "YOUR FIAT FRIEND THINKS THE HEATER IS AN INVESTMENT." },
{ label: "warm tonight", text: "SOMEONE'S GETTING WARMED TONIGHT. maybe not paid." },
{ label: "difficulty", text: "DIFFICULTY ADJUSTMENT: reality filed another complaint." },
{ label: "best calc", text: "BEST CALC: almost important, spiritually devastating." },
{ label: "hash high", text: "HASHRATE UP. ODDS STILL NEED A TELESCOPE." },
{ label: "pension", text: "JUST FIAT AND A PENSION: the saddest proof-of-warmth." },
{ label: "sovereign", text: "SOVEREIGN MINER: no block, no boss, no apology." },
{ label: "amber", text: "SIGNED BY A REAL SIGNER, UNLIKE YOUR BOOMER'S DIRECT DEBIT." },
{ label: "mempool", text: "MEMPOOL SAYS NO. DASHBOARD SAYS LOL." },
{ label: "reject", text: "REJECTED SHARE: even the server had standards." },
{ label: "bigpapa", text: "BIGPAPA IS WATCHING. probably collecting fuck all." },
{ label: "lottery", text: "SOLO LOTTERY TICKET PURCHASED WITH ELECTRICITY AND DENIAL." },
{ label: "fiat people", text: "FIAT PEOPLE SEE A HEATER. MINERS SEE A COWARD." },
{ label: "gif cope", kind: "gif", text: "LIVE FOOTAGE OF ME PRETENDING THE ODDS ARE FINE." },
{ label: "gif panic", kind: "gif", text: "THE HASHRATE MOVED 0.01 TH/S AND I HAVE BECOME UNBEARABLE." },
{ label: "gif refresh", kind: "gif", text: "REFRESHING THE DASHBOARD LIKE IT OWES ME MONEY." },
{ label: "gif chart", kind: "gif", text: "GRAPH GOES UP. BRAIN GOES OFF." },
{ label: "gif no block", kind: "gif", text: "NO BLOCK? MUST BE A CONSPIRACY AGAINST MY USB PORT." },
{ label: "gif office", kind: "gif", text: "CORPORATE NEEDS YOU TO FIND THE DIFFERENCE: HEATER / MINER." },
{ label: "gif maths", kind: "gif", text: "ME EXPLAINING 1 IN 2 BILLION LIKE IT IS A PLAN." },
{ label: "gif deploy", kind: "gif", text: "DEPLOYED TO PRODUCTION. MAY GOD REVIEW THE DIFF." },
{ label: "gif smug", kind: "gif", text: "I MINE SOLO, WHICH IS HOW YOU KNOW I AM DIFFICULT AT DINNER." },
{ label: "gif invoice", kind: "gif", text: "ELECTRIC BILL ARRIVED. IT IS NOW A WHITEPAPER." },
{ label: "ngmi heater", text: "NGMI: NOT GENERATING MINING INCOME." },
{ label: "printer money", text: "FIAT PRINTER WENT BRRR. MINER WENT PLEASE NOTICE ME." },
{ label: "normie mode", text: "NORMIE MODE: PAYING FOR HEAT WITHOUT EVEN ATTEMPTING A BLOCK." },
{ label: "bullish fan", text: "LOUD FAN, QUIET BALANCE, MAXIMUM CONVICTION." },
{ label: "chart crime", text: "THIS CHART IS A CRIME SCENE WITH AXES." },
{ label: "share cope", text: "ACCEPTED SHARE: A PARTICIPATION TROPHY WITH VOLTAGE." },
{ label: "stack sats", text: "STACKING SATS? NO. STACKING RECEIPTS FROM THE POWER COMPANY." },
{ label: "main character", text: "MAIN CHARACTER ENERGY, BACKGROUND HASHRATE." },
{ label: "block soon", text: "BLOCK SOON. SOURCE: THE SAME PART OF MY BRAIN THAT BUYS TOPS." },
{ label: "heater max", text: "HEATER ON MAX. HASHES ON VACATION." },
{
label: "anon miner",
text: "ANON DISCOVERS SOLO MINING AND IMMEDIATELY BECOMES A PROBLEM.",
image: "/stickers/anon-miner.svg",
},
{
label: "fiat npc",
text: "I JUST USE A NORMAL HEATER BECAUSE THE BANK SAID IT WAS FINE.",
image: "/stickers/fiat-npc.svg",
},
{
label: "chad hash",
text: "LOUD, WARM, WRONG, AND STILL MORE SOVEREIGN THAN YOUR SAVINGS ACCOUNT.",
image: "/stickers/chad-hash.svg",
},
{
label: "money printer",
text: "CENTRAL BANK MONETARY POLICY, ARTIST'S IMPRESSION.",
image: "/stickers/money-printer.svg",
},
{
label: "block denied",
text: "THE NETWORK REVIEWED YOUR NONCE AND CHOSE VIOLENCE.",
image: "/stickers/block-denied.svg",
},
{
label: "boomer unit",
text: "LEGACY HEATER ACHIEVES ZERO HASHES PER RETIREMENT.",
image: "/stickers/boomer-unit.svg",
},
{
label: "chart crime",
text: "TECHNICAL ANALYSIS CONDUCTED BY A FAN WITH A WALLET.",
image: "/stickers/chart-crime.svg",
},
{
label: "odds wizard",
text: "I HAVE CONSULTED THE ODDS AND THEY SAID LOL.",
image: "/stickers/odds-wizard.svg",
},
{
label: "gif fan panic",
kind: "gif",
text: "LIVE FAN FOOTAGE AFTER HASHRATE MOVES BY A DECIMAL.",
image: "/stickers/fan-panic.svg",
},
{
label: "gif printer",
kind: "gif",
text: "FIAT POLICY IN ONE LOOP.",
image: "/stickers/printer-loop.svg",
},
{
label: "gif block hopium",
kind: "gif",
text: "SOLO MINER WATCHING EVERY NEW BLOCK LIKE IT OWES HIM MONEY.",
image: "/stickers/block-hopium.svg",
},
{
label: "gif heat death",
kind: "gif",
text: "THE ROOM IS WARMER THAN THE INVESTMENT THESIS.",
image: "/stickers/heat-death.svg",
},
];
export function stickerContent(sticker: ChatSticker): string {
const image = sticker.image ? `${IMAGE_MARKER}${sticker.image}] ` : "";
return `${STICKER_PREFIX} ${image}${sticker.kind === "gif" ? `${GIF_MARKER} ` : ""}${sticker.text}`;
}
export function unsticker(
content: string,
): { text: string; kind: "sticker" | "gif"; image?: string } | null {
if (!content.startsWith(`${STICKER_PREFIX} `)) return null;
let body = content.slice(STICKER_PREFIX.length + 1);
let image: string | undefined;
if (body.startsWith(IMAGE_MARKER)) {
const end = body.indexOf("] ");
if (end > IMAGE_MARKER.length) {
image = body.slice(IMAGE_MARKER.length, end);
body = body.slice(end + 2);
}
}
if (body.startsWith(`${GIF_MARKER} `)) {
const parsed = { text: body.slice(GIF_MARKER.length + 1), kind: "gif" as const };
return image ? { ...parsed, image } : parsed;
}
const parsed = { text: body, kind: "sticker" as const };
return image ? { ...parsed, image } : parsed;
}
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
import { BLOCK_CELEBRATION_LINES } from "../strings";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const showing = ref(false);
const message = ref("");
let lastBlockFoundAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap) return;
// Block-found field not yet wired — placeholder for when the / homepage
// parser surfaces lastBlockFoundAt. For now this is dormant; trigger via
// window.gashboardCelebrate() in the console for a preview.
void lastBlockFoundAt; // silence unused
},
);
if (typeof window !== "undefined") {
(window as unknown as { gashboardCelebrate?: () => void }).gashboardCelebrate = () => {
const idx = Math.floor(Math.random() * BLOCK_CELEBRATION_LINES.length);
message.value = BLOCK_CELEBRATION_LINES[idx] ?? "";
showing.value = true;
setTimeout(() => (showing.value = false), 8000);
};
}
</script>
<template>
<Transition name="fade">
<div v-if="showing" class="overlay">
<div class="card glow-magenta">
<div class="title">★ B L O C K F O U N D ★</div>
<div class="msg">{{ message }}</div>
<button @click="showing = false">acknowledge</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
z-index: 1000;
}
.card {
text-align: center;
padding: 40px 50px;
border: 2px solid var(--neon-magenta);
background: var(--bg-1);
box-shadow: 0 0 40px var(--neon-magenta);
display: flex;
flex-direction: column;
gap: 18px;
max-width: 520px;
}
.title {
font-size: 24px;
letter-spacing: 0.4em;
font-weight: 700;
}
.msg {
font-size: 14px;
font-style: italic;
line-height: 1.5;
color: var(--fg-1);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.4s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,768 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useAuthStore } from "../stores/auth";
import * as chat from "../services/chat";
import type { ChatMessage, ChatProfile, ChatReaction } from "../services/chat";
import { CHAT_STICKERS, stickerContent, unsticker, type ChatSticker } from "../chatStickers";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const auth = useAuthStore();
const messages = ref<ChatMessage[]>([]);
const reactions = ref<Record<string, Record<string, string[]>>>({});
const profiles = ref<Record<string, ChatProfile>>({});
const draft = ref("");
const error = ref("");
const loading = ref(false);
const showStickers = ref(false);
const replyTarget = ref<ChatMessage | null>(null);
const listEl = ref<HTMLElement | null>(null);
let sub: { close: () => void } | null = null;
const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub));
const canSend = computed(() => auth.hasActiveSigner);
const mediaStickers = computed(() => CHAT_STICKERS.filter((sticker) => sticker.image));
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
onMounted(() => {
if (auth.isLoggedIn) void start();
});
onUnmounted(() => {
sub?.close();
});
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (loggedIn) void start();
else {
sub?.close();
sub = null;
messages.value = [];
profiles.value = {};
reactions.value = {};
replyTarget.value = null;
}
},
);
watch(
() => props.open,
(open) => {
if (open) {
if (!canSend.value) void auth.restoreSavedSigner();
void scrollBottom();
}
},
);
async function start(): Promise<void> {
if (sub) return;
loading.value = true;
error.value = "";
try {
messages.value = await chat.loadChat();
await loadReactions();
await loadMissingProfiles();
sub = chat.subscribeChat(
(message) => {
if (!messages.value.some((m) => m.id === message.id)) {
messages.value.push(message);
messages.value.sort((a, b) => a.createdAt - b.createdAt);
void loadMissingProfiles();
void scrollBottom();
}
},
(reaction) => addReaction(reaction),
);
await scrollBottom();
} catch (e) {
error.value = e instanceof Error ? e.message : "Chat failed";
} finally {
loading.value = false;
}
}
async function loadReactions(): Promise<void> {
const loaded = await chat.loadReactions(messages.value.map((m) => m.id));
reactions.value = {};
for (const reaction of loaded) addReaction(reaction);
}
async function loadMissingProfiles(): Promise<void> {
const authors = messages.value.map((m) => m.pubkey);
if (ownPubkey.value) authors.push(ownPubkey.value);
const missing = [...new Set(authors)].filter((p) => !profiles.value[p]);
if (!missing.length) return;
profiles.value = { ...profiles.value, ...(await chat.loadProfiles(missing)) };
}
async function send(): Promise<void> {
const content = draft.value.trim();
if (!content) return;
await sendContent(content, replyTarget.value ?? undefined);
draft.value = "";
replyTarget.value = null;
}
async function sendSticker(sticker: ChatSticker): Promise<void> {
await sendContent(stickerContent(sticker), replyTarget.value ?? undefined);
replyTarget.value = null;
showStickers.value = false;
}
async function sendContent(content: string, replyTo?: ChatMessage): Promise<void> {
error.value = "";
try {
const message = await chat.sendChat(content, replyTo ? { id: replyTo.id, pubkey: replyTo.pubkey } : undefined);
if (!messages.value.some((m) => m.id === message.id)) messages.value.push(message);
await loadMissingProfiles();
await scrollBottom();
} catch (e) {
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
await auth.restoreSavedSigner();
}
error.value = e instanceof Error ? e.message : "Could not send";
}
}
async function react(message: ChatMessage, content: string): Promise<void> {
error.value = "";
try {
addReaction(await chat.sendReaction(message, content));
} catch (e) {
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
await auth.restoreSavedSigner();
}
error.value = e instanceof Error ? e.message : "Could not react";
}
}
function addReaction(reaction: ChatReaction): void {
if (!reaction.messageId) return;
const existing = reactions.value[reaction.messageId]?.[reaction.content] ?? [];
if (existing.includes(reaction.pubkey)) return;
reactions.value = {
...reactions.value,
[reaction.messageId]: {
...(reactions.value[reaction.messageId] ?? {}),
[reaction.content]: [...existing, reaction.pubkey],
},
};
}
function profile(pubkey: string): ChatProfile {
return profiles.value[pubkey] ?? { pubkey, name: chat.shortPubkey(pubkey), picture: "" };
}
function timeLabel(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function stickerText(content: string): string | null {
return unsticker(content)?.text ?? null;
}
function stickerKind(content: string): "sticker" | "gif" | null {
return unsticker(content)?.kind ?? null;
}
function stickerImage(content: string): string | null {
return unsticker(content)?.image ?? null;
}
function replyPreview(message: ChatMessage): ChatMessage | null {
if (!message.replyToId) return null;
return messages.value.find((m) => m.id === message.replyToId) ?? null;
}
function shortText(content: string): string {
const sticker = unsticker(content);
const text = sticker?.text ?? content;
return text.length > 78 ? `${text.slice(0, 78)}...` : text;
}
function reactionEntries(message: ChatMessage): Array<{ content: string; count: number; mine: boolean }> {
const own = ownPubkey.value;
return Object.entries(reactions.value[message.id] ?? {}).map(([content, pubkeys]) => ({
content,
count: pubkeys.length,
mine: !!own && pubkeys.includes(own),
}));
}
async function scrollBottom(): Promise<void> {
await nextTick();
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
}
</script>
<template>
<div v-if="open" class="chat-backdrop" @click="emit('close')" />
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">private nostr-login chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
</header>
<div v-if="!canSend" class="notice reconnect">
<span>
Logged in, but the signer is not active in this tab. Reading works; sending needs a restored signer.
</span>
<button class="thin" :disabled="auth.busy" @click="auth.reconnectRemoteApp()">
{{ auth.busy ? "opening" : "reconnect" }}
</button>
</div>
<div v-if="error" class="notice err">{{ error }}</div>
<div ref="listEl" class="messages">
<div v-if="loading" class="empty muted">loading relay gossip...</div>
<div v-else-if="!messages.length" class="empty muted">no chat yet. terrifying discipline.</div>
<article
v-for="message in messages"
:key="message.id"
:class="['msg', { own: message.pubkey === ownPubkey }]"
>
<img v-if="profile(message.pubkey).picture" :src="profile(message.pubkey).picture" alt="" />
<div v-else class="avatar">{{ profile(message.pubkey).name.slice(0, 2).toUpperCase() }}</div>
<div class="bubble">
<div class="meta">
<strong>{{ profile(message.pubkey).name }}</strong>
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<button
v-if="replyPreview(message)"
type="button"
class="reply-quote"
@click="replyTarget = replyPreview(message)"
>
<strong>{{ profile(replyPreview(message)?.pubkey || '').name }}</strong>
<span>{{ shortText(replyPreview(message)?.content || '') }}</span>
</button>
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
<div v-else :class="['sticker', stickerKind(message.content)]">
<img v-if="stickerImage(message.content)" :src="stickerImage(message.content) || ''" alt="" />
<span>{{ stickerText(message.content) }}</span>
</div>
<div class="message-actions">
<button type="button" @click="replyTarget = message">reply</button>
<button type="button" @click="react(message, '+')">+</button>
<button type="button" @click="react(message, '🔥')">fire</button>
<button type="button" @click="react(message, '😂')">lol</button>
</div>
<div v-if="reactionEntries(message).length" class="reactions">
<button
v-for="entry in reactionEntries(message)"
:key="entry.content"
type="button"
:class="{ mine: entry.mine }"
@click="react(message, entry.content)"
>
{{ entry.content }} {{ entry.count }}
</button>
</div>
</div>
</article>
</div>
<div v-if="showStickers" class="sticker-modal" @click.self="showStickers = false">
<section class="sticker-dialog" aria-label="chat stickers">
<div class="sticker-head">
<div>
<div class="label">stickers</div>
<strong class="glow-amber">{{ CHAT_STICKERS.length }} signed insults</strong>
</div>
<button type="button" class="thin" aria-label="close stickers" @click="showStickers = false">×</button>
</div>
<div class="sticker-tray">
<div class="sticker-section">
<div class="section-title">image / gif stickers</div>
<div class="sticker-grid media-grid">
<button
v-for="sticker in mediaStickers"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
<img :src="sticker.image || ''" alt="" />
<span>{{ sticker.label }}</span>
<small>{{ sticker.text }}</small>
</button>
</div>
</div>
<div class="sticker-section">
<div class="section-title">text stickers</div>
<div class="sticker-grid text-grid">
<button
v-for="sticker in textStickers"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
<span>{{ sticker.label }}</span>
<small>{{ sticker.text }}</small>
</button>
</div>
</div>
</div>
</section>
</div>
<form class="composer" @submit.prevent="send">
<div v-if="replyTarget" class="replying">
<div>
<span class="label">replying to {{ profile(replyTarget.pubkey).name }}</span>
<p>{{ shortText(replyTarget.content) }}</p>
</div>
<button type="button" class="thin" @click="replyTarget = null">×</button>
</div>
<div class="composer-tools">
<button
type="button"
class="sticker-toggle"
:disabled="!canSend"
@click="showStickers = !showStickers"
>
stickers
</button>
</div>
<textarea
v-model="draft"
:disabled="!canSend"
rows="2"
maxlength="500"
placeholder="type something irresponsible but signed..."
/>
<button class="primary" :disabled="!canSend || !draft.trim()">send</button>
</form>
</aside>
</template>
<style scoped>
.chat-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.2);
}
.chat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: min(430px, 92vw);
height: 100dvh;
--chat-pad: 16px;
--composer-bottom: 24px;
--composer-height: 70px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
padding: var(--chat-pad);
background: rgba(7, 9, 15, 0.97);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.45);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.chat-drawer.open {
transform: translateX(0);
}
header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notice {
border: 1px solid var(--line);
color: var(--fg-1);
padding: 9px 10px;
font-size: 11px;
line-height: 1.35;
}
.notice.err {
border-color: var(--neon-red);
color: var(--neon-red);
}
.notice.reconnect {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.notice.reconnect span {
min-width: 0;
}
.notice.reconnect button {
flex: 0 0 auto;
}
.messages {
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
padding-bottom: calc(var(--composer-height) + var(--composer-bottom) + 18px);
}
.empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
}
.msg {
display: grid;
grid-template-columns: 34px 1fr;
gap: 8px;
align-items: start;
}
.msg.own {
grid-template-columns: 1fr 34px;
}
.msg.own img,
.msg.own .avatar {
grid-column: 2;
grid-row: 1;
}
.msg.own .bubble {
grid-column: 1;
grid-row: 1;
}
img,
.avatar {
width: 34px;
height: 34px;
border: 1px solid var(--line-bright);
object-fit: cover;
}
.avatar {
display: grid;
place-items: center;
color: var(--neon-cyan);
background: var(--bg-2);
font-size: 10px;
font-weight: 700;
}
.bubble {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 8px 10px;
}
.own .bubble {
border-color: var(--neon-cyan);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--fg-2);
font-size: 10px;
}
.meta strong {
color: var(--fg-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-quote {
width: 100%;
margin-top: 7px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-color: var(--line);
color: var(--fg-2);
text-align: left;
text-transform: none;
letter-spacing: 0.02em;
font-size: 10px;
}
.reply-quote strong {
color: var(--neon-cyan);
}
.reply-quote span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 5px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
}
.message-actions,
.reactions {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.message-actions button,
.reactions button {
min-height: 22px;
padding: 3px 6px;
color: var(--fg-2);
border-color: var(--line);
font-size: 9px;
}
.reactions button {
color: var(--neon-amber);
background: rgba(255, 216, 74, 0.04);
}
.reactions button.mine {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.sticker {
border: 1px solid var(--neon-magenta);
color: var(--neon-amber);
padding: 10px;
font-size: 13px;
line-height: 1.35;
text-transform: uppercase;
box-shadow: inset 0 0 18px rgba(255, 61, 240, 0.08);
}
.sticker img {
display: block;
width: min(220px, 100%);
aspect-ratio: 1 / 1;
height: auto;
margin-bottom: 8px;
border: 1px solid var(--line-bright);
object-fit: contain;
background: var(--bg-0);
}
.sticker span {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sticker.gif {
animation: sticker-bounce 1.4s steps(2, end) infinite;
background:
linear-gradient(90deg, rgba(255, 61, 240, 0.12), rgba(41, 255, 230, 0.08)),
rgba(255, 255, 255, 0.025);
}
.composer {
position: fixed;
right: var(--chat-pad);
bottom: var(--composer-bottom);
z-index: 4;
width: calc(min(430px, 92vw) - (var(--chat-pad) * 2));
min-height: var(--composer-height);
display: grid;
grid-template-columns: 78px minmax(0, 1fr) 64px;
gap: 8px;
align-items: start;
padding-top: 10px;
border-top: 1px solid var(--line);
background: rgba(7, 9, 15, 0.97);
}
.replying {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--line);
background: var(--bg-1);
}
.replying p {
margin: 3px 0 0;
color: var(--fg-1);
font-size: 11px;
line-height: 1.3;
}
.replying button {
height: 26px;
padding: 0 8px;
}
.composer-tools {
min-width: 0;
}
.sticker-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
align-items: end;
padding: 16px;
background: var(--bg-0);
}
.sticker-dialog {
min-height: 0;
max-height: min(520px, calc(100% - 24px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
border: 1px solid var(--neon-magenta);
background: var(--bg-1);
box-shadow: var(--shadow-magenta);
}
.sticker-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
.sticker-head strong {
display: block;
margin-top: 2px;
font-size: 14px;
}
.sticker-tray {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.sticker-section + .sticker-section {
margin-top: 14px;
}
.section-title {
margin-bottom: 8px;
color: var(--fg-2);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.sticker-grid {
display: grid;
gap: 8px;
}
.media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.text-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sticker-grid button {
min-width: 0;
min-height: 138px;
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start;
justify-content: flex-start;
padding: 9px 10px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
text-align: left;
overflow-wrap: anywhere;
}
.text-grid button {
min-height: 74px;
}
.sticker-grid button img {
width: 100%;
aspect-ratio: 1 / 1;
min-height: 120px;
max-height: 156px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--bg-0);
}
.sticker-grid button span {
color: var(--neon-cyan);
}
.sticker-grid button small {
color: var(--fg-1);
font-size: 10px;
line-height: 1.25;
text-transform: none;
letter-spacing: 0.02em;
}
.sticker-toggle {
width: 100%;
height: 58px;
padding: 0 6px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
}
textarea {
height: 58px;
min-height: 58px;
max-height: 92px;
resize: none;
line-height: 1.3;
}
.composer button {
align-self: start;
height: 58px;
padding: 0 8px;
}
@keyframes sticker-bounce {
0%,
100% {
transform: translate(0, 0);
filter: hue-rotate(0deg);
}
50% {
transform: translate(1px, -1px);
filter: hue-rotate(22deg);
}
}
@media (max-width: 760px) {
.chat-backdrop {
background: rgba(0, 0, 0, 0.44);
}
.chat-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 100svh;
max-height: none;
--chat-pad: 14px;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 14px 14px calc(72px + env(safe-area-inset-bottom));
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.chat-drawer.open {
transform: translateY(0);
}
.messages {
gap: 10px;
padding-bottom: 0;
}
.composer {
position: static;
width: auto;
grid-template-columns: 72px minmax(0, 1fr) 58px;
gap: 6px;
}
.sticker-modal {
padding: 10px;
}
.sticker-dialog {
max-height: calc(100% - 12px);
}
.sticker-tray {
padding-right: 0;
}
.media-grid,
.text-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { LOTTERY_LINES } from "../strings";
const props = defineProps<{ totalThs: number; networkEh: number }>();
const FALLBACK_NETWORK_EH_S = 720;
const SECONDS_PER_BLOCK = 600;
const SECONDS_PER_DAY = 86400;
const activeNetworkEh = computed(() => props.networkEh > 0 ? props.networkEh : FALLBACK_NETWORK_EH_S);
const probPerBlock = computed(() => {
if (props.totalThs <= 0) return 0;
// Th/s vs EH/s = 1e6 conversion: 1 EH/s = 1e6 Th/s
return props.totalThs / (activeNetworkEh.value * 1e6);
});
const probPerDay = computed(() => {
// Probability of NOT finding in any of (86400/600) = 144 blocks per day
const blocksPerDay = SECONDS_PER_DAY / SECONDS_PER_BLOCK;
const p = probPerBlock.value;
return 1 - Math.pow(1 - p, blocksPerDay);
});
const lineIndex = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
lineIndex.value = (lineIndex.value + 1) % LOTTERY_LINES.length;
}, 10_000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const currentLine = computed(() => {
const fn = LOTTERY_LINES[lineIndex.value];
if (!fn) return "";
return fn(probPerDay.value);
});
</script>
<template>
<section class="lottery panel">
<div class="label">solo lottery odds</div>
<div class="source muted">
calculated against {{ activeNetworkEh.toFixed(0) }} EH/s network hashrate
</div>
<div class="line glow-amber">
<span class="prefix">$</span>
<span class="text">{{ currentLine }}</span>
</div>
<div class="dots">
<span
v-for="(_, i) in LOTTERY_LINES"
:key="i"
:class="['dot', { active: i === lineIndex }]"
/>
</div>
</section>
</template>
<style scoped>
.lottery {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.source {
font-size: 10px;
overflow-wrap: anywhere;
}
.line {
font-size: 13px;
display: flex;
gap: 10px;
align-items: baseline;
min-height: 32px;
min-width: 0;
}
.prefix { color: var(--neon-amber); font-weight: 700; }
.text {
line-height: 1.5;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.dots {
display: flex;
gap: 5px;
margin-top: 4px;
flex-wrap: wrap;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--line-bright);
transition: background 0.3s, box-shadow 0.3s;
}
.dot.active {
background: var(--neon-amber);
box-shadow: 0 0 8px var(--neon-amber);
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MinerStat } from "../types";
import { ROASTS, STATUS_LINES, STALE_LINES } from "../strings";
const props = defineProps<{ miner: MinerStat; bestWork: number | undefined }>();
const isBoomerHeater = computed(() => props.miner.nickname === "Boomer Heater");
const slug = computed(() => {
// Map nickname → roast key
const map: Record<string, string> = {
QU4CK: "nerdqaxe",
P1XEL: "bitaxe",
BigPapa: "bitaxe",
N4N0: "avalon-nano-3",
M1N1: "avalon-mini-3",
};
return map[props.miner.nickname] ?? "unknown";
});
const roast = computed(() =>
isBoomerHeater.value ? "The dumb shit your fiat friends have." : (ROASTS[slug.value] ?? ROASTS["unknown"]),
);
const statusLabel = computed(() => STATUS_LINES[props.miner.status]);
const displayStatusLabel = computed(() => isBoomerHeater.value ? "dead" : statusLabel.value);
const statusMarker = computed(() => isBoomerHeater.value ? "☠" : "");
const statusGlow = computed(() => {
if (isBoomerHeater.value) return "glow-red";
switch (props.miner.status) {
case "hashing": return "glow-green";
case "stale": return "glow-amber";
case "idle": return "glow-red";
}
});
const lastShareLabel = computed(() => {
if (isBoomerHeater.value) return "Nothing";
const s = props.miner.lastShareAgeS;
if (s === null) return "no shares yet";
if (s < 60) return `${s.toFixed(0)}s ago`;
if (s < 3600) return `${(s / 60).toFixed(1)}m ago`;
return `${(s / 3600).toFixed(1)}h ago`;
});
const lifetimeShares = computed(() => props.miner.diffAcceptedCount);
const acceptedWork = computed(() => isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.diffAcceptedSum));
const contributionWork = computed(() =>
isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.contributionAcceptedWork ?? props.miner.diffAcceptedSum),
);
const contributionShare = computed(() =>
isBoomerHeater.value ? "0.00%" : `${(props.miner.contributionPct ?? 0).toFixed(2)}%`,
);
const bestWork = computed(() => {
if (isBoomerHeater.value) return "Fuck all";
return props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting";
});
const staleNote = computed(() => {
if (isBoomerHeater.value) return "warming a room while contributing nothing to civilization.";
if (props.miner.status !== "stale") return "";
const idx = Math.floor(Math.random() * STALE_LINES.length);
return STALE_LINES[idx];
});
function formatBig(n: number): string {
if (n < 1_000) return n.toFixed(0);
if (n < 1e6) return `${(n / 1e3).toFixed(1)}K`;
if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`;
if (n < 1e12) return `${(n / 1e9).toFixed(1)}B`;
return `${(n / 1e12).toFixed(1)}T`;
}
</script>
<template>
<div class="card panel" :data-status="miner.status">
<header>
<div class="nickname glow-magenta">{{ miner.nickname }}</div>
<div :class="['status', statusGlow]">
<span v-if="statusMarker" class="dead-mark">{{ statusMarker }}</span>
<span v-else class="dot pulse" />
{{ displayStatusLabel }}
</div>
</header>
<div class="model">{{ miner.model }}</div>
<div class="roast muted">{{ roast }}</div>
<div class="stats">
<div class="stat">
<div class="label">hashrate</div>
<div class="value glow-cyan">
{{ isBoomerHeater ? "No hashes" : miner.hashrateThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
</div>
<div class="stat">
<div class="label">last share</div>
<div class="value">{{ lastShareLabel }}</div>
</div>
<div class="stat">
<div class="label">tickets bought</div>
<div class="value">{{ isBoomerHeater ? "None" : lifetimeShares.toLocaleString() }}</div>
</div>
<div class="stat">
<div class="label">best calc</div>
<div class="value glow-amber">{{ bestWork }}</div>
</div>
<div class="stat">
<div class="label">reward share</div>
<div class="value glow-green">{{ contributionShare }}</div>
</div>
<div class="stat">
<div class="label">reward work</div>
<div class="value">{{ contributionWork }}</div>
</div>
<div class="stat">
<div class="label">reject %</div>
<div :class="['value', miner.rejectPct > 5 ? 'glow-red' : '']">
{{ isBoomerHeater ? "not even rejected" : `${miner.rejectPct.toFixed(2)}%` }}
</div>
</div>
<div class="stat">
<div class="label">accepted work</div>
<div class="value">{{ acceptedWork }}</div>
</div>
</div>
<footer>
<span class="ua muted" :title="miner.userAgent">{{ miner.userAgent || "—" }}</span>
<span class="loc muted">@{{ miner.location }}</span>
</footer>
<div v-if="miner.status === 'stale' || isBoomerHeater" class="stale-banner">
{{ staleNote }}
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 12px;
position: relative;
overflow: hidden;
}
.card[data-status="idle"] {
opacity: 0.6;
}
.card[data-status="stale"] {
border-color: var(--neon-amber);
}
.card[data-status="hashing"] {
border-color: var(--neon-green);
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nickname {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.16em;
}
.status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
display: flex;
gap: 6px;
align-items: center;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.dead-mark {
font-size: 13px;
line-height: 1;
text-shadow: 0 0 8px currentColor;
}
.model {
font-size: 12px;
color: var(--fg-1);
letter-spacing: 0.06em;
}
.roast {
font-size: 11.5px;
font-style: italic;
line-height: 1.4;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 14px;
padding: 10px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.stat .label {
font-size: 10px;
}
.stat .value {
font-size: 14px;
font-weight: 500;
}
.stat .unit {
font-size: 10px;
color: var(--fg-2);
}
footer {
display: flex;
justify-content: space-between;
font-size: 10.5px;
}
.ua { max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stale-banner {
font-size: 10.5px;
color: var(--neon-amber);
border-top: 1px dashed var(--neon-amber);
padding-top: 8px;
font-style: italic;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
import { useRotatingCopy } from "../composables/useRotatingCopy";
import { RACE_SCALE_NOTES, RACE_TITLES } from "../copy";
import { multiple } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const raceTitle = useRotatingCopy(RACE_TITLES);
const scaleNote = useRotatingCopy(RACE_SCALE_NOTES);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const rows = computed(() => {
const miners = props.snapshot?.miners ?? [];
const max = Math.max(...miners.map((m) => m.hashrateThs), 0.001);
const realRows = miners
.map((miner) => {
const key = minerKey(miner);
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
const velocity = samples.length >= 2 ? samples.at(-1)! - samples[0]! : 0;
return {
key,
miner,
image: imageFor(miner),
width: Math.max(8, (miner.hashrateThs / max) * 100),
trend: velocity,
place: 0,
};
})
.sort((a, b) => b.miner.hashrateThs - a.miner.hashrateThs)
.map((row, idx) => ({ ...row, place: idx + 1, joke: "" }));
return [
...realRows,
{
key: "boomer-heater",
miner: {
nickname: "Boomer Heater",
model: "Legacy Resistance Heater",
hashrateThs: 0,
status: "idle",
},
image: "/miners/boomer-heater.svg",
width: 0,
trend: 0,
place: realRows.length + 1,
joke: "0 TH/s · 0 shares · 0 sats · just fiat and a pension.",
},
];
});
const cosmicRows = computed(() => {
if (!networkEh.value || !totalThs.value) return [];
const totalNetworkThs = networkEh.value * 1_000_000;
const largePoolThs = totalNetworkThs * 0.25;
return [
{
key: "large-pool",
name: "Large pool-ish bully",
value: `${(largePoolThs / 1_000_000).toFixed(0)} EH/s`,
width: 78,
joke: "illustrative 25% chunk of the monster",
},
{
key: "network",
name: "Total Bitcoin network",
value: `${networkEh.value.toFixed(0)} EH/s`,
width: 100,
joke: `${multiple(networkMultiple.value)} your shelf. rude but factual.`,
},
];
});
function minerKey(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function imageFor(m: MinerStat): string {
if (m.nickname === "QU4CK") return "/miners/nerdqaxe.svg";
if (m.nickname === "P1XEL") return "/miners/bitaxe.svg";
if (m.nickname === "BigPapa" && /bitaxe/i.test(m.model)) return "/miners/bigpapa-bitaxe.svg";
if (m.nickname === "N4N0") return "/miners/avalon-nano-3.svg";
return "/miners/avalon-mini-3.svg";
}
</script>
<template>
<section class="race panel">
<header>
<div>
<div class="label">hashrate race</div>
<h2 class="glow-cyan">{{ raceTitle }}</h2>
</div>
<span class="muted">{{ scaleNote }}</span>
</header>
<div class="track">
<div class="section-label">Actual miners</div>
<div v-for="row in rows" :key="row.key" class="lane">
<div class="rank">#{{ row.place }}</div>
<img :src="row.image" :alt="row.miner.model" />
<div class="runner">
<div class="meta">
<strong>{{ row.miner.nickname }}</strong>
<span>{{ row.joke || `${row.miner.hashrateThs.toFixed(2)} TH/s` }}</span>
</div>
<div :class="['bar', { dead: row.width <= 0 }]">
<span :style="{ width: `${row.width}%` }">
<i>{{ row.width <= 0 ? "lol" : row.trend >= 0 ? "▲" : "▼" }}</i>
</span>
</div>
</div>
</div>
<div v-if="cosmicRows.length" class="section-label cosmic-label">
Big pools / total network · separate scale so the kids remain visible
</div>
<div v-for="row in cosmicRows" :key="row.key" class="lane cosmic">
<div class="rank">∞</div>
<div class="network-icon">₿</div>
<div class="runner">
<div class="meta">
<strong>{{ row.name }}</strong>
<span>{{ row.value }} · {{ row.joke }}</span>
</div>
<div class="bar cosmic-bar">
<span :style="{ width: `${row.width}%` }">
<i>NOPE</i>
</span>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.race {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 20px;
letter-spacing: 0;
}
.track {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-label {
color: var(--neon-amber);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
border-top: 1px solid var(--line);
padding-top: 10px;
}
.cosmic-label {
color: var(--neon-red);
}
.lane {
display: grid;
grid-template-columns: 46px 86px 1fr;
gap: 12px;
align-items: center;
}
.rank {
color: var(--neon-amber);
font-weight: 700;
font-size: 18px;
}
img {
width: 86px;
height: 58px;
object-fit: contain;
}
.network-icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border: 1px solid var(--neon-red);
color: var(--neon-amber);
font-size: 28px;
box-shadow: 0 0 14px rgba(255, 79, 120, 0.24);
}
.runner {
min-width: 0;
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
}
.meta span {
color: var(--fg-2);
}
.bar {
height: 18px;
margin-top: 5px;
border: 1px solid var(--line);
background: var(--bg-2);
overflow: hidden;
}
.bar span {
display: flex;
justify-content: flex-end;
align-items: center;
min-width: 28px;
height: 100%;
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-magenta), var(--heat-color, var(--neon-red)));
box-shadow: 0 0 14px rgba(255, 79, 120, 0.25);
transition: width 0.6s ease;
}
.bar.dead span {
width: 22px !important;
background: #475569;
box-shadow: none;
}
.cosmic .bar span {
background: linear-gradient(90deg, #ff4f78, #ffd84a);
}
.cosmic .meta strong {
color: var(--neon-red);
}
.bar i {
padding-right: 6px;
color: #07090f;
font-style: normal;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 620px) {
header {
flex-direction: column;
}
.lane {
grid-template-columns: 36px 64px 1fr;
}
img {
width: 64px;
}
}
</style>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot } from "../types";
import { useRotatingCopy } from "../composables/useRotatingCopy";
import { BLOCK_LABELS, HASHRATE_LABELS, NETWORK_LABELS } from "../copy";
import { multiple } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const networkRoast = computed(() => {
if (!networkMultiple.value) return "waiting for the grown-up numbers";
if (networkMultiple.value > 20_000_000) return "the network is a freight train; this shelf is a polite cough.";
if (networkMultiple.value > 1_000_000) return "big miners brought warehouses; you brought character.";
return "we are briefly visible on the cosmic spreadsheet.";
});
const subscribed = computed(() => props.snapshot?.pool.totalSubscriptions ?? 0);
const sharesAccepted = computed(() => props.snapshot?.pool.sharesAccepted ?? 0);
const sharesRejected = computed(() => props.snapshot?.pool.sharesRejected ?? 0);
const blockHeight = computed(() => props.snapshot?.job.blockHeight ?? 0);
const hashrateLabel = useRotatingCopy(HASHRATE_LABELS);
const blockLabel = useRotatingCopy(BLOCK_LABELS);
const networkLabel = useRotatingCopy(NETWORK_LABELS);
const ageS = computed(() => {
const t = props.snapshot?.fetchedAt;
if (!t) return null;
return Math.floor((Date.now() - t) / 1000);
});
</script>
<template>
<section class="hero panel">
<div class="left">
<div class="label">{{ hashrateLabel }}</div>
<div class="number glow-cyan">
{{ totalThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
<div class="network-roast muted">{{ networkRoast }}</div>
</div>
<div class="right">
<div class="cell">
<div class="label">{{ blockLabel }}</div>
<div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</div>
</div>
<div class="cell">
<div class="label">{{ networkLabel }}</div>
<div class="value glow-amber">{{ networkEh > 0 ? `${networkEh.toFixed(0)} EH/s` : "—" }}</div>
</div>
<div class="cell">
<div class="label">network is bigger by</div>
<div class="value glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "—" }}</div>
</div>
<div class="cell">
<div class="label">subscribed</div>
<div class="value">{{ subscribed }}</div>
</div>
<div class="cell">
<div class="label">accepted</div>
<div class="value glow-green">{{ sharesAccepted.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">rejected</div>
<div class="value glow-red">{{ sharesRejected.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">last poll</div>
<div class="value muted">{{ ageS === null ? "—" : `${ageS}s ago` }}</div>
</div>
</div>
</section>
</template>
<style scoped>
.hero {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 24px;
align-items: center;
}
.left .number {
font-size: 64px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
}
.left .unit {
font-size: 18px;
color: var(--fg-2);
}
.network-roast {
margin-top: 10px;
font-size: 11px;
line-height: 1.4;
}
.right {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
align-items: stretch;
}
.cell {
min-height: 74px;
display: grid;
grid-template-rows: 34px 1fr;
align-content: start;
}
.cell .label {
min-height: 34px;
display: flex;
align-items: flex-start;
line-height: 1.25;
overflow: hidden;
}
.cell .value {
font-size: 18px;
font-weight: 600;
margin-top: 6px;
min-height: 28px;
display: flex;
align-items: baseline;
white-space: nowrap;
}
@media (max-width: 800px) {
.hero { grid-template-columns: 1fr; }
.right { grid-template-columns: repeat(2, 1fr); }
.left .number { font-size: 44px; }
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { RELEASE_NOTES } from "../releaseNotes";
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
</script>
<template>
<div v-if="open" class="release-backdrop" @click="emit('close')" />
<aside :class="['release-drawer', { open }]">
<header>
<div>
<div class="label">release notes</div>
<h2 class="glow-amber">what changed while the heaters screamed</h2>
</div>
<button class="thin" aria-label="close release notes" @click="emit('close')">×</button>
</header>
<div class="notes">
<article v-for="note in RELEASE_NOTES" :key="note.hash" class="note">
<div class="note-head">
<strong>{{ note.title }}</strong>
<code>{{ note.hash }}</code>
</div>
<ul>
<li v-for="bullet in note.bullets" :key="bullet">{{ bullet }}</li>
</ul>
</article>
</div>
</aside>
</template>
<style scoped>
.release-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.22);
}
.release-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 21;
width: min(520px, 94vw);
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 16px;
background: rgba(7, 9, 15, 0.98);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.48);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.release-drawer.open {
transform: translateX(0);
}
header,
.note-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notes {
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.note {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 12px;
}
.note-head strong {
color: var(--neon-cyan);
}
code {
color: var(--fg-2);
font-size: 10px;
}
ul {
margin: 8px 0 0;
padding-left: 18px;
}
li {
margin: 5px 0;
color: var(--fg-1);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 760px) {
.release-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 65vh;
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.release-drawer.open {
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
import { multiple, tinyPercentAsOdds } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const HASHES_PER_TH = 1e12;
const DIFF_ONE_HASHES = 2 ** 32;
const BLOCKS_PER_DAY = 144;
const DAYS_PER_YEAR = 365;
const SUBSIDY_SATS = 312_500_000;
const miners = computed(() => props.snapshot?.miners ?? []);
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const totalWatts = computed(() => miners.value.reduce((sum, m) => sum + m.watts, 0));
const rewardSats = computed(() => props.snapshot?.job.blockValueSats || SUBSIDY_SATS);
const difficulty = computed(() => props.snapshot?.job.difficulty || 0);
const bestByMiner = computed(() => bestWorkByMiner(props.history));
const poolBest = computed(() => Math.max(0, ...Object.values(bestByMiner.value)));
const windowHours = computed(() => {
const first = props.history[0];
const last = props.history.at(-1);
if (!first || !last || last.t <= first.t) return 0;
return (last.t - first.t) / 3600000;
});
const shareVelocity = computed(() => {
const first = props.history[0];
const last = props.history.at(-1);
if (!first || !last || windowHours.value <= 0) return 0;
return Math.max(0, last.accepted - first.accepted) / windowHours.value;
});
const expectedSecondsToBlock = computed(() => {
if (!difficulty.value || totalThs.value <= 0) return 0;
return (difficulty.value * DIFF_ONE_HASHES) / (totalThs.value * HASHES_PER_TH);
});
const expectedBlocksYear = computed(() => expectedSecondsToBlock.value > 0 ? (DAYS_PER_YEAR * 86400) / expectedSecondsToBlock.value : 0);
const expectedSatsDay = computed(() => expectedBlocksYear.value > 0 ? (expectedBlocksYear.value / DAYS_PER_YEAR) * rewardSats.value : 0);
const heatBtu = computed(() => totalWatts.value * 3.412);
const poolEfficiency = computed(() => totalWatts.value > 0 ? totalThs.value / totalWatts.value : 0);
const connectionHealth = computed(() => {
const pool = props.snapshot?.pool;
if (!pool) return "collecting";
if (pool.totalSubscriptions < miners.value.length) return "missing subscriptions";
if (miners.value.some((m) => m.status !== "hashing")) return "somebody wandered off";
if (pool.activeThreads <= 0) return "threads unknown";
return "nominal, suspiciously";
});
const minerRows = computed(() => {
return miners.value
.map((m) => {
const key = minerKey(m);
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
const avg = average(samples);
const sd = stddev(samples, avg);
const dominance = totalThs.value > 0 ? (m.hashrateThs / totalThs.value) * 100 : 0;
const expectedPct = m.expectedHashrateThs > 0 ? (m.hashrateThs / m.expectedHashrateThs) * 100 : 0;
const idlePct = statusPct(props.history, key, "idle") + statusPct(props.history, key, "stale");
const best = bestByMiner.value[key] ?? 0;
return {
key,
miner: m,
avg,
high: samples.length ? Math.max(...samples) : 0,
low: samples.length ? Math.min(...samples) : 0,
sd,
dominance,
expectedPct,
idlePct,
best,
bestDifficultyPct: difficulty.value > 0 ? (best / difficulty.value) * 100 : 0,
thPerWatt: m.watts > 0 ? m.hashrateThs / m.watts : 0,
heatBtu: m.watts * 3.412,
sharesPerHour: minerShareVelocity(props.history, key),
line: personaLine(m, dominance, expectedPct, sd, idlePct),
};
})
.sort((a, b) => b.best - a.best || b.miner.hashrateThs - a.miner.hashrateThs);
});
const leaders = computed(() => {
const rows = minerRows.value;
return [
{ label: "carrier", name: [...rows].sort((a, b) => b.dominance - a.dominance)[0]?.miner.nickname ?? "-" },
{ label: "best swing", name: [...rows].sort((a, b) => b.best - a.best)[0]?.miner.nickname ?? "-" },
{ label: "most stable", name: [...rows].sort((a, b) => a.sd - b.sd)[0]?.miner.nickname ?? "-" },
{ label: "reject pain", name: [...miners.value].sort((a, b) => b.rejectPct - a.rejectPct)[0]?.nickname ?? "-" },
];
});
function bestWorkByMiner(history: HistoryPoint[]): Record<string, number> {
const out: Record<string, number> = {};
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
for (const [key, work] of Object.entries(next.minerWork)) {
const shares = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = work - (prev.minerWork[key] ?? work);
if (shares > 0 && workDelta > 0) out[key] = Math.max(out[key] ?? 0, workDelta / shares);
}
}
return out;
}
function minerKey(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function average(values: number[]): number {
if (!values.length) return 0;
return values.reduce((sum, n) => sum + n, 0) / values.length;
}
function stddev(values: number[], avg: number): number {
if (values.length < 2) return 0;
return Math.sqrt(values.reduce((sum, n) => sum + (n - avg) ** 2, 0) / values.length);
}
function statusPct(history: HistoryPoint[], key: string, status: MinerStat["status"]): number {
const seen = history.filter((p) => p.minerStatus?.[key]);
if (!seen.length) return 0;
return (seen.filter((p) => p.minerStatus[key] === status).length / seen.length) * 100;
}
function minerShareVelocity(history: HistoryPoint[], key: string): number {
const first = history.find((p) => Number.isFinite(p.minerShares?.[key]));
const last = [...history].reverse().find((p) => Number.isFinite(p.minerShares?.[key]));
if (!first || !last || last.t <= first.t) return 0;
return Math.max(0, (last.minerShares[key] ?? 0) - (first.minerShares[key] ?? 0)) / ((last.t - first.t) / 3600000);
}
function personaLine(m: MinerStat, dominance: number, expectedPct: number, sd: number, idlePct: number): string {
if (idlePct > 30) return `${m.nickname} has spent too much of this window being decorative.`;
if (m.rejectPct > 5) return `${m.nickname} is donating work to the void with confidence.`;
if (expectedPct > 110) return `${m.nickname} is carrying the group project and will be insufferable about it.`;
if (dominance > 55) return `${m.nickname} is the adult in the room, which is bleak for everyone else.`;
if (sd > Math.max(0.4, m.hashrateThs * 0.25)) return `${m.nickname} hashes like a shopping trolley with one bad wheel.`;
return `${m.nickname} is doing its tiny job. Nobody alert the institutions.`;
}
function ageLabel(seconds: number | null): string {
if (seconds === null) return "never";
if (seconds < 60) return `${seconds.toFixed(0)}s`;
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
return `${(seconds / 3600).toFixed(1)}h`;
}
function duration(seconds: number): string {
if (!seconds || !Number.isFinite(seconds)) return "collecting";
const years = seconds / 31536000;
if (years >= 1e9) return `${(years / 1e9).toFixed(2)}B years`;
if (years >= 1e6) return `${(years / 1e6).toFixed(2)}M years`;
if (years >= 1000) return `${(years / 1000).toFixed(2)}K years`;
if (years >= 1) return `${years.toFixed(1)} years`;
const days = seconds / 86400;
return days >= 1 ? `${days.toFixed(1)} days` : `${(seconds / 3600).toFixed(1)}h`;
}
function compact(n: number, digits = 2): string {
if (!Number.isFinite(n)) return "-";
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
return n.toFixed(digits);
}
function pct(n: number, digits = 4): string {
if (!Number.isFinite(n) || n <= 0) return "0%";
if (n < 0.001) return tinyPercentAsOdds(n);
return `${n.toFixed(digits)}%`;
}
</script>
<template>
<section class="shameboard panel">
<header>
<div>
<div class="label">leaderboard / shameboard</div>
<h2 class="glow-magenta">Receipts from the hashing cupboard</h2>
</div>
<div class="health">
<span class="label">connection</span>
<strong>{{ connectionHealth }}</strong>
</div>
</header>
<div class="summary">
<div>
<span class="label">best pool swing</span>
<strong class="glow-amber">{{ poolBest > 0 ? compact(poolBest) : "collecting" }}</strong>
<small>{{ difficulty > 0 ? `${pct((poolBest / difficulty) * 100)} of difficulty` : "difficulty unavailable" }}</small>
</div>
<div>
<span class="label">expected block</span>
<strong>{{ duration(expectedSecondsToBlock) }}</strong>
<small>{{ compact(expectedBlocksYear, 8) }} blocks/year</small>
</div>
<div>
<span class="label">imaginary payday</span>
<strong class="glow-green">{{ compact(expectedSatsDay, 4) }}</strong>
<small>sats/day expected</small>
</div>
<div>
<span class="label">share velocity</span>
<strong>{{ compact(shareVelocity, 1) }}</strong>
<small>accepted/hour</small>
</div>
<div>
<span class="label">space heater mode</span>
<strong>{{ compact(totalWatts, 0) }} W</strong>
<small>{{ compact(heatBtu, 0) }} BTU/hr · {{ compact(poolEfficiency, 4) }} TH/W</small>
</div>
<div>
<span class="label">network bullying</span>
<strong class="glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "collecting" }}</strong>
<small>{{ networkEh > 0 ? `${compact(networkEh, 0)} EH/s globally. easy version: the planet brought a warehouse.` : "tx1138 mempool warming up" }}</small>
</div>
</div>
<div class="leader-strip">
<div v-for="leader in leaders" :key="leader.label">
<span class="label">{{ leader.label }}</span>
<strong>{{ leader.name }}</strong>
</div>
</div>
<div class="table">
<div class="row head">
<span>miner</span>
<span>best</span>
<span>share</span>
<span>stability</span>
<span>power</span>
<span>last</span>
</div>
<div v-for="row in minerRows" :key="row.key" class="row">
<div class="who">
<strong>{{ row.miner.nickname }}</strong>
<small>{{ row.line }}</small>
</div>
<span>
{{ row.best > 0 ? compact(row.best) : "collecting" }}
<small>{{ difficulty > 0 ? pct(row.bestDifficultyPct) : "diff ?" }}</small>
</span>
<span>
{{ compact(row.dominance, 1) }}%
<small>{{ compact(row.sharesPerHour, 1) }}/hr</small>
</span>
<span>
{{ compact(row.avg) }} TH/s
<small>{{ compact(row.low) }}-{{ compact(row.high) }} · sd {{ compact(row.sd) }}</small>
</span>
<span>
{{ compact(row.thPerWatt, 4) }} TH/W
<small>{{ row.miner.watts }} W · {{ compact(row.heatBtu, 0) }} BTU/hr</small>
</span>
<span>
{{ ageLabel(row.miner.lastShareAgeS) }}
<small>{{ compact(row.idlePct, 1) }}% idle/stale</small>
</span>
</div>
</div>
</section>
</template>
<style scoped>
.shameboard {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
}
header,
.summary,
.leader-strip,
.row {
display: grid;
gap: 12px;
}
header {
grid-template-columns: 1fr auto;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 20px;
letter-spacing: 0;
}
.health {
text-align: right;
}
.health strong {
display: block;
color: var(--neon-cyan);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summary {
grid-template-columns: repeat(6, 1fr);
}
.summary > div,
.leader-strip > div {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
padding: 10px;
min-height: 88px;
}
strong {
display: block;
font-size: 16px;
}
small {
display: block;
color: var(--fg-2);
font-size: 10px;
line-height: 1.35;
}
.leader-strip {
grid-template-columns: repeat(4, 1fr);
}
.table {
display: flex;
flex-direction: column;
border-top: 1px solid var(--line);
}
.row {
grid-template-columns: minmax(180px, 1.6fr) repeat(5, minmax(96px, 1fr));
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.row.head {
color: var(--fg-2);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.who strong {
color: var(--neon-magenta);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, 1fr);
}
.leader-strip {
grid-template-columns: repeat(2, 1fr);
}
.row {
grid-template-columns: 1fr 1fr;
}
.row.head {
display: none;
}
}
@media (max-width: 640px) {
header,
.summary,
.leader-strip,
.row {
grid-template-columns: 1fr;
}
.health {
text-align: left;
}
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
// Simple sparkline: keep the last N hashrate samples and render as SVG.
const SAMPLES = 60;
const samples = ref<number[]>([]);
let lastFetchedAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap || snap.fetchedAt === lastFetchedAt) return;
lastFetchedAt = snap.fetchedAt;
samples.value.push(snap.pool.combinedHashrateThs);
if (samples.value.length > SAMPLES) samples.value.shift();
},
);
const path = computed(() => {
const ys = samples.value;
if (ys.length < 2) return "";
const max = Math.max(...ys, 0.001);
const w = 100;
const h = 32;
return ys
.map((y, i) => {
const x = (i / (SAMPLES - 1)) * w;
const yPx = h - (y / max) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${yPx.toFixed(2)}`;
})
.join(" ");
});
</script>
<template>
<section class="ticker panel">
<div class="label">hashrate · last {{ SAMPLES }} polls</div>
<svg viewBox="0 0 100 32" preserveAspectRatio="none">
<path :d="path" stroke="var(--neon-cyan)" stroke-width="0.6" fill="none" />
</svg>
</section>
</template>
<style scoped>
.ticker { display: flex; flex-direction: column; gap: 6px; }
svg { width: 100%; height: 60px; }
</style>
@@ -0,0 +1,18 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
export function useRotatingCopy(lines: readonly string[], intervalMs = 10_000) {
const index = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
index.value = (index.value + 1) % lines.length;
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) window.clearInterval(timer);
});
return computed(() => lines[index.value] ?? lines[0] ?? "");
}
+114
View File
@@ -0,0 +1,114 @@
export const HASHRATE_LABELS = [
"total hashrate · all actual miners",
"tiny thunder department",
"hashing cupboard output",
"combined cope velocity",
"someone's getting warmed tonight",
"space heaters with receipts",
"four boards versus a planet",
"domestic sha-256 turbulence",
"the little engines that almost certainly won't",
"thermal optimism index",
"proof-of-warmth aggregate",
"hashrate, allegedly",
"tiny chips doing tax fraud to physics",
"the room is warmer than the odds",
"solo lottery horsepower",
"sats-per-delusion engine",
"garage-grade entropy cannon",
"pleb furnace output",
"all miners, no excuses",
"bitcoin-adjacent radiator speed",
] as const;
export const BLOCK_LABELS = [
"current block height",
"where bitcoin is right now",
"latest block on the wall",
"chain height, for the adults",
"block number the kids are chasing",
"global scoreboard tick",
"bitcoin's current floor",
"the moving target",
"height of the mountain",
"mempool says we're here",
"sovereign block receipt",
"latest confirmed step",
"chain altitude",
"block ladder position",
"what the node believes",
"today's impossible number",
"where the grown-up miners are",
"the target keeps walking",
"bitcoin's odometer",
"public humiliation height",
] as const;
export const NETWORK_LABELS = [
"network hashrate",
"the actual boss fight",
"everyone else, unfortunately",
"global mining industrial complex",
"the dragon outside",
"planetary sha-256 weather",
"big pool gravity",
"the reason this is funny",
"industrial-scale bullying",
"total network furnace",
"the adults with warehouses",
"hashrate landlord class",
"bitcoin's meat grinder",
"global chip argument",
"big miner misery wall",
"the mountain of nope",
"everyone versus your shelf",
"network-sized emotional damage",
"the pool hall full of monsters",
"the thing Boomer Heater fears",
] as const;
export const RACE_TITLES = [
"Tiny machines, deeply unfair track",
"Actual miners, spiritually overconfident",
"The cupboard grand prix",
"Drag race at walking speed",
"The hashrate kindergarten derby",
"Domestic miners doing their little laps",
"Four warm rectangles and a dream",
"Race day for the statistically doomed",
"The space-heater invitational",
"Actual miners, actual delusion",
"The tiny ASIC humiliation circuit",
"Tour de not-finding-a-block",
"Hashrate horse race, if horses were firmware",
"A sprint measured in geological disappointment",
"The nobody-is-retiring 500",
"Proof-of-work nursery slopes",
"The little rigs are trying, allegedly",
"Solo mining track day",
"The warm boys championship",
"Someone's getting warmed tonight",
] as const;
export const RACE_SCALE_NOTES = [
"actual miners · scaled up to not shame the children",
"scaled up so the children remain visible",
"visual mercy scale enabled",
"not to scale, because cruelty has limits",
"tiny miner zoom mode",
"chart wearing reading glasses",
"magnified for emotional support",
"scaled up like their confidence",
"warehouse miners cropped out for decency",
"child-safe hashrate scale",
"actual miners, padded for dignity",
"zoomed in before the network laughs",
"big pools excluded from the yardstick",
"scale adjusted to preserve morale",
"the children get a booster seat",
"mercifully not linear",
"visual participation trophy mode",
"shown larger than nature intended",
"small rig cinema mode",
"the graph lies kindly",
] as const;
+26
View File
@@ -0,0 +1,26 @@
export function compactNumber(n: number, digits = 1): string {
if (!Number.isFinite(n)) return "-";
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
return n.toFixed(digits);
}
export function oneIn(probability: number): string {
if (!Number.isFinite(probability) || probability <= 0) return "basically never";
return `1 in ${compactNumber(1 / probability, 1)}`;
}
export function tinyPercentAsOdds(percent: number): string {
if (!Number.isFinite(percent) || percent <= 0) return "0%";
const probability = percent / 100;
if (percent < 0.001) return oneIn(probability);
if (percent < 1) return `${percent.toFixed(4)}%`;
return `${percent.toFixed(2)}%`;
}
export function multiple(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "-";
return `${compactNumber(n, 1)}x`;
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import { registerPwa } from "./services/pwa";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
registerPwa();
@@ -0,0 +1,96 @@
export type ReleaseNote = {
hash: string;
title: string;
bullets: string[];
};
export const RELEASE_NOTES: ReleaseNote[] = [
{
hash: "b715c3f",
title: "Nostr chat drawer",
bullets: [
"Added a signed Nostr chat panel, because apparently the miners needed a comments section for witnesses.",
"Desktop slides in from the right; mobile rises as a half-screen sheet like a tiny gossip bunker.",
"Loads kind-0 profile names and avatars, so the abuse has proper attribution.",
],
},
{
hash: "b2f81a1",
title: "Tidier hero stats",
bullets: [
"Pinned the hero stat boxes down so rotating jokes stop rearranging the furniture.",
"Aligned block height, network hashrate, and share stats like they have jobs.",
],
},
{
hash: "1f86bf9",
title: "Readable odds",
bullets: [
"Replaced cursed science notation with human-readable odds like '1 in 2.0B', which is still awful but now legible.",
"Slowed rotating labels and lottery jokes to 10 seconds so the dashboard stops acting caffeinated.",
],
},
{
hash: "fa707e2",
title: "Network stats and rotating jokes",
bullets: [
"Added block height, network hashrate, and difficulty from tx1138 mempool, because Datum was keeping the good gossip to itself.",
"Solo odds now use current network hashrate, so the humiliation is freshly calculated.",
"Added rotating labels and a large pile of darker lottery jokes about fiat, Bitcoin, and electrical self-deception.",
"Race view now compares actual miners to a large-pool-ish bully and the total network, on a mercy scale so the children remain visible.",
],
},
{
hash: "7e1f7a1",
title: "Heat-reactive theme",
bullets: [
"Interface now gets redder from 10 TH/s to 70 TH/s, because someone's getting warmed tonight.",
],
},
{
hash: "243cd0d",
title: "Boomer Heater got worse",
bullets: [
"Gave Boomer Heater a dead status, skull marker, and custom zero-stat labels because it contributes exactly fuck all.",
"Updated the heater asset to say 'NO HASHES. JUST GHEI.' Accurate thermodynamics, questionable monetary policy.",
],
},
{
hash: "d6f8fd7",
title: "Boomer Heater on status",
bullets: [
"Added fake Boomer Heater to the status screen, always last, where fiat appliances belong.",
"Race copy now says 'just fiat and a pension.' No sats, no shares, no future.",
],
},
{
hash: "0c8e26f",
title: "PWA and miner race",
bullets: [
"Made gashboard installable as a PWA with icons, service worker, and cache rules that should stop stale bundles haunting the cupboard.",
"Remote signer login can resume after Primal/Amber returns to the PWA callback, because mobile auth should not require ritual sacrifice.",
"Added miner race visuals with local miner assets, including the non-hashing radiator of shame.",
],
},
{
hash: "360b1eb",
title: "BigPapa",
bullets: ["Renamed the unknown Datum miner from ??? to BigPapa. Same mystery, more authority."],
},
{
hash: "c2c376f",
title: "Shameboard calculations",
bullets: [
"Added best observed share/work, expected block time, sats/day fantasy math, share velocity, reject pain, efficiency, BTU/hr, and per-miner insults. A spreadsheet with a personality disorder.",
],
},
{
hash: "c77c746",
title: "Graphs and remote signer login",
bullets: [
"Added graph tab with hashrate telemetry, share flow, miner mix, and browser-local history so the suffering can be trended.",
"Added Nostr Connect 'open signer app' login for Primal/Amber handoff, replacing the bunker URI homework nobody asked for.",
"Fixed Portainer-on-Umbrel deployment compose for host networking and direct Datum URL, because Portainer's fake network was cosplay.",
],
},
];
+45
View File
@@ -0,0 +1,45 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { useAuthStore } from "./stores/auth";
const routes: RouteRecordRaw[] = [
{
path: "/access",
name: "access",
component: () => import("./views/AccessView.vue"),
meta: { requiresAuth: true },
},
{
path: "/",
name: "dashboard",
component: () => import("./views/DashboardView.vue"),
meta: { requiresAuth: true },
},
{
path: "/graphs",
name: "graphs",
component: () => import("./views/GraphsView.vue"),
meta: { requiresAuth: true },
},
{
path: "/login",
name: "login",
component: () => import("./views/LoginView.vue"),
},
{
path: "/auth/nostr-callback",
name: "nostr-callback",
component: () => import("./views/NostrCallbackView.vue"),
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) return { name: "login" };
if (to.name === "login" && auth.isLoggedIn) return { name: "dashboard" };
return true;
});
@@ -0,0 +1,115 @@
import type { ApiError, ChatSnapshot, DatumSnapshot, LoginResponse } from "../types";
import { buildNip98AuthHeader } from "./nip98";
const TOKEN_KEY = "gashboard.jwt";
const NPUB_KEY = "gashboard.npub";
export function loadToken(): { token: string; npub: string } | null {
const token = localStorage.getItem(TOKEN_KEY);
const npub = localStorage.getItem(NPUB_KEY);
if (!token || !npub) return null;
return { token, npub };
}
export function saveToken(token: string, npub: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(NPUB_KEY, npub);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(NPUB_KEY);
}
async function asError(res: Response): Promise<Error> {
let body: ApiError | undefined;
try {
body = (await res.json()) as ApiError;
} catch {
/* ignore */
}
const code = body?.error?.code ?? `HTTP_${res.status}`;
const message = body?.error?.message ?? res.statusText;
const err = new Error(`${code}: ${message}`);
(err as Error & { code?: string }).code = code;
return err;
}
export async function login(): Promise<LoginResponse> {
const url = `${window.location.origin}/api/auth/login`;
const auth = await buildNip98AuthHeader("POST", url);
const res = await fetch(url, {
method: "POST",
headers: { authorization: auth, "content-type": "application/json" },
body: "{}",
});
if (!res.ok) throw await asError(res);
return (await res.json()) as LoginResponse;
}
async function authedGet<T>(path: string): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
headers: { authorization: `Bearer ${stored.token}` },
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
async function authedPost<T>(path: string, body: unknown): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
method: "POST",
headers: {
authorization: `Bearer ${stored.token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
export async function fetchSnapshot(): Promise<DatumSnapshot> {
return authedGet<DatumSnapshot>("/api/datum/stats");
}
export async function fetchChatPubkeys(): Promise<string[]> {
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
return res.pubkeys;
}
export function fetchChat(): Promise<ChatSnapshot> {
return authedGet<ChatSnapshot>("/api/chat");
}
export function postChatMessage(body: {
content: string;
replyToId?: string;
replyToPubkey?: string;
}): Promise<ChatSnapshot["messages"][number]> {
return authedPost<ChatSnapshot["messages"][number]>("/api/chat/messages", body);
}
export function postChatReaction(body: {
messageId: string;
content: string;
}): Promise<ChatSnapshot["reactions"][number]> {
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
}
export function postChatKeyWraps(body: {
wraps: Array<{ recipientPubkey: string; ciphertext: string }>;
}): Promise<{ wraps: ChatSnapshot["keyWraps"] }> {
return authedPost<{ wraps: ChatSnapshot["keyWraps"] }>("/api/chat/key-wraps", body);
}
@@ -0,0 +1,274 @@
import type { Event as NostrEvent } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
import { fetchChat, fetchChatPubkeys, postChatKeyWraps, postChatMessage, postChatReaction } from "./api";
import { getActiveSigner, hasActiveSigner } from "./signer";
import type { ChatSnapshot } from "../types";
const RELAYS = ["wss://relay.primal.net"];
const POLL_MS = 2500;
const pool = new SimplePool();
export type ChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
};
export type ChatReaction = {
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
};
export type ChatProfile = {
pubkey: string;
name: string;
picture: string;
};
let reactionCache: ChatReaction[] = [];
let cachedRecipients: string[] | null = null;
let cachedRoomKey: CryptoKey | null = null;
let cachedRoomKeyRaw = "";
export function canSendChat(): boolean {
return hasActiveSigner();
}
export function pubkeyFromNpub(npub: string | null): string | null {
if (!npub) return null;
try {
const decoded = nip19.decode(npub);
return decoded.type === "npub" ? decoded.data : null;
} catch {
return null;
}
}
export async function loadChat(): Promise<ChatMessage[]> {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
return (await Promise.all(snapshot.messages.map(decryptMessage))).sort((a, b) => a.createdAt - b.createdAt);
}
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
const wanted = new Set(messageIds);
if (!reactionCache.length) {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
}
return reactionCache.filter((r) => wanted.has(r.messageId));
}
export function subscribeChat(
onMessage: (message: ChatMessage) => void,
onReaction?: (reaction: ChatReaction) => void,
): { close: () => void } {
const seenMessages = new Set<string>();
const seenReactions = new Set<string>();
let closed = false;
async function poll(): Promise<void> {
if (closed) return;
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
for (const message of snapshot.messages) {
if (seenMessages.has(message.id)) continue;
seenMessages.add(message.id);
onMessage(await decryptMessage(message));
}
for (const reaction of snapshot.reactions) {
if (seenReactions.has(reaction.id)) continue;
seenReactions.add(reaction.id);
onReaction?.(await decryptReaction(reaction));
}
}
const timer = window.setInterval(() => {
void poll().catch(() => {});
}, POLL_MS);
void poll().catch(() => {});
return {
close() {
closed = true;
window.clearInterval(timer);
},
};
}
export async function sendChat(
content: string,
replyTo?: { id: string; pubkey: string },
): Promise<ChatMessage> {
const encrypted = await encryptPayload(content);
const message = await postChatMessage({
content: encrypted,
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
});
return { ...message, content };
}
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
const encrypted = await encryptPayload(content);
const reaction = await postChatReaction({ messageId: message.id, content: encrypted });
return { ...reaction, content };
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
const authors = [...new Set(pubkeys)].filter(Boolean);
if (!authors.length) return {};
const events = await pool.querySync(RELAYS, { kinds: [0], authors }, { maxWait: 2500 });
const latest = new Map<string, NostrEvent>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) latest.set(event.pubkey, event);
}
const out: Record<string, ChatProfile> = {};
for (const [pubkey, event] of latest) {
try {
const meta = JSON.parse(event.content) as { name?: string; display_name?: string; picture?: string };
out[pubkey] = {
pubkey,
name: meta.display_name || meta.name || shortPubkey(pubkey),
picture: meta.picture || "",
};
} catch {
out[pubkey] = { pubkey, name: shortPubkey(pubkey), picture: "" };
}
}
return out;
}
export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
type EncryptedEnvelope = {
v: 1;
alg: "aes-gcm";
room: "gashboard-chat";
iv: string;
ciphertext: string;
};
async function encryptPayload(plaintext: string): Promise<string> {
const key = await ensureRoomKey();
try {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
return JSON.stringify({
v: 1,
alg: "aes-gcm",
room: "gashboard-chat",
iv: base64(iv),
ciphertext: base64(new Uint8Array(ciphertext)),
} satisfies EncryptedEnvelope);
} catch (e) {
throw new Error(
e instanceof Error
? `Private chat encryption failed: ${e.message}. Reconnect signer if the room key is missing.`
: "Private chat encryption failed. Reconnect signer if the room key is missing.",
);
}
}
async function decryptPayload(value: string): Promise<string> {
try {
const envelope = JSON.parse(value) as EncryptedEnvelope;
if (envelope.v !== 1 || envelope.alg !== "aes-gcm" || envelope.room !== "gashboard-chat") {
return "[encrypted with unknown chat format]";
}
const key = await ensureRoomKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
key,
unbase64(envelope.ciphertext),
);
return new TextDecoder().decode(decrypted);
} catch {
return "[encrypted - decrypt failed]";
}
}
async function decryptMessage(message: ChatMessage): Promise<ChatMessage> {
return { ...message, content: await decryptPayload(message.content) };
}
async function decryptReaction(reaction: ChatReaction): Promise<ChatReaction> {
return { ...reaction, content: await decryptPayload(reaction.content) };
}
async function getRecipients(): Promise<string[]> {
const signer = getActiveSigner();
const own = await signer?.getPublicKey();
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
if (cachedRoomKey) return cachedRoomKey;
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
const own = await signer.getPublicKey();
const current = snapshot ?? await fetchChat();
const wrap = current.keyWraps.find((item) => item.recipientPubkey === own);
if (wrap) {
const raw = await signer.decrypt(wrap.senderPubkey, wrap.ciphertext);
return importRoomKey(raw);
}
const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const raw = base64(rawBytes);
await importRoomKey(raw);
await publishRoomKey(raw);
return cachedRoomKey!;
}
async function publishRoomKey(raw: string): Promise<void> {
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to share private chat key");
const recipients = await getRecipients();
const wraps = await Promise.all(
recipients.map(async (recipientPubkey) => ({
recipientPubkey,
ciphertext: await signer.encrypt(recipientPubkey, raw),
})),
);
await postChatKeyWraps({ wraps });
}
async function importRoomKey(raw: string): Promise<CryptoKey> {
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
cachedRoomKeyRaw = raw;
return cachedRoomKey;
}
function base64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function unbase64(value: string): Uint8Array {
const binary = atob(value);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
@@ -0,0 +1,21 @@
import type { EventTemplate } from "nostr-tools";
import { getActiveSigner } from "./signer";
export async function buildNip98AuthHeader(method: string, fullUrl: string): Promise<string> {
const signer = getActiveSigner();
if (!signer) throw new Error("Not logged in");
const template: EventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", fullUrl],
["method", method.toUpperCase()],
],
content: "",
};
const signed = await signer.signEvent(template);
const json = JSON.stringify(signed);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `Nostr ${b64}`;
}
@@ -0,0 +1,18 @@
export function registerPwa(): void {
if (!("serviceWorker" in navigator)) return;
if (import.meta.env.DEV) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").then((registration) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
worker.postMessage({ type: "SKIP_WAITING" });
}
});
});
});
});
}
@@ -0,0 +1,404 @@
// Two signing paths: NIP-07 browser extension and NIP-46 remote signer
// (covers Primal app + Amber via bunker:// URI). The active signer is held
// in module state — there's only one logged-in user at a time.
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import type { Filter } from "nostr-tools/filter";
import { SimplePool } from "nostr-tools/pool";
import { readonly, ref } from "vue";
export type SignerKind = "extension" | "bunker";
export type Signer = {
kind: SignerKind;
getPublicKey(): Promise<string>;
signEvent(template: EventTemplate): Promise<NostrEvent>;
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
const activeSigner = ref<Signer | null>(null);
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
const NOSTR_CONNECT_PENDING_KEY = "gashboard.nostrconnect.pending";
const SIGNER_SESSION_KEY = "gashboard.signer.session";
const pool = new SimplePool();
export function getActiveSigner(): Signer | null {
return activeSigner.value;
}
export function hasActiveSigner(): boolean {
return activeSigner.value !== null;
}
export function clearSigner(): void {
activeSigner.value = null;
clearSavedSignerSession();
}
export const activeSignerState = readonly(activeSigner);
export async function loginWithExtension(): Promise<string> {
if (!window.nostr) {
throw new Error("No Nostr signer available. Open Gashboard from Archipelago, or enable a NIP-07 browser extension.");
}
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
activeSigner.value = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await ext.signEvent({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your signer cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your signer cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return pubkey;
}
export async function loginWithBunker(bunkerUri: string): Promise<string> {
// Lazy-load applesauce-signers — only pulled when bunker login is attempted
// so the dashboard works for users who only use NIP-07.
const mod = await import("applesauce-signers");
const Ctor =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
if (!Ctor) {
throw new Error("applesauce-signers does not expose NostrConnectSigner");
}
const fromUri = Ctor.fromBunkerURI ?? Ctor.fromURI;
if (!fromUri) {
throw new Error("NostrConnectSigner has no fromBunkerURI / fromURI helper");
}
const signer = await fromUri.call(Ctor, bunkerUri);
const pubkey: string = await signer.getPublicKey();
const remote = signer.remote || pubkey;
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
key: Array.from(signer.signer.key),
secret: signer.secret,
relays: signer.relays,
remote,
pubkey,
});
return pubkey;
}
export async function loginWithRemoteApp(): Promise<string> {
return connectRemoteApp({ openApp: true });
}
export async function reconnectRemoteApp(): Promise<string> {
clearSigner();
return connectRemoteApp({ openApp: true });
}
export async function resumeRemoteAppLogin(): Promise<string> {
return connectRemoteApp({ openApp: false });
}
export function hasPendingRemoteAppLogin(): boolean {
return !!sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
}
async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
const PrivateKeySigner =
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
if (!NostrConnectSigner || !PrivateKeySigner) {
throw new Error("Nostr Connect signer support is unavailable");
}
const pending = loadPendingRemoteLogin();
const clientSigner = pending
? new PrivateKeySigner(new Uint8Array(pending.key))
: new PrivateKeySigner();
const signer = new NostrConnectSigner({
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
signer: clientSigner,
...(pending ? { secret: pending.secret } : {}),
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
const permissions = [
...(NostrConnectSigner.buildSigningPermissions?.([27235, 4]) ?? ["sign_event:27235", "sign_event:4"]),
"nip44_encrypt",
"nip44_decrypt",
];
if (!pending) {
savePendingRemoteLogin({
key: Array.from(clientSigner.key),
secret: signer.secret,
relays: NOSTR_CONNECT_RELAYS,
});
}
if (opts.openApp) {
const uri = withCallback(
signer.getNostrConnectURI({
name: "gashboard",
url: window.location.origin,
permissions,
}),
);
openSignerApp(uri);
}
const abort = new AbortController();
const timeout = window.setTimeout(() => abort.abort(), NOSTR_CONNECT_TIMEOUT_MS);
try {
await signer.waitForSigner(abort.signal);
if (!signer.remote) throw new Error("Remote signer did not complete the connection");
const pubkey: string = await signer.getPublicKey();
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
key: Array.from(clientSigner.key),
secret: signer.secret,
relays: signer.relays,
remote: signer.remote,
pubkey,
});
clearPendingRemoteLogin();
return pubkey;
} finally {
window.clearTimeout(timeout);
if (!signer.isConnected) await signer.close().catch(() => {});
}
}
export async function restoreSavedSigner(): Promise<boolean> {
if (activeSigner.value) return true;
const saved = loadSavedSignerSession();
if (!saved) return false;
if (saved.kind === "extension") {
if (!window.nostr) return false;
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
if (saved.pubkey && saved.pubkey !== pubkey) {
clearSavedSignerSession();
return false;
}
activeSigner.value = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await ext.signEvent({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your signer cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your signer cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return true;
}
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
const PrivateKeySigner =
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
if (!NostrConnectSigner || !PrivateKeySigner) return false;
const signer = new NostrConnectSigner({
relays: saved.relays,
signer: new PrivateKeySigner(new Uint8Array(saved.key)),
secret: saved.secret,
remote: saved.remote,
pubkey: saved.pubkey,
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => saved.pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
return true;
}
type PendingRemoteLogin = {
key: number[];
secret: string;
relays: string[];
};
type SavedSignerSession =
| {
kind: "extension";
pubkey: string;
}
| {
kind: "bunker";
key: number[];
secret: string;
relays: string[];
remote: string;
pubkey: string;
};
function loadPendingRemoteLogin(): PendingRemoteLogin | null {
try {
const raw = sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingRemoteLogin;
if (!Array.isArray(parsed.key) || !parsed.secret || !Array.isArray(parsed.relays)) return null;
return parsed;
} catch {
return null;
}
}
function savePendingRemoteLogin(pending: PendingRemoteLogin): void {
sessionStorage.setItem(NOSTR_CONNECT_PENDING_KEY, JSON.stringify(pending));
}
function clearPendingRemoteLogin(): void {
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY);
}
function loadSavedSignerSession(): SavedSignerSession | null {
try {
const raw = localStorage.getItem(SIGNER_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as SavedSignerSession;
if (parsed.kind === "extension" && parsed.pubkey) return parsed;
if (
parsed.kind === "bunker" &&
Array.isArray(parsed.key) &&
parsed.secret &&
Array.isArray(parsed.relays) &&
parsed.remote &&
parsed.pubkey
) {
return parsed;
}
return null;
} catch {
return null;
}
}
function saveSignerSession(session: SavedSignerSession): void {
localStorage.setItem(SIGNER_SESSION_KEY, JSON.stringify(session));
}
function clearSavedSignerSession(): void {
localStorage.removeItem(SIGNER_SESSION_KEY);
}
function withCallback(uri: string): string {
const separator = uri.includes("?") ? "&" : "?";
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;
}
function openSignerApp(uri: string): void {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
if (isMobile) {
window.location.href = uri;
return;
}
const opened = window.open(uri, "_blank", "noopener,noreferrer");
if (!opened) window.location.href = uri;
}
function subscribeToRelays(relays: string[], filters: Filter[]): AsyncIterable<NostrEvent | string> {
return {
[Symbol.asyncIterator]() {
const queue: Array<NostrEvent | string> = [];
let wake: (() => void) | null = null;
let closed = false;
const sub = pool.subscribeMany(relays, filters, {
onevent(event) {
queue.push(event as NostrEvent);
wake?.();
},
onclose(reasons) {
for (const reason of reasons) queue.push(reason);
closed = true;
wake?.();
},
});
return {
async next(): Promise<IteratorResult<NostrEvent | string>> {
while (!queue.length && !closed) {
await new Promise<void>((resolve) => {
wake = resolve;
});
wake = null;
}
const value = queue.shift();
if (value) return { value, done: false };
return { value: undefined, done: true };
},
async return(): Promise<IteratorResult<NostrEvent | string>> {
closed = true;
sub.close();
wake?.();
return { value: undefined, done: true };
},
};
},
};
}
async function publishToRelays(relays: string[], event: NostrEvent): Promise<void> {
const results = await Promise.allSettled(pool.publish(relays, event));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish Nostr Connect request to relay");
}
}
@@ -0,0 +1,147 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "../services/api";
import * as signer from "../services/signer";
export const useAuthStore = defineStore("auth", () => {
const stored = api.loadToken();
const npub = ref<string | null>(stored?.npub ?? null);
const token = ref<string | null>(stored?.token ?? null);
const error = ref<string | null>(null);
const busy = ref(false);
const restoringSigner = ref(false);
const isLoggedIn = computed(() => !!token.value);
const hasActiveSigner = computed(() => signer.activeSignerState.value !== null);
if (token.value) void restoreSavedSigner();
async function loginExtension(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithExtension();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
async function loginBunker(uri: string): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithBunker(uri);
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
async function loginRemoteApp(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithRemoteApp();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer login failed";
throw e;
} finally {
busy.value = false;
}
}
async function reconnectRemoteApp(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.reconnectRemoteApp();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer reconnect failed";
throw e;
} finally {
busy.value = false;
}
}
async function resumeRemoteAppLogin(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.resumeRemoteAppLogin();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer login failed";
throw e;
} finally {
busy.value = false;
}
}
function logout(): void {
api.clearToken();
signer.clearSigner();
token.value = null;
npub.value = null;
error.value = null;
}
async function restoreSavedSigner(): Promise<boolean> {
if (hasActiveSigner.value) return true;
restoringSigner.value = true;
try {
return await signer.restoreSavedSigner();
} catch (e) {
error.value = e instanceof Error ? e.message : "Could not restore signer";
return false;
} finally {
restoringSigner.value = false;
}
}
return {
npub,
token,
error,
busy,
restoringSigner,
isLoggedIn,
hasActiveSigner,
loginExtension,
loginBunker,
loginRemoteApp,
reconnectRemoteApp,
resumeRemoteAppLogin,
restoreSavedSigner,
hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin,
logout,
};
});
@@ -0,0 +1,101 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "../services/api";
import type { DatumSnapshot, HistoryPoint } from "../types";
const POLL_MS = 5000;
const HISTORY_KEY = "gashboard.history.v1";
const MAX_HISTORY_AGE_MS = 24 * 60 * 60 * 1000;
const MAX_HISTORY_POINTS = 18_000;
function loadHistory(): HistoryPoint[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as HistoryPoint[];
if (!Array.isArray(parsed)) return [];
const minT = Date.now() - MAX_HISTORY_AGE_MS;
return parsed.filter((p) => Number.isFinite(p.t) && p.t >= minT);
} catch {
return [];
}
}
function asHistoryPoint(snap: DatumSnapshot): HistoryPoint {
const miners: Record<string, number> = {};
const minerWork: Record<string, number> = {};
const minerShares: Record<string, number> = {};
const minerStatus: Record<string, DatumSnapshot["miners"][number]["status"]> = {};
for (const miner of snap.miners) {
const key = miner.authUsername || miner.remoteHost || miner.nickname;
miners[key] = miner.hashrateThs;
minerWork[key] = miner.diffAcceptedSum;
minerShares[key] = miner.diffAcceptedCount;
minerStatus[key] = miner.status;
}
return {
t: snap.fetchedAt,
ths: snap.pool.combinedHashrateThs,
accepted: snap.pool.sharesAccepted,
rejected: snap.pool.sharesRejected,
connections: snap.pool.totalConnections,
subscriptions: snap.pool.totalSubscriptions,
height: snap.job.blockHeight,
miners,
minerWork,
minerShares,
minerStatus,
};
}
export const useStatsStore = defineStore("stats", () => {
const snapshot = ref<DatumSnapshot | null>(null);
const history = ref<HistoryPoint[]>(loadHistory());
const error = ref<string | null>(null);
const loading = ref(false);
let timer: number | null = null;
let persistTimer: number | null = null;
function remember(snap: DatumSnapshot): void {
if (!snap.ok) return;
const last = history.value.at(-1);
if (last?.t === snap.fetchedAt) return;
history.value.push(asHistoryPoint(snap));
const minT = Date.now() - MAX_HISTORY_AGE_MS;
history.value = history.value.filter((p) => p.t >= minT).slice(-MAX_HISTORY_POINTS);
if (persistTimer !== null) window.clearTimeout(persistTimer);
persistTimer = window.setTimeout(() => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value));
persistTimer = null;
}, 250);
}
async function fetchOnce(): Promise<void> {
try {
loading.value = true;
const next = await api.fetchSnapshot();
snapshot.value = next;
remember(next);
error.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : "Fetch failed";
} finally {
loading.value = false;
}
}
function start(): void {
if (timer !== null) return;
void fetchOnce();
timer = window.setInterval(() => void fetchOnce(), POLL_MS);
}
function stop(): void {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
return { snapshot, history, error, loading, fetchOnce, start, stop };
});
+177
View File
@@ -0,0 +1,177 @@
import { oneIn } from "./format";
// All user-facing copy. Lean into the futility — these are 4 hobby boards
// trying to hit a 1-in-quadrillions lottery. Take the piss with affection.
export const ROASTS: Record<string, string> = {
nerdqaxe: "wired together by a guy on Telegram. somehow still hashing.",
bitaxe: "single chip, single mum, one in 10²² odds. heart of gold.",
"avalon-nano-3": "desk ornament with a fan. occasionally impersonates infrastructure.",
"avalon-mini-3": "the only one of you that pays for itself. don't get a big head.",
unknown: "we don't know what this is. we love it anyway.",
};
export const STATUS_LINES = {
hashing: "hashing",
stale: "thinking real hard",
idle: "having a lie down",
};
export const LOTTERY_LINES = [
(oddsPerDay: number) =>
`today: ${formatPct(oddsPerDay)}. A rounding error wearing a cape.`,
(oddsPerDay: number) =>
`expected wait: ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. Your descendants' descendants will still be checking the fan noise.`,
() =>
`the math says no in every language, including ones we haven't invented yet.`,
(oddsPerDay: number) =>
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. Even the sky has a better business plan.`,
() =>
`four little heaters bullying SHA-256 and calling it a retirement strategy.`,
() =>
`every share is proof of work. Not useful work. Let's not get carried away.`,
() =>
`solo mining: where hope goes to become server logs.`,
() =>
`this is not financial advice. It is barely electrical advice.`,
() =>
`the fiat system is shaking, but not because of this. This is mostly fan noise.`,
() =>
`somewhere a central banker felt nothing at all.`,
() =>
`if this finds a block, check the logs, the stars, and whoever touched the thermostat.`,
() =>
`the orange pill was supposed to be freedom, not watching a desk fan beg for destiny.`,
() =>
`your odds are non-zero, which is how all terrible ideas get funding.`,
() =>
`the network difficulty looked at this rig and filed a noise complaint.`,
() =>
`proof of work? More like proof of warm.`,
() =>
`fiat dies slowly. This block attempt dies every ten minutes.`,
() =>
`the hash is strong. The probability is in witness protection.`,
() =>
`the IMF has not yet issued a statement about your 0.000000whatever percent chance.`,
() =>
`solo mining is just buying a lottery ticket that also heats the hallway.`,
() =>
`the block reward remains technically available, like buried treasure on Mars.`,
() =>
`every rejected share is a tiny central bank meeting with worse catering.`,
() =>
`some call it sovereign mining. Others call it an elaborate way to run a heater.`,
() =>
`the machines are awake. The odds are legally dead.`,
() =>
`if math had a face, it would be avoiding eye contact.`,
() =>
`hash harder, little toaster. The empire will not dismantle itself.`,
() =>
`Bitcoin is permissionless, including permission to embarrass yourself electrically.`,
() =>
`your miners are attacking fiat with the force of a damp receipt.`,
() =>
`the conspiracy is real: the big pools have more ASICs than your shelf.`,
() =>
`this is how monetary revolution sounds when it has a 40mm fan.`,
() =>
`the Cantillon effect remains undefeated by the cupboard.`,
() =>
`the nonce search continues. So does the comedy.`,
() =>
`somewhere, Satoshi is either proud or changing the Wi-Fi password.`,
() =>
`the chance is small enough to qualify as abstract art.`,
() =>
`mining a block today would be less prediction, more paranormal incident.`,
() =>
`these rigs are not decentralizing power. They are decentralizing warmth.`,
() =>
`the mempool sees you. It is trying not to laugh.`,
() =>
`fiat printers go brrr. Your fan goes whrrr. Guess who has better margins.`,
() =>
`today's block odds have been forwarded to the Ministry of Delusion.`,
() =>
`the network hashrate is a dragon. You brought teaspoons.`,
() =>
`this is sovereign self-custody of disappointment.`,
() =>
`if a block appears, assume divine intervention or a rounding bug.`,
() =>
`monetary debasement is tragic. This rig's odds are slapstick.`,
() =>
`your ASICs are yelling at SHA-256 through a letterbox.`,
() =>
`the pools have warehouses. You have vibes and a power strip.`,
() =>
`a beautiful act of rebellion, if rebellion paid in almost nothing.`,
() =>
`the block subsidy is safe from you for now.`,
() =>
`hashing like this is how you whisper at a volcano.`,
() =>
`the odds are dark, but at least the room isn't cold.`,
() =>
`fiat friends buy heaters. You bought statistically complicated heaters.`,
() =>
`the nonce lottery has reviewed your application and placed it under a very large pile.`,
() =>
`this is not a get-rich-quick scheme. It is barely a get-warm-slowly scheme.`,
() =>
`the global banking cartel remains untroubled by this particular fan curve.`,
() =>
`somebody said "be your own bank" and now the shelf is making whale noises.`,
() =>
`the blockchain is immutable. So is the humiliation.`,
() =>
`your chance is smaller than a politician's apology and twice as expensive to heat.`,
() =>
`the rig believes. The spreadsheet has concerns.`,
() =>
`mining solo is what happens when optimism discovers electricity.`,
() =>
`the odds are so low they qualify as a privacy feature.`,
() =>
`keep stacking sats. These machines are mostly stacking anecdotes.`,
() =>
`the revolution will be decentralized, but apparently not profitable today.`,
];
export const BLOCK_CELEBRATION_LINES = [
"WAIT. WHAT. CHECK THE LOG. YOU FOUND A — actually let me check that twice.",
"no but seriously, one of these little boards just won the lottery.",
"the network has been notified. so have your friends. so has god.",
];
export const STALE_LINES = [
"hasn't submitted in a while. either thinking really hard or the breaker tripped.",
"currently meditating. has been for some time.",
"not dead, just resting. probably.",
];
function formatPct(p: number): string {
if (!isFinite(p) || p <= 0) return "ε";
if (p >= 0.01) return `${(p * 100).toFixed(4)}%`;
return oneIn(p);
}
function humanYears(years: number): string {
if (!isFinite(years)) return "forever";
if (years < 1) return `${(years * 365).toFixed(1)} days`;
if (years < 100) return `${years.toFixed(1)} years`;
if (years < 1e6) return `${(years / 1e3).toFixed(1)} thousand years`;
if (years < 1e9) return `${(years / 1e6).toFixed(1)} million years`;
return `${(years / 1e9).toFixed(1)} billion years`;
}
function ratioVsLightning(oddsPerDay: number): string {
const lightning = 0.00000028;
const r = oddsPerDay / lightning;
if (r < 0.001) return `about ${oneIn(1 / r)} as likely`;
if (r < 1) return r.toFixed(3);
if (r < 1000) return r.toFixed(1);
return `${(r / 1000).toFixed(1)}K`;
}
+179
View File
@@ -0,0 +1,179 @@
/* gashboard cyberpunk theme */
:root {
--bg-0: #07090f;
--bg-1: #0c1120;
--bg-2: #131a2e;
--line: #1f2a4a;
--line-bright: #2c3a66;
--fg-0: #e6edff;
--fg-1: #b6c3e0;
--fg-2: #6a7aa0;
--neon-cyan: #29ffe6;
--neon-magenta: #ff3df0;
--neon-amber: #ffd84a;
--neon-red: #ff4f78;
--neon-green: #6cff8c;
--shadow-cyan: 0 0 16px rgba(41, 255, 230, 0.35);
--shadow-magenta: 0 0 16px rgba(255, 61, 240, 0.4);
--mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background:
radial-gradient(900px 460px at 50% -20%, rgba(255, 79, 120, calc(var(--heat, 0) * 0.22)), transparent),
radial-gradient(1200px 600px at 20% -10%, rgba(255, 61, 240, 0.12), transparent),
radial-gradient(1000px 500px at 100% 110%, rgba(41, 255, 230, 0.10), transparent),
var(--bg-0);
color: var(--fg-0);
font-family: var(--mono);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
letter-spacing: 0.01em;
}
body.hot-hash {
--line-bright: #7a3046;
--neon-magenta: #ff4f78;
--shadow-magenta: 0 0 18px rgba(255, 79, 120, 0.48);
}
button {
font-family: var(--mono);
font-size: 13px;
background: transparent;
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 14px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: border-color 0.12s, color 0.12s, box-shadow 0.12s, background 0.12s;
}
button:hover:not(:disabled) {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
button.primary {
border-color: var(--neon-magenta);
color: var(--neon-magenta);
}
button.primary:hover:not(:disabled) {
background: rgba(255, 61, 240, 0.08);
box-shadow: var(--shadow-magenta);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input,
textarea {
font-family: var(--mono);
background: var(--bg-1);
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 10px;
width: 100%;
font-size: 13px;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
a {
color: var(--neon-cyan);
text-decoration: none;
}
.muted {
color: var(--fg-2);
}
.label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
color: var(--fg-2);
}
.panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
var(--bg-1);
border: 1px solid var(--line);
padding: 16px;
}
.glow-cyan {
color: var(--neon-cyan);
text-shadow: 0 0 8px rgba(41, 255, 230, 0.45);
}
.glow-magenta {
color: var(--neon-magenta);
text-shadow: 0 0 8px rgba(255, 61, 240, 0.5);
}
.glow-amber {
color: var(--neon-amber);
text-shadow: 0 0 8px rgba(255, 216, 74, 0.5);
}
.glow-red {
color: var(--neon-red);
text-shadow: 0 0 8px rgba(255, 79, 120, 0.5);
}
.glow-green {
color: var(--neon-green);
text-shadow: 0 0 8px rgba(108, 255, 140, 0.5);
}
/* CRT scanlines (very subtle, opt-in via body.crt class) */
body.crt::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background:
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0) 2px,
rgba(0, 0, 0, 0.18) 3px,
rgba(0, 0, 0, 0.18) 4px
);
mix-blend-mode: multiply;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.pulse {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
47%, 49% { opacity: 0.85; }
48% { opacity: 0.6; }
}
.flicker {
animation: flicker 5s steps(20) infinite;
}
+149
View File
@@ -0,0 +1,149 @@
export type MinerStat = {
authUsername: string;
remoteHost: string;
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;
diffAcceptedSum: number;
diffAcceptedCount: number;
diffRejectedSum: number;
diffRejectedCount: number;
rejectPct: number;
vdiff: number;
userAgent: string;
subscribed: boolean;
status: "hashing" | "stale" | "idle";
contributionAcceptedWork?: number;
contributionAcceptedShares?: number;
contributionPct?: number;
contributionFirstSeenAt?: number;
contributionLastSeenAt?: number;
currentlyConnected?: boolean;
};
export type PoolStat = {
combinedHashrateThs: number;
totalConnections: number;
totalSubscriptions: number;
activeThreads: number;
sharesAccepted: number;
sharesRejected: number;
uptimeSeconds: number;
connectionStatus: string;
poolHost: string;
poolTag: string;
minerTag: string;
poolDifficulty: number;
};
export type CurrentJob = {
blockHeight: number;
blockValueSats: number;
difficulty: number;
bits: string;
prevBlock: string;
target: string;
version: string;
weight: number;
size: number;
txnCount: number;
timeInfo: string;
};
export type NetworkStat = {
blockHeight: number;
hashrateEh: number;
difficulty: number;
source: string;
};
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
network: NetworkStat;
miners: MinerStat[];
error?: { code: string; message: string };
};
export type HistoryPoint = {
t: number;
ths: number;
accepted: number;
rejected: number;
connections: number;
subscriptions: number;
height: number;
miners: Record<string, number>;
minerWork: Record<string, number>;
minerShares: Record<string, number>;
minerStatus: Record<string, MinerStat["status"]>;
};
export type LoginResponse = {
token: string;
npub: string;
expiresAt: number;
};
export type ChatSnapshot = {
messages: Array<{
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
}>;
reactions: Array<{
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
}>;
keyWraps: Array<{
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
updatedAt: number;
}>;
};
export type ApiError = { error: { code: string; message: string } };
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
}): Promise<{
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
}>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
};
}
}
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { loadToken } from "../services/api";
type Member = { npub: string; role: "owner" | "viewer" };
const members = ref<Member[]>([]);
const owner = ref(false);
const loaded = ref(false);
const busy = ref(false);
const npub = ref("");
const error = ref("");
const notice = ref("");
async function request(method = "GET", key = ""): Promise<void> {
busy.value = true;
error.value = "";
notice.value = "";
try {
const token = loadToken()?.token;
const res = await fetch(`/api/access${method === "DELETE" ? `/${encodeURIComponent(key)}` : ""}`, {
method,
headers: { authorization: `Bearer ${token ?? ""}`, "content-type": "application/json" },
...(method === "POST" ? { body: JSON.stringify({ npub: key }) } : {}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error?.message ?? "Could not update access. Please sign in again.");
owner.value = data.isOwner;
members.value = data.members;
loaded.value = true;
if (method === "POST") { npub.value = ""; notice.value = "Viewer added. They can now sign in with that Nostr identity."; }
if (method === "DELETE") notice.value = "Viewer removed. Existing sessions no longer have access.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Could not load access list.";
} finally { busy.value = false; }
}
onMounted(() => { void request(); });
</script>
<template>
<section class="panel access">
<h1 class="glow-cyan">Your mining crew</h1>
<p class="muted">Choose who can see your fleet, shares, and dashboard chat.</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="notice" role="status">{{ notice }}</p>
<p v-if="!loaded && !error">Loading access list…</p>
<p v-if="loaded && !owner">Ask a node owner to add or remove people. Your account has viewer access.</p>
<template v-if="loaded && owner">
<form @submit.prevent="request('POST', npub.trim())">
<label for="viewer-npub">Invite a viewer by Nostr public key</label>
<div class="invite">
<input id="viewer-npub" v-model="npub" placeholder="npub1…" autocomplete="off" spellcheck="false" required :disabled="busy" />
<button class="primary" type="submit" :disabled="busy || !npub.trim()">Add viewer</button>
</div>
<small class="muted">Viewers can follow mining and join chat. Only node owners can change this list.</small>
</form>
<ul>
<li v-for="member in members" :key="member.npub">
<div><span class="role">{{ member.role }}</span><code>{{ member.npub }}</code></div>
<button v-if="member.role === 'viewer'" :disabled="busy" :aria-label="`Remove ${member.npub}`" @click="request('DELETE', member.npub)">Remove</button>
</li>
</ul>
<p class="muted">Owners come from Archipelago identities. Restart Gashboard after changing node identities. Invited viewers stay on this list across restarts.</p>
</template>
</section>
</template>
<style scoped>
.access { max-width: 880px; margin: 0 auto; }
h1 { margin-top: 0; }
form { margin: 28px 0; }
label { display: block; margin-bottom: 12px; }
.invite { display: flex; gap: 12px; margin-bottom: 12px; }
input { flex: 1; min-width: 0; }
ul { padding: 0; list-style: none; }
li { display: flex; align-items: center; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--line); }
li > div { flex: 1; min-width: 0; }
code { display: block; overflow-wrap: anywhere; font-size: 12px; margin-top: 8px; }
.role { color: var(--neon-cyan); text-transform: uppercase; }
[role="alert"] { color: var(--neon-red); }
[role="status"] { color: var(--neon-cyan); }
@media (max-width: 520px) { .invite { flex-direction: column; } }
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { useStatsStore } from "../stores/stats";
import MinerCard from "../components/MinerCard.vue";
import PoolHero from "../components/PoolHero.vue";
import LotteryWidget from "../components/LotteryWidget.vue";
import ShareTicker from "../components/ShareTicker.vue";
import BlockCelebration from "../components/BlockCelebration.vue";
import Shameboard from "../components/Shameboard.vue";
const stats = useStatsStore();
onMounted(() => stats.start());
onUnmounted(() => stats.stop());
const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => stats.snapshot?.network.hashrateEh ?? 0);
const miners = computed(() => stats.snapshot?.miners ?? []);
const errorMsg = computed(() => stats.error);
const upstreamErr = computed(() => stats.snapshot?.error);
const bestWorkByMiner = computed(() => {
const out: Record<string, number> = {};
for (let i = 1; i < stats.history.length; i += 1) {
const prev = stats.history[i - 1];
const next = stats.history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
for (const [key, work] of Object.entries(next.minerWork)) {
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = work - (prev.minerWork[key] ?? work);
if (shareDelta <= 0 || workDelta <= 0) continue;
out[key] = Math.max(out[key] ?? 0, workDelta / shareDelta);
}
}
return out;
});
const poolBestWork = computed(() => Math.max(0, ...Object.values(bestWorkByMiner.value)));
const contributionRows = computed(() => {
return miners.value
.filter((m) => (m.contributionAcceptedWork ?? m.diffAcceptedSum) > 0)
.map((m) => ({
key: minerKey(m),
name: m.nickname,
work: m.contributionAcceptedWork ?? m.diffAcceptedSum,
pct: m.contributionPct ?? 0,
connected: m.currentlyConnected ?? true,
}))
.sort((a, b) => b.work - a.work);
});
const boomerHeater = {
authUsername: "fiat.heat",
remoteHost: "wall-socket",
nickname: "Boomer Heater",
model: "Legacy Resistance Heater",
location: "The Past",
expectedHashrateThs: 0,
watts: 2000,
hashrateThs: 0,
hashrateAgeS: null,
lastShareAgeS: null,
diffAcceptedSum: 0,
diffAcceptedCount: 0,
diffRejectedSum: 0,
diffRejectedCount: 0,
rejectPct: 0,
vdiff: 0,
userAgent: "CentralHeating/fiat-only",
subscribed: false,
status: "idle",
} as const;
function minerKey(m: { nickname: string; authUsername: string; remoteHost: string }): string {
return m.authUsername || m.remoteHost || m.nickname;
}
</script>
<template>
<BlockCelebration :snapshot="stats.snapshot" />
<div v-if="errorMsg" class="banner err">
✗ {{ errorMsg }}
</div>
<div v-else-if="upstreamErr" class="banner warn">
⚠ datum: {{ upstreamErr.code }} — {{ upstreamErr.message }}
</div>
<PoolHero :snapshot="stats.snapshot" />
<section class="best panel">
<div>
<div class="label">best accepted calculation observed</div>
<strong class="glow-amber">
{{ poolBestWork > 0 ? poolBestWork.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "collecting" }}
</strong>
</div>
<span class="muted">derived from accepted difficulty jumps while this browser has history</span>
</section>
<section class="split panel">
<div class="split-head">
<div>
<div class="label">block reward split</div>
<strong class="glow-green">accepted-work ledger</strong>
</div>
<span class="muted">persisted after this deploy</span>
</div>
<div v-if="contributionRows.length" class="split-rows">
<div v-for="row in contributionRows" :key="row.key" class="split-row">
<span>
{{ row.name }}
<small v-if="!row.connected" class="muted">offline</small>
</span>
<strong>{{ row.pct.toFixed(2) }}%</strong>
<em class="muted">{{ row.work.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</em>
</div>
</div>
<div v-else class="muted">waiting for accepted work</div>
</section>
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
<ShareTicker :snapshot="stats.snapshot" />
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
<div class="miners">
<MinerCard
v-for="m in miners"
:key="m.authUsername || m.nickname"
:miner="m"
:best-work="bestWorkByMiner[minerKey(m)]"
/>
<MinerCard
key="boomer-heater"
:miner="boomerHeater"
:best-work="0"
/>
<div v-if="!miners.length && !errorMsg" class="empty panel muted">
waiting for the first poll · the boards are warming up
</div>
</div>
</template>
<style scoped>
.banner {
padding: 10px 14px;
margin-bottom: 16px;
font-size: 12px;
border: 1px solid var(--line);
}
.banner.err { border-color: var(--neon-red); color: var(--neon-red); }
.banner.warn { border-color: var(--neon-amber); color: var(--neon-amber); }
.grid-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
margin: 16px 0;
}
.best {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 16px;
min-width: 0;
}
.best strong {
display: block;
margin-top: 4px;
font-size: 24px;
}
.best span {
font-size: 11px;
text-align: right;
overflow-wrap: anywhere;
}
.split {
margin-top: 16px;
}
.split-head,
.split-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 14px;
align-items: center;
}
.split-head {
margin-bottom: 10px;
}
.split-rows {
display: grid;
gap: 8px;
}
.split-row {
padding-top: 8px;
border-top: 1px solid var(--line);
font-size: 13px;
}
.split-row small {
margin-left: 8px;
font-size: 10px;
text-transform: uppercase;
}
.split-row em {
font-style: normal;
font-size: 11px;
}
.miners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.empty {
padding: 40px;
text-align: center;
font-style: italic;
}
@media (max-width: 800px) {
.grid-row { grid-template-columns: 1fr; }
.best {
align-items: flex-start;
flex-direction: column;
}
.best span {
text-align: left;
}
.split-head,
.split-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.split-head > span,
.split-row em {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useStatsStore } from "../stores/stats";
import type { HistoryPoint } from "../types";
import Shameboard from "../components/Shameboard.vue";
import MinerRace from "../components/MinerRace.vue";
const stats = useStatsStore();
onMounted(() => stats.start());
onUnmounted(() => stats.stop());
const ranges = [
{ label: "15m", ms: 15 * 60 * 1000 },
{ label: "1h", ms: 60 * 60 * 1000 },
{ label: "6h", ms: 6 * 60 * 60 * 1000 },
{ label: "24h", ms: 24 * 60 * 60 * 1000 },
] as const;
const selectedRange = ref<(typeof ranges)[number]["label"]>("1h");
const activeRange = computed(() => ranges.find((r) => r.label === selectedRange.value) ?? ranges[1]);
const now = computed(() => stats.snapshot?.fetchedAt ?? Date.now());
const points = computed(() => {
const minT = now.value - activeRange.value.ms;
const filtered = stats.history.filter((p) => p.t >= minT);
return filtered.length > 0 ? filtered : stats.history;
});
const current = computed(() => stats.snapshot);
const currentThs = computed(() => current.value?.pool.combinedHashrateThs ?? 0);
const activeMiners = computed(() => current.value?.miners.filter((m) => m.status === "hashing").length ?? 0);
const windowLabel = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last) return "collecting";
const minutes = Math.max(0, Math.round((last.t - first.t) / 60000));
if (minutes < 60) return `${minutes}m captured`;
return `${(minutes / 60).toFixed(1)}h captured`;
});
const deltas = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last) return { accepted: 0, rejected: 0, total: 0, rejectPct: 0 };
const accepted = Math.max(0, last.accepted - first.accepted);
const rejected = Math.max(0, last.rejected - first.rejected);
const total = accepted + rejected;
return { accepted, rejected, total, rejectPct: total > 0 ? (rejected / total) * 100 : 0 };
});
const avgThs = computed(() => {
if (!points.value.length) return 0;
return points.value.reduce((sum, p) => sum + p.ths, 0) / points.value.length;
});
const highLow = computed(() => {
if (!points.value.length) return { high: 0, low: 0 };
return {
high: Math.max(...points.value.map((p) => p.ths)),
low: Math.min(...points.value.map((p) => p.ths)),
};
});
const shareRate = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last || last.t <= first.t) return 0;
return deltas.value.accepted / ((last.t - first.t) / 3600000);
});
const hashratePath = computed(() => linePath(points.value.map((p) => ({ x: p.t, y: p.ths })), 460, 190));
const shareBars = computed(() => buildShareBuckets(points.value, activeRange.value.ms));
const minerRows = computed(() => {
const latest = current.value?.miners ?? [];
return latest
.map((m) => {
const key = m.authUsername || m.remoteHost || m.nickname;
const average = averageMinerHashrate(points.value, key);
const bestWork = bestObservedWork(stats.history, key);
const pct = currentThs.value > 0 ? (m.hashrateThs / currentThs.value) * 100 : 0;
return { key, name: m.nickname, model: m.model, current: m.hashrateThs, average, bestWork, pct, status: m.status };
})
.sort((a, b) => b.current - a.current);
});
function linePath(items: { x: number; y: number }[], width: number, height: number): string {
if (items.length < 2) return "";
const first = items[0];
if (!first) return "";
const minX = first.x;
const maxX = items.at(-1)?.x ?? minX + 1;
const minY = Math.min(...items.map((i) => i.y), 0);
const maxY = Math.max(...items.map((i) => i.y), 1);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(0.001, maxY - minY);
return items
.map((item, idx) => {
const x = ((item.x - minX) / spanX) * width;
const y = height - ((item.y - minY) / spanY) * height;
return `${idx === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
function buildShareBuckets(history: HistoryPoint[], rangeMs: number): { x: number; accepted: number; rejected: number; h: number }[] {
if (history.length < 2) return [];
const bucketCount = rangeMs <= 60 * 60 * 1000 ? 30 : 48;
const first = history[0];
if (!first) return [];
const start = first.t;
const end = history.at(-1)?.t ?? start + 1;
const span = Math.max(1, end - start);
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ x: i, accepted: 0, rejected: 0, h: 0 }));
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev || !next) continue;
const idx = Math.min(bucketCount - 1, Math.max(0, Math.floor(((next.t - start) / span) * bucketCount)));
const bucket = buckets[idx];
if (!bucket) continue;
bucket.accepted += Math.max(0, next.accepted - prev.accepted);
bucket.rejected += Math.max(0, next.rejected - prev.rejected);
}
const max = Math.max(...buckets.map((b) => b.accepted + b.rejected), 1);
return buckets.map((b) => ({ ...b, h: ((b.accepted + b.rejected) / max) * 72 }));
}
function averageMinerHashrate(history: HistoryPoint[], key: string): number {
const values = history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
if (!values.length) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function bestObservedWork(history: HistoryPoint[], key: string): number {
let best = 0;
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = (next.minerWork[key] ?? 0) - (prev.minerWork[key] ?? 0);
if (shareDelta > 0 && workDelta > 0) best = Math.max(best, workDelta / shareDelta);
}
return best;
}
function fmt(n: number, digits = 2): string {
return n.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits });
}
</script>
<template>
<section class="graph-shell">
<header class="graph-head">
<div>
<div class="label">graph view</div>
<h1 class="glow-cyan">Hashrate telemetry</h1>
</div>
<div class="range-tabs">
<button
v-for="range in ranges"
:key="range.label"
:class="{ active: selectedRange === range.label }"
@click="selectedRange = range.label"
>
{{ range.label }}
</button>
</div>
</header>
<MinerRace :snapshot="stats.snapshot" :history="stats.history" />
<div class="metric-grid">
<div class="metric panel">
<div class="label">now</div>
<strong class="glow-cyan">{{ fmt(currentThs) }}</strong>
<span>Th/s</span>
</div>
<div class="metric panel">
<div class="label">window avg</div>
<strong>{{ fmt(avgThs) }}</strong>
<span>Th/s · {{ windowLabel }}</span>
</div>
<div class="metric panel">
<div class="label">accepted rate</div>
<strong class="glow-green">{{ fmt(shareRate, 1) }}</strong>
<span>shares/hour</span>
</div>
<div class="metric panel">
<div class="label">rejects</div>
<strong :class="deltas.rejectPct > 3 ? 'glow-red' : ''">{{ fmt(deltas.rejectPct, 2) }}%</strong>
<span>{{ deltas.rejected.toLocaleString() }} / {{ deltas.total.toLocaleString() }}</span>
</div>
<div class="metric panel">
<div class="label">active miners</div>
<strong>{{ activeMiners }}</strong>
<span>{{ current?.pool.totalSubscriptions ?? 0 }} subscriptions</span>
</div>
</div>
<div class="chart-grid">
<section class="panel chart-card wide">
<div class="chart-title">
<div>
<div class="label">combined hashrate</div>
<strong>{{ fmt(highLow.low) }} → {{ fmt(highLow.high) }} Th/s</strong>
</div>
<span class="muted">{{ points.length.toLocaleString() }} samples</span>
</div>
<svg class="line-chart" viewBox="0 0 460 190" preserveAspectRatio="none">
<defs>
<linearGradient id="hashFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--neon-cyan)" stop-opacity="0.22" />
<stop offset="100%" stop-color="var(--neon-cyan)" stop-opacity="0" />
</linearGradient>
</defs>
<path v-if="hashratePath" :d="`${hashratePath} L460,190 L0,190 Z`" fill="url(#hashFill)" />
<path v-if="hashratePath" :d="hashratePath" class="hash-line" />
<text v-else x="230" y="96" text-anchor="middle">collecting samples</text>
</svg>
</section>
<section class="panel chart-card">
<div class="chart-title">
<div>
<div class="label">share flow</div>
<strong>{{ deltas.accepted.toLocaleString() }} accepted</strong>
</div>
<span class="muted">{{ deltas.rejected.toLocaleString() }} rejected</span>
</div>
<div class="bars">
<div v-for="bar in shareBars" :key="bar.x" class="bar-track">
<span class="bar accepted" :style="{ height: `${bar.h}px` }" />
<span
v-if="bar.rejected"
class="bar rejected"
:style="{ height: `${Math.max(4, (bar.rejected / Math.max(1, bar.accepted + bar.rejected)) * bar.h)}px` }"
/>
</div>
</div>
</section>
<section class="panel chart-card">
<div class="chart-title">
<div>
<div class="label">miner mix</div>
<strong>{{ minerRows.length }} devices</strong>
</div>
</div>
<div class="miner-mix">
<div v-for="miner in minerRows" :key="miner.key" class="miner-row">
<div class="miner-meta">
<span :class="['status-dot', miner.status]" />
<strong>{{ miner.name }}</strong>
<span class="muted">{{ fmt(miner.current) }} Th/s</span>
</div>
<div class="mix-track">
<span :style="{ width: `${Math.min(100, miner.pct)}%` }" />
</div>
<div class="muted avg">
avg {{ fmt(miner.average) }} Th/s · best {{ miner.bestWork > 0 ? fmt(miner.bestWork) : "collecting" }}
</div>
</div>
</div>
</section>
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
</section>
</template>
<style scoped>
.graph-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.graph-head,
.chart-title,
.miner-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
h1 {
margin: 2px 0 0;
font-size: 28px;
letter-spacing: 0;
}
.range-tabs {
display: flex;
gap: 8px;
}
.range-tabs button {
min-width: 52px;
padding: 7px 10px;
}
.range-tabs button.active {
border-color: var(--neon-magenta);
color: var(--neon-magenta);
box-shadow: var(--shadow-magenta);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.metric {
min-height: 104px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 24px;
line-height: 1.1;
}
.metric span {
display: block;
margin-top: 6px;
color: var(--fg-2);
font-size: 11px;
}
.chart-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
.chart-card {
min-height: 260px;
overflow: hidden;
}
.chart-card.wide {
grid-column: 1 / -1;
}
.line-chart {
width: 100%;
height: 300px;
margin-top: 12px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(to bottom, transparent 24%, rgba(255,255,255,0.04) 25%, transparent 26%),
linear-gradient(to bottom, transparent 49%, rgba(255,255,255,0.04) 50%, transparent 51%),
linear-gradient(to bottom, transparent 74%, rgba(255,255,255,0.04) 75%, transparent 76%);
}
.hash-line {
fill: none;
stroke: var(--neon-cyan);
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 5px rgba(41, 255, 230, 0.45));
}
.line-chart text {
fill: var(--fg-2);
font-size: 12px;
}
.bars {
height: 170px;
display: grid;
grid-template-columns: repeat(48, 1fr);
gap: 3px;
align-items: end;
margin-top: 34px;
border-bottom: 1px solid var(--line);
}
.bar-track {
height: 90px;
position: relative;
display: flex;
align-items: end;
}
.bar {
width: 100%;
min-height: 2px;
display: block;
}
.bar.accepted {
background: var(--neon-green);
box-shadow: 0 0 8px rgba(108, 255, 140, 0.25);
}
.bar.rejected {
position: absolute;
bottom: 0;
background: var(--neon-red);
}
.miner-mix {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 18px;
}
.miner-row {
display: grid;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-2);
box-shadow: 0 0 8px currentColor;
}
.status-dot.hashing { background: var(--neon-green); }
.status-dot.stale { background: var(--neon-amber); }
.status-dot.idle { background: var(--neon-red); }
.mix-track {
height: 8px;
background: var(--bg-2);
border: 1px solid var(--line);
}
.mix-track span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--neon-magenta), var(--neon-cyan));
}
.avg {
font-size: 10px;
}
@media (max-width: 1000px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.chart-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.graph-head {
align-items: flex-start;
flex-direction: column;
}
.range-tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.range-tabs button {
min-width: 0;
}
.metric-grid {
grid-template-columns: 1fr;
}
.line-chart {
height: 220px;
}
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
const waitingRemote = ref(false);
async function loginExt(): Promise<void> {
try {
await auth.loginExtension();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
async function loginBunker(): Promise<void> {
if (!bunkerUri.value.trim().startsWith("bunker://")) {
auth.error = 'URI must start with "bunker://"';
return;
}
try {
await auth.loginBunker(bunkerUri.value.trim());
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
async function loginRemoteApp(): Promise<void> {
waitingRemote.value = true;
try {
await auth.loginRemoteApp();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} finally {
waitingRemote.value = false;
}
}
async function resumeRemoteApp(): Promise<void> {
if (!auth.hasPendingRemoteAppLogin()) return;
waitingRemote.value = true;
try {
await auth.resumeRemoteAppLogin();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} finally {
waitingRemote.value = false;
}
}
onMounted(() => {
if (route.query.remote === "return") void resumeRemoteApp();
});
</script>
<template>
<div class="login">
<div class="panel">
<div class="header">
<div class="title glow-cyan">// auth_required</div>
<div class="subtitle muted">
gashboard is a closed dashboard. only allow-listed npubs may pass.
</div>
</div>
<div class="options">
<button class="primary big" :disabled="auth.busy" @click="loginExt">
{{ auth.busy ? "..." : "sign in with Nostr" }}
<small>Archipelago identity or browser extension</small>
</button>
<button
class="primary big"
:disabled="auth.busy"
@click="loginRemoteApp"
>
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
<small>Primal, Amber, or any Nostr Connect signer</small>
</button>
<button
class="big"
:disabled="auth.busy"
@click="showBunkerInput = !showBunkerInput"
>
bunker URI
<small>advanced fallback · nsecbunker</small>
</button>
<div v-if="showBunkerInput" class="bunker">
<label class="label">paste bunker URI</label>
<input
v-model="bunkerUri"
placeholder="bunker://<pubkey>?relay=wss://…&secret=…"
spellcheck="false"
autocomplete="off"
/>
<button class="primary" :disabled="auth.busy" @click="loginBunker">
{{ auth.busy ? "connecting…" : "connect" }}
</button>
</div>
</div>
<div v-if="auth.error" class="err">
✗ {{ auth.error }}
</div>
<div class="foot muted">
choose your signer, select your identity, and approve the sign-in request.
</div>
</div>
</div>
</template>
<style scoped>
.login {
width: min(540px, 92vw);
}
.header { margin-bottom: 24px; }
.title {
font-size: 22px;
letter-spacing: 0.18em;
margin-bottom: 6px;
font-weight: 700;
}
.subtitle { font-size: 12px; }
.options { display: flex; flex-direction: column; gap: 14px; }
button.big {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px 16px;
font-size: 14px;
}
button.big small {
font-size: 10.5px;
font-weight: 400;
text-transform: none;
color: var(--fg-2);
letter-spacing: 0.04em;
}
.bunker {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line-bright);
background: rgba(0, 0, 0, 0.25);
}
.err {
margin-top: 18px;
padding: 10px 12px;
border: 1px solid var(--neon-red);
color: var(--neon-red);
font-size: 12px;
}
.foot {
margin-top: 24px;
font-size: 11px;
letter-spacing: 0.04em;
}
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
onMounted(async () => {
if (auth.hasPendingRemoteAppLogin()) {
try {
await auth.resumeRemoteAppLogin();
} catch {
/* surfaced on the login screen if resume fails */
}
void router.replace({ name: auth.isLoggedIn ? "dashboard" : "login", query: auth.isLoggedIn ? {} : { remote: "return" } });
return;
}
if (auth.isLoggedIn) {
void router.replace({ name: "dashboard" });
return;
}
void router.replace({ name: "login", query: { remote: "return" } });
});
</script>
<template>
<div class="callback panel">
<div class="title glow-cyan">// signer_return</div>
<p class="muted">Finishing remote signer login...</p>
</div>
</template>
<style scoped>
.callback {
width: min(460px, 92vw);
text-align: center;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.16em;
margin-bottom: 8px;
}
p {
margin: 0;
font-size: 12px;
}
</style>