Add PWA support and miner race
@@ -28,6 +28,8 @@ export function buildApp() {
|
||||
"img-src": ["'self'", "data:"],
|
||||
"connect-src": ["'self'", "wss://relay.primal.net"],
|
||||
"font-src": ["'self'", "data:"],
|
||||
"manifest-src": ["'self'"],
|
||||
"worker-src": ["'self'"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
"upgrade-insecure-requests": null,
|
||||
},
|
||||
@@ -67,10 +69,21 @@ export function buildApp() {
|
||||
|
||||
if (staticDir && fs.existsSync(staticDir)) {
|
||||
logger.info({ staticDir }, "serving web assets");
|
||||
app.use(express.static(staticDir, { index: false, maxAge: "1h" }));
|
||||
app.use(
|
||||
express.static(staticDir, {
|
||||
index: false,
|
||||
maxAge: "1h",
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith("sw.js") || filePath.endsWith("manifest.webmanifest")) {
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.get(/.*/, (_req, res, next) => {
|
||||
const indexFile = path.join(staticDir, "index.html");
|
||||
if (!fs.existsSync(indexFile)) return next();
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.sendFile(indexFile);
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0e1a" />
|
||||
<meta name="application-name" content="gashboard" />
|
||||
<meta name="apple-mobile-web-app-title" content="gashboard" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
<title>gashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
7
apps/web/public/icons/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
|
||||
<rect width="512" height="512" fill="#07090f"/>
|
||||
<rect x="34" y="34" width="444" height="444" fill="#0c1120" stroke="#29ffe6" stroke-width="12"/>
|
||||
<path d="M98 314h68v74H98zm92-118h68v192h-68zm92 54h68v138h-68zm92-126h68v264h-68z" fill="#ff3df0"/>
|
||||
<path d="M80 256h64l34-82 62 178 58-132 44 70h90" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<text x="256" y="104" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="54" font-weight="700">G</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 630 B |
7
apps/web/public/icons/maskable.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
|
||||
<rect width="512" height="512" fill="#07090f"/>
|
||||
<circle cx="256" cy="256" r="188" fill="#0c1120" stroke="#29ffe6" stroke-width="14"/>
|
||||
<path d="M126 316h62v72h-62zm84-118h62v190h-62zm84 52h62v138h-62zm84-126h62v264h-62z" fill="#ff3df0"/>
|
||||
<path d="M104 258h66l32-82 60 176 58-132 44 70h54" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
<text x="256" y="112" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="52" font-weight="700">G</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 622 B |
34
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "gashboard",
|
||||
"short_name": "gashboard",
|
||||
"description": "Solo mining dashboard for Datum miners.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#07090f",
|
||||
"theme_color": "#0a0e1a",
|
||||
"categories": ["utilities", "finance"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Graphs",
|
||||
"short_name": "Graphs",
|
||||
"url": "/graphs",
|
||||
"description": "Open telemetry graphs"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
apps/web/public/miners/avalon-mini-3.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 220" role="img" aria-label="Avalon Mini 3 miner">
|
||||
<rect width="420" height="220" fill="none"/>
|
||||
<g filter="url(#g)">
|
||||
<rect x="44" y="56" width="332" height="110" rx="16" fill="#1f2937" stroke="#ff3df0" stroke-width="4"/>
|
||||
<rect x="70" y="78" width="210" height="66" rx="8" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
|
||||
<path d="M92 96h166M92 112h166M92 128h166" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="326" cy="111" r="34" fill="#07090f" stroke="#ffd84a" stroke-width="5"/>
|
||||
<path d="M326 78v66M293 111h66M303 88l46 46M349 88l-46 46" stroke="#6cff8c" stroke-width="4"/>
|
||||
<path d="M76 166h268M104 184h212" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="62" cy="74" r="6" fill="#29ffe6"/>
|
||||
<circle cx="360" cy="74" r="6" fill="#ff3df0"/>
|
||||
</g>
|
||||
<defs><filter id="g"><feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#000" flood-opacity=".55"/></filter></defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1023 B |
12
apps/web/public/miners/avalon-nano-3.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Avalon Nano 3 miner">
|
||||
<rect width="320" height="220" fill="none"/>
|
||||
<g filter="url(#g)">
|
||||
<rect x="76" y="44" width="168" height="128" rx="20" fill="#e7edf5" stroke="#29ffe6" stroke-width="4"/>
|
||||
<rect x="96" y="64" width="128" height="88" rx="14" fill="#111827"/>
|
||||
<circle cx="160" cy="108" r="42" fill="#07090f" stroke="#ff3df0" stroke-width="6"/>
|
||||
<path d="M160 68c16 22 16 58 0 80M120 108c22-16 58-16 80 0M132 80c30 4 52 26 56 56M188 80c-4 30-26 52-56 56" stroke="#29ffe6" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="112" y="166" width="96" height="12" rx="6" fill="#475569"/>
|
||||
<path d="M64 112h28M228 112h28" stroke="#ffd84a" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".45"/></filter></defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 925 B |
15
apps/web/public/miners/bitaxe.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Bitaxe miner">
|
||||
<rect width="320" height="220" fill="none"/>
|
||||
<g filter="url(#g)">
|
||||
<rect x="72" y="70" width="176" height="84" rx="12" fill="#152033" stroke="#6cff8c" stroke-width="4"/>
|
||||
<rect x="94" y="88" width="56" height="48" rx="6" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
|
||||
<circle cx="122" cy="112" r="18" fill="#07090f" stroke="#ff3df0" stroke-width="5"/>
|
||||
<path d="M122 94v36M104 112h36" stroke="#ffd84a" stroke-width="3"/>
|
||||
<rect x="166" y="90" width="54" height="18" rx="3" fill="#29ffe6" opacity=".85"/>
|
||||
<rect x="166" y="118" width="38" height="10" rx="3" fill="#ff3df0" opacity=".85"/>
|
||||
<path d="M86 154h148M104 170h112" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="80" cy="80" r="5" fill="#ffd84a"/>
|
||||
<circle cx="240" cy="80" r="5" fill="#ffd84a"/>
|
||||
</g>
|
||||
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
12
apps/web/public/miners/boomer-heater.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Boomer Heater">
|
||||
<rect width="320" height="220" fill="none"/>
|
||||
<g filter="url(#g)">
|
||||
<rect x="72" y="48" width="176" height="132" rx="14" fill="#3b2f2f" stroke="#ff4f78" stroke-width="4"/>
|
||||
<rect x="92" y="70" width="136" height="84" rx="8" fill="#1f1717"/>
|
||||
<path d="M116 142c-18-28 18-36 0-64M160 142c-18-28 18-36 0-64M204 142c-18-28 18-36 0-64" fill="none" stroke="#ff8a4f" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M90 180h140" stroke="#475569" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="224" cy="58" r="9" fill="#6a7aa0"/>
|
||||
<text x="160" y="205" text-anchor="middle" fill="#6a7aa0" font-family="monospace" font-size="15" font-weight="700">NO HASHES. JUST HEAT.</text>
|
||||
</g>
|
||||
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 940 B |
15
apps/web/public/miners/nerdqaxe.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="NerdQAxe miner">
|
||||
<rect width="320" height="220" fill="none"/>
|
||||
<g filter="url(#g)">
|
||||
<rect x="58" y="54" width="204" height="112" rx="10" fill="#111827" stroke="#29ffe6" stroke-width="4"/>
|
||||
<rect x="78" y="74" width="74" height="70" rx="6" fill="#2a102b" stroke="#ff3df0" stroke-width="3"/>
|
||||
<circle cx="115" cy="109" r="28" fill="#07090f" stroke="#29ffe6" stroke-width="5"/>
|
||||
<path d="M115 82v54M88 109h54M96 90l38 38M134 90l-38 38" stroke="#6cff8c" stroke-width="3"/>
|
||||
<rect x="170" y="76" width="70" height="28" rx="4" fill="#05070c" stroke="#ffd84a" stroke-width="3"/>
|
||||
<path d="M180 92h50" stroke="#ffd84a" stroke-width="4"/>
|
||||
<path d="M72 166h176M88 184h144" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="81" cy="58" r="8" fill="#ff3df0"/>
|
||||
<circle cx="239" cy="58" r="8" fill="#29ffe6"/>
|
||||
</g>
|
||||
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
74
apps/web/public/sw.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const CACHE_VERSION = "gashboard-pwa-v1";
|
||||
const APP_SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg", "/icons/maskable.svg"];
|
||||
const MINER_ASSETS = [
|
||||
"/miners/nerdqaxe.svg",
|
||||
"/miners/bitaxe.svg",
|
||||
"/miners/avalon-nano-3.svg",
|
||||
"/miners/avalon-mini-3.svg",
|
||||
"/miners/boomer-heater.svg",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll([...APP_SHELL, ...MINER_ASSETS])));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") return;
|
||||
|
||||
if (req.mode === "navigate") {
|
||||
event.respondWith(networkFirst(req, "/"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(req));
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(networkFirst(req));
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
|
||||
});
|
||||
|
||||
async function cacheFirst(req) {
|
||||
const cache = await caches.open(CACHE_VERSION);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
}
|
||||
|
||||
async function networkFirst(req, fallbackPath) {
|
||||
const cache = await caches.open(CACHE_VERSION);
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
if (res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
if (fallbackPath) {
|
||||
const fallback = await cache.match(fallbackPath);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
throw new Error("offline");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { useStatsStore } from "./stores/stats";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const stats = useStatsStore();
|
||||
const route = useRoute();
|
||||
const crt = ref(false);
|
||||
const heatLevel = computed(() => {
|
||||
const snap = stats.snapshot;
|
||||
if (!snap?.miners.length) return 0;
|
||||
const expected = snap.miners.reduce((sum, m) => sum + m.expectedHashrateThs, 0);
|
||||
if (expected <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, snap.pool.combinedHashrateThs / expected));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem("gashboard.crt") === "1";
|
||||
@@ -22,6 +31,16 @@ function toggleCrt(): void {
|
||||
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>
|
||||
|
||||
179
apps/web/src/components/MinerRace.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
|
||||
|
||||
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
|
||||
|
||||
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 vibes and a pension.",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function minerKey(m: MinerStat): string {
|
||||
return m.nickname || m.authUsername || m.remoteHost;
|
||||
}
|
||||
|
||||
function imageFor(m: MinerStat): string {
|
||||
if (m.nickname === "QU4CK") return "/miners/nerdqaxe.svg";
|
||||
if (m.nickname === "P1XEL") return "/miners/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">Tiny machines, deeply unfair track</h2>
|
||||
</div>
|
||||
<span class="muted">ranked by current TH/s</span>
|
||||
</header>
|
||||
|
||||
<div class="track">
|
||||
<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>
|
||||
</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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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>
|
||||
@@ -2,9 +2,11 @@ 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();
|
||||
|
||||
18
apps/web/src/services/pwa.ts
Normal file
@@ -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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export type Signer = {
|
||||
let activeSigner: 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 pool = new SimplePool();
|
||||
|
||||
export function getActiveSigner(): Signer | null {
|
||||
@@ -73,6 +74,18 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function loginWithRemoteApp(): Promise<string> {
|
||||
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 ??
|
||||
@@ -84,24 +97,38 @@ export async function loginWithRemoteApp(): Promise<string> {
|
||||
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: NOSTR_CONNECT_RELAYS,
|
||||
signer: new PrivateKeySigner(),
|
||||
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
|
||||
signer: clientSigner,
|
||||
...(pending ? { secret: pending.secret } : {}),
|
||||
subscriptionMethod: subscribeToRelays,
|
||||
publishMethod: publishToRelays,
|
||||
});
|
||||
|
||||
const permissions =
|
||||
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
|
||||
const uri = withCallback(
|
||||
signer.getNostrConnectURI({
|
||||
name: "gashboard",
|
||||
url: window.location.origin,
|
||||
permissions,
|
||||
}),
|
||||
);
|
||||
if (!pending) {
|
||||
savePendingRemoteLogin({
|
||||
key: Array.from(clientSigner.key),
|
||||
secret: signer.secret,
|
||||
relays: NOSTR_CONNECT_RELAYS,
|
||||
});
|
||||
}
|
||||
|
||||
openSignerApp(uri);
|
||||
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);
|
||||
@@ -115,6 +142,7 @@ export async function loginWithRemoteApp(): Promise<string> {
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
};
|
||||
clearPendingRemoteLogin();
|
||||
return pubkey;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
@@ -122,6 +150,32 @@ export async function loginWithRemoteApp(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
type PendingRemoteLogin = {
|
||||
key: number[];
|
||||
secret: string;
|
||||
relays: 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 withCallback(uri: string): string {
|
||||
const separator = uri.includes("?") ? "&" : "?";
|
||||
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;
|
||||
|
||||
@@ -66,6 +66,24 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -74,5 +92,17 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, loginRemoteApp, logout };
|
||||
return {
|
||||
npub,
|
||||
token,
|
||||
error,
|
||||
busy,
|
||||
isLoggedIn,
|
||||
loginExtension,
|
||||
loginBunker,
|
||||
loginRemoteApp,
|
||||
resumeRemoteAppLogin,
|
||||
hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ body,
|
||||
|
||||
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);
|
||||
@@ -44,6 +45,12 @@ body {
|
||||
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;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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();
|
||||
|
||||
@@ -167,6 +168,8 @@ function fmt(n: number, digits = 2): string {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MinerRace :snapshot="stats.snapshot" :history="stats.history" />
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric panel">
|
||||
<div class="label">now</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
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);
|
||||
@@ -42,6 +43,23 @@ async function loginRemoteApp(): Promise<void> {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
<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(() => {
|
||||
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">Return to the gashboard tab to finish sign-in.</p>
|
||||
<p class="muted">Finishing remote signer login...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||