feat(web): cyberpunk vue 3 dashboard with primal/amber/extension login
Frontend in @gashboard/web (Vue 3 + Vite + Pinia + TS, ~1.2k LoC):
Auth flow:
- Two signing paths: NIP-07 browser extension (Alby/nos2x/Primal
extension via window.nostr) and NIP-46 remote signer (Primal
app, Amber, nsecbunker via bunker:// URI).
- applesauce-signers lazy-loaded only on bunker login so users
with NIP-07 don't pay the cost.
- NIP-98 event built client-side, posted to /api/auth/login,
JWT persisted in localStorage. Pinia auth store handles
login/logout/state restore on reload.
Dashboard (composes the live /api/datum/stats poll, 5s):
- PoolHero — combined hashrate as the headline number,
block height, subscribed count, accepted/rejected shares.
- LotteryWidget — rotating self-deprecating odds copy
("you're 0.3× as likely to find a block as get hit by
lightning today"). Uses ~720 EH/s as the network-hashrate
constant (TODO: fetch live).
- ShareTicker — SVG sparkline of the last 60 polls.
- MinerCard ×N — nickname (QU4CK/P1XEL/N4N0/M1N1), live
hashrate, last share, lifetime tickets, reject %, status
glow (green hashing / amber stale / red idle), affectionate
roast subtitle per ASIC type.
- BlockCelebration — full-screen overlay with celebration
copy. Dormant for now (Datum's lastBlockFoundAt isn't
surfaced yet); preview via window.gashboardCelebrate().
Cyberpunk theme:
- Pure CSS vars, no Tailwind. Dark bg, neon cyan/magenta
accents, monospace, glow shadows.
- Optional CRT scanlines toggle (persists to localStorage).
- Mobile-aware grid breakpoints.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
101
apps/web/src/App.vue
Normal file
101
apps/web/src/App.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const crt = ref(false);
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="brand glow-magenta flicker">
|
||||
⌬ GASHBOARD
|
||||
<span class="muted">// solo lottery dashboard</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
|
||||
<button class="thin" @click="auth.logout()">logout</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
</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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
78
apps/web/src/components/BlockCelebration.vue
Normal file
78
apps/web/src/components/BlockCelebration.vue
Normal file
@@ -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>
|
||||
91
apps/web/src/components/LotteryWidget.vue
Normal file
91
apps/web/src/components/LotteryWidget.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { LOTTERY_LINES } from "../strings";
|
||||
|
||||
const props = defineProps<{ totalThs: number }>();
|
||||
|
||||
// As of mid-2026, network hashrate is roughly ~720 EH/s. Hardcoded estimate;
|
||||
// good enough for self-deprecating jokes about cosmic odds.
|
||||
const NETWORK_EH_S = 720;
|
||||
const SECONDS_PER_BLOCK = 600;
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
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 / (NETWORK_EH_S * 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;
|
||||
}, 7000);
|
||||
});
|
||||
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="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;
|
||||
}
|
||||
.line {
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
min-height: 32px;
|
||||
}
|
||||
.prefix { color: var(--neon-amber); font-weight: 700; }
|
||||
.text { line-height: 1.5; }
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.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>
|
||||
197
apps/web/src/components/MinerCard.vue
Normal file
197
apps/web/src/components/MinerCard.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<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 }>();
|
||||
|
||||
const slug = computed(() => {
|
||||
// Map nickname → roast key
|
||||
const map: Record<string, string> = {
|
||||
QU4CK: "nerdqaxe",
|
||||
P1XEL: "bitaxe",
|
||||
N4N0: "avalon-nano-3",
|
||||
M1N1: "avalon-mini-3",
|
||||
};
|
||||
return map[props.miner.nickname] ?? "unknown";
|
||||
});
|
||||
|
||||
const roast = computed(() => ROASTS[slug.value] ?? ROASTS["unknown"]);
|
||||
|
||||
const statusLabel = computed(() => STATUS_LINES[props.miner.status]);
|
||||
const statusGlow = computed(() => {
|
||||
switch (props.miner.status) {
|
||||
case "hashing": return "glow-green";
|
||||
case "stale": return "glow-amber";
|
||||
case "idle": return "glow-red";
|
||||
}
|
||||
});
|
||||
|
||||
const lastShareLabel = computed(() => {
|
||||
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 bestShare = computed(() => formatBig(props.miner.diffAcceptedSum));
|
||||
|
||||
const staleNote = computed(() => {
|
||||
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 class="dot pulse" />
|
||||
{{ statusLabel }}
|
||||
</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">
|
||||
{{ 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">{{ lifetimeShares.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">total work</div>
|
||||
<div class="value">{{ bestShare }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">reject %</div>
|
||||
<div :class="['value', miner.rejectPct > 5 ? 'glow-red' : '']">
|
||||
{{ miner.rejectPct.toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">vdiff</div>
|
||||
<div class="value">{{ miner.vdiff.toLocaleString() }}</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'" 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;
|
||||
}
|
||||
.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>
|
||||
85
apps/web/src/components/PoolHero.vue
Normal file
85
apps/web/src/components/PoolHero.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DatumSnapshot } from "../types";
|
||||
|
||||
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
|
||||
|
||||
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
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 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">total hashrate · all 4 little soldiers</div>
|
||||
<div class="number glow-cyan">
|
||||
{{ totalThs.toFixed(2) }}
|
||||
<span class="unit">Th/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="cell">
|
||||
<div class="label">block height</div>
|
||||
<div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</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);
|
||||
}
|
||||
.right {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.cell .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.right { grid-template-columns: repeat(2, 1fr); }
|
||||
.left .number { font-size: 44px; }
|
||||
}
|
||||
</style>
|
||||
50
apps/web/src/components/ShareTicker.vue
Normal file
50
apps/web/src/components/ShareTicker.vue
Normal file
@@ -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>
|
||||
10
apps/web/src/main.ts
Normal file
10
apps/web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
import "./style.css";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
28
apps/web/src/router.ts
Normal file
28
apps/web/src/router.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "dashboard",
|
||||
component: () => import("./views/DashboardView.vue"),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("./views/LoginView.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;
|
||||
});
|
||||
66
apps/web/src/services/api.ts
Normal file
66
apps/web/src/services/api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ApiError, 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;
|
||||
}
|
||||
|
||||
export async function fetchSnapshot(): Promise<DatumSnapshot> {
|
||||
return authedGet<DatumSnapshot>("/api/datum/stats");
|
||||
}
|
||||
21
apps/web/src/services/nip98.ts
Normal file
21
apps/web/src/services/nip98.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
68
apps/web/src/services/signer.ts
Normal file
68
apps/web/src/services/signer.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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";
|
||||
|
||||
export type SignerKind = "extension" | "bunker";
|
||||
|
||||
export type Signer = {
|
||||
kind: SignerKind;
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
||||
};
|
||||
|
||||
let activeSigner: Signer | null = null;
|
||||
|
||||
export function getActiveSigner(): Signer | null {
|
||||
return activeSigner;
|
||||
}
|
||||
|
||||
export function clearSigner(): void {
|
||||
activeSigner = null;
|
||||
}
|
||||
|
||||
export async function loginWithExtension(): Promise<string> {
|
||||
if (!window.nostr) {
|
||||
throw new Error("No NIP-07 extension found. Try Alby, nos2x, or Primal extension.");
|
||||
}
|
||||
const ext = window.nostr;
|
||||
const pubkey = await ext.getPublicKey();
|
||||
activeSigner = {
|
||||
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,
|
||||
};
|
||||
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();
|
||||
activeSigner = {
|
||||
kind: "bunker",
|
||||
getPublicKey: async () => pubkey,
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
};
|
||||
return pubkey;
|
||||
}
|
||||
60
apps/web/src/stores/auth.ts
Normal file
60
apps/web/src/stores/auth.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
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 isLoggedIn = computed(() => !!token.value);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
api.clearToken();
|
||||
signer.clearSigner();
|
||||
token.value = null;
|
||||
npub.value = null;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, logout };
|
||||
});
|
||||
41
apps/web/src/stores/stats.ts
Normal file
41
apps/web/src/stores/stats.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "../services/api";
|
||||
import type { DatumSnapshot } from "../types";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
export const useStatsStore = defineStore("stats", () => {
|
||||
const snapshot = ref<DatumSnapshot | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
let timer: number | null = null;
|
||||
|
||||
async function fetchOnce(): Promise<void> {
|
||||
try {
|
||||
loading.value = true;
|
||||
const next = await api.fetchSnapshot();
|
||||
snapshot.value = 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, error, loading, fetchOnce, start, stop };
|
||||
});
|
||||
67
apps/web/src/strings.ts
Normal file
67
apps/web/src/strings.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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": "USB-stick miner. AliExpress's revenge on the network hashrate.",
|
||||
"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) =>
|
||||
`P(block today): ${formatPct(oddsPerDay)}. P(struck by lightning today): 0.000028%. hash on, brave little board.`,
|
||||
(oddsPerDay: number) =>
|
||||
`at this hashrate, a block is expected once every ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. compound those vibes.`,
|
||||
() =>
|
||||
`lifetime tickets purchased: many. winners: 0. hope: priceless.`,
|
||||
(oddsPerDay: number) =>
|
||||
`you're ${ratioVsLightning(oddsPerDay)}× as likely to find a block as get hit by lightning today. almost too easy.`,
|
||||
() =>
|
||||
`the network does not know you exist. and yet, you persist.`,
|
||||
() =>
|
||||
`every share is a ticket bought. nobody is buying with more conviction.`,
|
||||
];
|
||||
|
||||
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 `${(p * 100).toExponential(2)}%`;
|
||||
}
|
||||
|
||||
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 r.toExponential(1);
|
||||
if (r < 1) return r.toFixed(3);
|
||||
if (r < 1000) return r.toFixed(1);
|
||||
return r.toExponential(1);
|
||||
}
|
||||
172
apps/web/src/style.css
Normal file
172
apps/web/src/style.css
Normal file
@@ -0,0 +1,172 @@
|
||||
/* 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(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
87
apps/web/src/types.ts
Normal file
87
apps/web/src/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
export type MinerStat = {
|
||||
authUsername: string;
|
||||
remoteHost: string;
|
||||
nickname: string;
|
||||
model: string;
|
||||
location: string;
|
||||
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";
|
||||
};
|
||||
|
||||
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 DatumSnapshot = {
|
||||
ok: boolean;
|
||||
fetchedAt: number;
|
||||
pool: PoolStat;
|
||||
job: CurrentJob;
|
||||
miners: MinerStat[];
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
npub: string;
|
||||
expiresAt: 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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
74
apps/web/src/views/DashboardView.vue
Normal file
74
apps/web/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<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";
|
||||
|
||||
const stats = useStatsStore();
|
||||
|
||||
onMounted(() => stats.start());
|
||||
onUnmounted(() => stats.stop());
|
||||
|
||||
const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0);
|
||||
const miners = computed(() => stats.snapshot?.miners ?? []);
|
||||
const errorMsg = computed(() => stats.error);
|
||||
const upstreamErr = computed(() => stats.snapshot?.error);
|
||||
</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" />
|
||||
|
||||
<div class="grid-row">
|
||||
<LotteryWidget :total-ths="totalThs" />
|
||||
<ShareTicker :snapshot="stats.snapshot" />
|
||||
</div>
|
||||
|
||||
<div class="miners">
|
||||
<MinerCard v-for="m in miners" :key="m.authUsername || m.nickname" :miner="m" />
|
||||
<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: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
134
apps/web/src/views/LoginView.vue
Normal file
134
apps/web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const bunkerUri = ref("");
|
||||
const showBunkerInput = 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 */
|
||||
}
|
||||
}
|
||||
</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 ? "..." : "browser extension" }}
|
||||
<small>NIP-07 · alby, nos2x, primal</small>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="big"
|
||||
:disabled="auth.busy"
|
||||
@click="showBunkerInput = !showBunkerInput"
|
||||
>
|
||||
remote signer
|
||||
<small>NIP-46 · primal app, amber, 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">
|
||||
no extension? install
|
||||
<a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noopener">alby</a>,
|
||||
or use a remote signer.
|
||||
</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>
|
||||
Reference in New Issue
Block a user