Add graph telemetry and remote signer login
This commit is contained in:
@@ -13,10 +13,10 @@ LOG_LEVEL=info
|
||||
# STATIC_DIR=
|
||||
|
||||
# ---- Datum gateway (the Umbrel app we're polling) ----
|
||||
# Reachable internally inside Umbrel's docker network. From your LAN it's also
|
||||
# at http://192.168.1.191:21000 but that path goes through umbrelOS auth.
|
||||
# Inside the Umbrel docker network, use the Datum service hostname directly.
|
||||
DATUM_URL=http://10.21.0.11:21000
|
||||
# Inside the Umbrel docker network, use Datum's service hostname directly.
|
||||
# If you run the API outside that Docker network, override this with a hostname
|
||||
# or IP address reachable from that process.
|
||||
DATUM_URL=http://datum:21000
|
||||
DATUM_ADMIN_USER=admin
|
||||
DATUM_ADMIN_PASSWORD=
|
||||
# How often to scrape /clients (ms). Datum updates per-worker hashrate every
|
||||
|
||||
@@ -51,6 +51,15 @@ function formatErr(err: unknown): string {
|
||||
const cause = (err as Error & { cause?: unknown }).cause;
|
||||
if (cause instanceof Error) {
|
||||
const code = (cause as Error & { code?: string }).code;
|
||||
const hostname = (cause as Error & { hostname?: string }).hostname;
|
||||
if (code === "ENOTFOUND" && hostname) {
|
||||
const datumHost = new URL(config.datum.url).hostname;
|
||||
const hint =
|
||||
hostname === datumHost
|
||||
? "; check DATUM_NETWORK or set DATUM_URL to a hostname/IP reachable from the gashboard API container"
|
||||
: "";
|
||||
return `${err.message}: DNS could not resolve ${hostname} (${code})${hint}`;
|
||||
}
|
||||
return code ? `${err.message}: ${cause.message} (${code})` : `${err.message}: ${cause.message}`;
|
||||
}
|
||||
return err.message;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function buildApp() {
|
||||
"script-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
"img-src": ["'self'", "data:"],
|
||||
"connect-src": ["'self'"],
|
||||
"connect-src": ["'self'", "wss://relay.primal.net"],
|
||||
"font-src": ["'self'", "data:"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
"upgrade-insecure-requests": null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -30,6 +30,10 @@ function shortNpub(n: string): string {
|
||||
⌬ 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>
|
||||
</nav>
|
||||
<div class="actions">
|
||||
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
|
||||
<template v-if="auth.isLoggedIn">
|
||||
@@ -72,6 +76,27 @@ function shortNpub(n: string): string {
|
||||
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);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -98,4 +123,18 @@ main.shellLogin {
|
||||
text-align: center;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav {
|
||||
margin: 0;
|
||||
}
|
||||
.actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from "vue";
|
||||
import type { MinerStat } from "../types";
|
||||
import { ROASTS, STATUS_LINES, STALE_LINES } from "../strings";
|
||||
|
||||
const props = defineProps<{ miner: MinerStat }>();
|
||||
const props = defineProps<{ miner: MinerStat; bestWork: number | undefined }>();
|
||||
|
||||
const slug = computed(() => {
|
||||
// Map nickname → roast key
|
||||
@@ -36,7 +36,8 @@ const lastShareLabel = computed(() => {
|
||||
});
|
||||
|
||||
const lifetimeShares = computed(() => props.miner.diffAcceptedCount);
|
||||
const bestShare = computed(() => formatBig(props.miner.diffAcceptedSum));
|
||||
const acceptedWork = computed(() => formatBig(props.miner.diffAcceptedSum));
|
||||
const bestWork = computed(() => props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting");
|
||||
|
||||
const staleNote = computed(() => {
|
||||
if (props.miner.status !== "stale") return "";
|
||||
@@ -83,8 +84,12 @@ function formatBig(n: number): string {
|
||||
<div class="value">{{ lifetimeShares.toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">total work</div>
|
||||
<div class="value">{{ bestShare }}</div>
|
||||
<div class="label">best calc</div>
|
||||
<div class="value glow-amber">{{ bestWork }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">accepted work</div>
|
||||
<div class="value">{{ acceptedWork }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">reject %</div>
|
||||
|
||||
@@ -8,11 +8,22 @@ const routes: RouteRecordRaw[] = [
|
||||
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({
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 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";
|
||||
|
||||
export type SignerKind = "extension" | "bunker";
|
||||
|
||||
@@ -13,6 +15,9 @@ export type Signer = {
|
||||
};
|
||||
|
||||
let activeSigner: Signer | null = null;
|
||||
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
|
||||
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
|
||||
const pool = new SimplePool();
|
||||
|
||||
export function getActiveSigner(): Signer | null {
|
||||
return activeSigner;
|
||||
@@ -66,3 +71,118 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
|
||||
};
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
export async function loginWithRemoteApp(): 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 signer = new NostrConnectSigner({
|
||||
relays: NOSTR_CONNECT_RELAYS,
|
||||
signer: new PrivateKeySigner(),
|
||||
subscriptionMethod: subscribeToRelays,
|
||||
publishMethod: publishToRelays,
|
||||
});
|
||||
|
||||
const permissions =
|
||||
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
|
||||
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 = {
|
||||
kind: "bunker",
|
||||
getPublicKey: async () => pubkey,
|
||||
signEvent: async (template: EventTemplate) =>
|
||||
(await signer.signEvent(template)) as NostrEvent,
|
||||
};
|
||||
return pubkey;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
if (!signer.isConnected) await signer.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,24 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
api.clearToken();
|
||||
signer.clearSigner();
|
||||
@@ -56,5 +74,5 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, logout };
|
||||
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, loginRemoteApp, logout };
|
||||
});
|
||||
|
||||
@@ -1,21 +1,78 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "../services/api";
|
||||
import type { DatumSnapshot } from "../types";
|
||||
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> = {};
|
||||
for (const miner of snap.miners) {
|
||||
const key = miner.nickname || miner.authUsername || miner.remoteHost;
|
||||
miners[key] = miner.hashrateThs;
|
||||
minerWork[key] = miner.diffAcceptedSum;
|
||||
minerShares[key] = miner.diffAcceptedCount;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -37,5 +94,5 @@ export const useStatsStore = defineStore("stats", () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { snapshot, error, loading, fetchOnce, start, stop };
|
||||
return { snapshot, history, error, loading, fetchOnce, start, stop };
|
||||
});
|
||||
|
||||
@@ -17,17 +17,21 @@ export const STATUS_LINES = {
|
||||
|
||||
export const LOTTERY_LINES = [
|
||||
(oddsPerDay: number) =>
|
||||
`today's chance: ${formatPct(oddsPerDay)}. small number, enormous main-character energy.`,
|
||||
`today: ${formatPct(oddsPerDay)}. A rounding error wearing a cape.`,
|
||||
(oddsPerDay: number) =>
|
||||
`statistically, the next block arrives in ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. emotionally, any minute now.`,
|
||||
`expected wait: ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. Your descendants' descendants will still be checking the fan noise.`,
|
||||
() =>
|
||||
`the math says no. the dashboard has chosen to hear "not yet."`,
|
||||
`the math says no in every language, including ones we haven't invented yet.`,
|
||||
(oddsPerDay: number) =>
|
||||
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. bad weather has better marketing.`,
|
||||
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. Even the sky has a better business plan.`,
|
||||
() =>
|
||||
`four tiny tickets in a planetary raffle. extremely unserious. deeply respected.`,
|
||||
`four little heaters bullying SHA-256 and calling it a retirement strategy.`,
|
||||
() =>
|
||||
`every share is a receipt for optimism with terrible accounting.`,
|
||||
`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.`,
|
||||
];
|
||||
|
||||
export const BLOCK_CELEBRATION_LINES = [
|
||||
|
||||
@@ -56,6 +56,19 @@ export type DatumSnapshot = {
|
||||
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>;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
npub: string;
|
||||
|
||||
@@ -16,6 +16,27 @@ 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);
|
||||
|
||||
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)));
|
||||
|
||||
function minerKey(m: { nickname: string; authUsername: string; remoteHost: string }): string {
|
||||
return m.nickname || m.authUsername || m.remoteHost;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,13 +51,28 @@ const upstreamErr = computed(() => stats.snapshot?.error);
|
||||
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
<MinerCard
|
||||
v-for="m in miners"
|
||||
:key="m.authUsername || m.nickname"
|
||||
:miner="m"
|
||||
:best-work="bestWorkByMiner[minerKey(m)]"
|
||||
/>
|
||||
<div v-if="!miners.length && !errorMsg" class="empty panel muted">
|
||||
waiting for the first poll · the boards are warming up
|
||||
</div>
|
||||
@@ -58,6 +94,22 @@ const upstreamErr = computed(() => stats.snapshot?.error);
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.best {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.best strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.best span {
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
.miners {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@@ -70,5 +122,12 @@ const upstreamErr = computed(() => stats.snapshot?.error);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.grid-row { grid-template-columns: 1fr; }
|
||||
.best {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.best span {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
441
apps/web/src/views/GraphsView.vue
Normal file
441
apps/web/src/views/GraphsView.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useStatsStore } from "../stores/stats";
|
||||
import type { HistoryPoint } from "../types";
|
||||
|
||||
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.nickname || m.authUsername || m.remoteHost;
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
@@ -7,6 +7,7 @@ const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const bunkerUri = ref("");
|
||||
const showBunkerInput = ref(false);
|
||||
const waitingRemote = ref(false);
|
||||
|
||||
async function loginExt(): Promise<void> {
|
||||
try {
|
||||
@@ -29,6 +30,18 @@ async function loginBunker(): Promise<void> {
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,13 +60,22 @@ async function loginBunker(): Promise<void> {
|
||||
<small>NIP-07 · alby, nos2x, primal</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"
|
||||
>
|
||||
remote signer
|
||||
<small>NIP-46 · primal app, amber, nsecbunker</small>
|
||||
bunker URI
|
||||
<small>advanced fallback · nsecbunker</small>
|
||||
</button>
|
||||
|
||||
<div v-if="showBunkerInput" class="bunker">
|
||||
@@ -75,9 +97,7 @@ async function loginBunker(): Promise<void> {
|
||||
</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.
|
||||
tap open signer app, approve the connection, then approve the sign-in event.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
23
apps/web/src/views/NostrCallbackView.vue
Normal file
23
apps/web/src/views/NostrCallbackView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -1,9 +1,11 @@
|
||||
version: "3.9"
|
||||
|
||||
# gashboard — deploy as a Portainer Stack on the same Umbrel host that runs Datum.
|
||||
# IMPORTANT: set DATUM_NETWORK to the actual Docker network used by datum_datum_1.
|
||||
# Find it with:
|
||||
# docker inspect -f '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' datum_datum_1
|
||||
#
|
||||
# Umbrel's Portainer app runs user stacks inside Docker-in-Docker. A Portainer
|
||||
# stack network named "umbrel_main_network" is not the real Umbrel host network,
|
||||
# so Docker DNS names such as datum_datum_1 do not resolve from this container.
|
||||
# Host networking is used here so gashboard can reach Datum's real container IP.
|
||||
|
||||
services:
|
||||
gashboard:
|
||||
@@ -23,28 +25,21 @@ services:
|
||||
PORT: "1337"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
CORS_ORIGIN: "${CORS_ORIGIN:-}"
|
||||
# Reach the Datum gateway container directly on its Docker network.
|
||||
# Do not use the Umbrel host proxy here; it serves the Umbrel web shell.
|
||||
DATUM_URL: "${DATUM_URL:-http://datum:21000}"
|
||||
# Reach the Datum gateway container directly. Do not use Umbrel's
|
||||
# published port 21000 here; it is the Umbrel auth proxy.
|
||||
# Find the current IP with:
|
||||
# docker inspect -f '{{.NetworkSettings.Networks.umbrel_main_network.IPAddress}}' datum_datum_1
|
||||
DATUM_URL: "${DATUM_URL?must be set, e.g. http://10.21.0.11:21000}"
|
||||
DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}"
|
||||
DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}"
|
||||
DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}"
|
||||
NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}"
|
||||
JWT_SECRET: "${JWT_SECRET?must be set}"
|
||||
JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"
|
||||
ports:
|
||||
- "1337:1337"
|
||||
network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- datum_network
|
||||
- default
|
||||
|
||||
networks:
|
||||
datum_network:
|
||||
external: true
|
||||
name: "${DATUM_NETWORK:-umbrel_main_network}"
|
||||
|
||||
Reference in New Issue
Block a user