diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 3b7a528..2d17bb0 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -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 {
diff --git a/apps/web/index.html b/apps/web/index.html
index 32bfe30..4d29fdf 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -4,6 +4,13 @@
+
+
+
+
+
+
+
gashboard
diff --git a/apps/web/public/icons/icon.svg b/apps/web/public/icons/icon.svg
new file mode 100644
index 0000000..d6ec436
--- /dev/null
+++ b/apps/web/public/icons/icon.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/web/public/icons/maskable.svg b/apps/web/public/icons/maskable.svg
new file mode 100644
index 0000000..242e9b7
--- /dev/null
+++ b/apps/web/public/icons/maskable.svg
@@ -0,0 +1,7 @@
+
diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest
new file mode 100644
index 0000000..a19024e
--- /dev/null
+++ b/apps/web/public/manifest.webmanifest
@@ -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"
+ }
+ ]
+}
diff --git a/apps/web/public/miners/avalon-mini-3.svg b/apps/web/public/miners/avalon-mini-3.svg
new file mode 100644
index 0000000..a08c9ec
--- /dev/null
+++ b/apps/web/public/miners/avalon-mini-3.svg
@@ -0,0 +1,14 @@
+
diff --git a/apps/web/public/miners/avalon-nano-3.svg b/apps/web/public/miners/avalon-nano-3.svg
new file mode 100644
index 0000000..8689c6f
--- /dev/null
+++ b/apps/web/public/miners/avalon-nano-3.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/web/public/miners/bitaxe.svg b/apps/web/public/miners/bitaxe.svg
new file mode 100644
index 0000000..88731d6
--- /dev/null
+++ b/apps/web/public/miners/bitaxe.svg
@@ -0,0 +1,15 @@
+
diff --git a/apps/web/public/miners/boomer-heater.svg b/apps/web/public/miners/boomer-heater.svg
new file mode 100644
index 0000000..5db3115
--- /dev/null
+++ b/apps/web/public/miners/boomer-heater.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/web/public/miners/nerdqaxe.svg b/apps/web/public/miners/nerdqaxe.svg
new file mode 100644
index 0000000..8f0ac27
--- /dev/null
+++ b/apps/web/public/miners/nerdqaxe.svg
@@ -0,0 +1,15 @@
+
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 0000000..81d351b
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -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");
+ }
+}
diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue
index e641dfa..1c5e362 100644
--- a/apps/web/src/App.vue
+++ b/apps/web/src/App.vue
@@ -1,11 +1,20 @@
diff --git a/apps/web/src/components/MinerRace.vue b/apps/web/src/components/MinerRace.vue
new file mode 100644
index 0000000..241bca4
--- /dev/null
+++ b/apps/web/src/components/MinerRace.vue
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
hashrate race
+
Tiny machines, deeply unfair track
+
+ ranked by current TH/s
+
+
+
+
+
#{{ row.place }}
+
![]()
+
+
+ {{ row.miner.nickname }}
+ {{ row.joke || `${row.miner.hashrateThs.toFixed(2)} TH/s` }}
+
+
+
+ {{ row.width <= 0 ? "lol" : row.trend >= 0 ? "▲" : "▼" }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts
index 0a00efb..e6d9acc 100644
--- a/apps/web/src/main.ts
+++ b/apps/web/src/main.ts
@@ -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();
diff --git a/apps/web/src/services/pwa.ts b/apps/web/src/services/pwa.ts
new file mode 100644
index 0000000..733cafd
--- /dev/null
+++ b/apps/web/src/services/pwa.ts
@@ -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" });
+ }
+ });
+ });
+ });
+ });
+}
diff --git a/apps/web/src/services/signer.ts b/apps/web/src/services/signer.ts
index ed6699c..3390e06 100644
--- a/apps/web/src/services/signer.ts
+++ b/apps/web/src/services/signer.ts
@@ -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 {
}
export async function loginWithRemoteApp(): Promise {
+ return connectRemoteApp({ openApp: true });
+}
+
+export async function resumeRemoteAppLogin(): Promise {
+ return connectRemoteApp({ openApp: false });
+}
+
+export function hasPendingRemoteAppLogin(): boolean {
+ return !!sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
+}
+
+async function connectRemoteApp(opts: { openApp: boolean }): Promise {
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
@@ -84,24 +97,38 @@ export async function loginWithRemoteApp(): Promise {
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 {
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 {
}
}
+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`)}`;
diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts
index ad26c55..dc7a2e0 100644
--- a/apps/web/src/stores/auth.ts
+++ b/apps/web/src/stores/auth.ts
@@ -66,6 +66,24 @@ export const useAuthStore = defineStore("auth", () => {
}
}
+ async function resumeRemoteAppLogin(): Promise {
+ 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,
+ };
});
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index c0b352c..8d5f0cc 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -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;
diff --git a/apps/web/src/views/GraphsView.vue b/apps/web/src/views/GraphsView.vue
index f3b1cdb..3a392dd 100644
--- a/apps/web/src/views/GraphsView.vue
+++ b/apps/web/src/views/GraphsView.vue
@@ -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 {
+
+
now
diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue
index 567d300..7ee243b 100644
--- a/apps/web/src/views/LoginView.vue
+++ b/apps/web/src/views/LoginView.vue
@@ -1,10 +1,11 @@
diff --git a/apps/web/src/views/NostrCallbackView.vue b/apps/web/src/views/NostrCallbackView.vue
index b910d2d..bc7b658 100644
--- a/apps/web/src/views/NostrCallbackView.vue
+++ b/apps/web/src/views/NostrCallbackView.vue
@@ -1,7 +1,24 @@
+
+
// signer_return
-
Return to the gashboard tab to finish sign-in.
+
Finishing remote signer login...