feat(ui): Networking Profits becomes a dashboard

Dashboard tab (default): stat tiles per earning source, 7-day earnings
LineChart (one line per source, daily buckets — earnings are sparse
events so finer buckets would draw noise), active-session count and
revenue-by-service bars from streaming.list-sessions. Configure tab
keeps the old settings screen with the intro copy boxed in a card, plus
a new TollGate WiFi entry (price per minute via openwrt.provision-
tollgate, with a set-up-gateway hint when no router is paired). Demo
mocks gain profit history entries and streaming sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-17 03:07:30 +01:00
parent 90bedc2a25
commit aaa3477d5e
2 changed files with 455 additions and 67 deletions

View File

@ -2382,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
case 'streaming.list-services': {
return res.json({ result: { services: mockState.streamingServices || [] } })
}
case 'streaming.list-sessions': {
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
return res.json({ result: {
sessions: [
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
],
total_active: 3,
total_revenue_sats: 770_000,
revenue_by_service: {
'content-download': 512_400,
'nostr-relay': 201_600,
'api-access': 56_000,
},
} })
}
case 'streaming.configure-service': {
const p = params || {}
const list = mockState.streamingServices || []
@ -3566,13 +3583,32 @@ app.post('/rpc/v1', (req, res) => {
}
case 'wallet.networking-profits': {
// Deterministic-but-varied week of profit events for the dashboard
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
const recent = []
const nowMs = Date.now()
for (let i = 0; i < 42; i++) {
const daysAgo = (i * 3) % 7
const hour = (i * 5) % 24
recent.push({
source: profitSources[i % 3],
amount_sats: 800 + ((i * 7919) % 14000),
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
description: profitNotes[i % 3],
})
}
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
return res.json({
result: {
total_sats: 5_231_978,
content_sales_sats: 3_180_000,
routing_fees_sats: 1_281_978,
relay_sats: 770_000,
streaming_revenue_sats: 770_000,
recent,
// legacy aliases kept for older UI builds
relay_sats: 770_000,
total_earned_sats: 5_231_978,
total_forwarded_sats: 1_281_978,
forward_count: 1284,

View File

@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
const router = useRouter()
@ -23,6 +25,10 @@ interface ServicePricing {
accepted_mints: string[]
}
// Tabs
const tab = ref<'dashboard' | 'configure'>('dashboard')
// Configure state
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
@ -32,7 +38,7 @@ const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
const allFree = computed(() => services.value.every((s) => !s.enabled) && !tollgate.value?.enabled)
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
@ -113,86 +119,432 @@ async function saveService(svc: ServicePricing) {
}
}
onMounted(load)
// TollGate (paid WiFi on the OpenWrt gateway)
interface TollGateStatus {
installed: boolean
enabled?: boolean
step_size_ms?: number
price_per_step?: number
min_steps?: number
mint_url?: string
}
const tollgate = ref<TollGateStatus | null>(null)
const tollgateChecked = ref(false)
const tollgateSaving = ref(false)
const tollgatePrice = ref(10)
const tollgateEnabled = ref(false)
const tollgateUnit = computed(() => {
const ms = tollgate.value?.step_size_ms || 60_000
if (ms === 3_600_000) return 'hour'
if (ms === 60_000) return 'minute'
if (ms === 1000) return 'second'
return `${ms.toLocaleString()} ms`
})
async function loadTollgate() {
try {
const res = await rpcClient.call<{ tollgate?: TollGateStatus }>({ method: 'openwrt.get-status' })
tollgate.value = res.tollgate || null
tollgateEnabled.value = !!res.tollgate?.enabled
tollgatePrice.value = Math.max(1, res.tollgate?.price_per_step || 10)
} catch {
// No gateway configured (or unreachable) the card shows the setup hint.
tollgate.value = null
} finally {
tollgateChecked.value = true
}
}
async function saveTollgate() {
if (tollgatePrice.value < 1) tollgatePrice.value = 1
tollgateSaving.value = true
try {
await rpcClient.call({
method: 'openwrt.provision-tollgate',
params: {
enabled: tollgateEnabled.value,
// Real backend reads price_sats; the mock reads price_per_step
// send both so the same call works against either.
price_sats: tollgatePrice.value,
price_per_step: tollgatePrice.value,
step_size_ms: tollgate.value?.step_size_ms || 60_000,
min_steps: tollgate.value?.min_steps || 1,
},
})
showStatus(
tollgateEnabled.value
? `TollGate WiFi is charging ${tollgatePrice.value} sats per ${tollgateUnit.value}.`
: 'TollGate WiFi is now free.',
false,
)
void loadTollgate()
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save TollGate settings', true)
} finally {
tollgateSaving.value = false
}
}
// Dashboard state
type ProfitSource = 'content_sale' | 'routing_fee' | 'streaming_revenue'
interface ProfitEntry {
source: ProfitSource
amount_sats: number
timestamp: string
description?: string
}
interface ProfitsSummary {
total_sats: number
content_sales_sats: number
routing_fees_sats: number
streaming_revenue_sats?: number
recent?: ProfitEntry[]
}
interface SessionsSummary {
total_active: number
total_revenue_sats: number
revenue_by_service: Record<string, number>
}
const profits = ref<ProfitsSummary | null>(null)
const sessionsSummary = ref<SessionsSummary | null>(null)
const dashboardLoading = ref(true)
async function loadDashboard() {
dashboardLoading.value = true
try {
const [p, s] = await Promise.all([
rpcClient.call<ProfitsSummary>({ method: 'wallet.networking-profits' }).catch(() => null),
rpcClient.call<SessionsSummary>({ method: 'streaming.list-sessions' }).catch(() => null),
])
profits.value = p
sessionsSummary.value = s
} finally {
dashboardLoading.value = false
}
}
function formatSats(n: number | undefined | null): string {
return (n ?? 0).toLocaleString()
}
// 7 daily buckets, oldest newest, one series per earning source. A week of
// daily totals is the sweet spot here: earnings are sparse events (unlike the
// second-by-second system metrics on Monitoring), so finer buckets would just
// draw noise, and a longer window would flatten a new node's first sats.
const DAY_MS = 86_400_000
const WINDOW_DAYS = 7
const dayLabels = computed(() => {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const labels: string[] = []
for (let i = WINDOW_DAYS - 1; i >= 0; i--) {
labels.push(names[new Date(Date.now() - i * DAY_MS).getDay()] as string)
}
return labels
})
function bucketize(source: ProfitSource): number[] {
const buckets = new Array(WINDOW_DAYS).fill(0)
const now = Date.now()
for (const e of profits.value?.recent || []) {
if (e.source !== source) continue
const t = Date.parse(e.timestamp)
if (Number.isNaN(t)) continue
const age = Math.floor((now - t) / DAY_MS)
if (age < 0 || age >= WINDOW_DAYS) continue
buckets[WINDOW_DAYS - 1 - age] += e.amount_sats
}
return buckets
}
const earningsDatasets = computed<ChartDataset[]>(() => [
{ label: 'Streaming', data: bucketize('streaming_revenue'), color: '#f97316' },
{ label: 'Content sales', data: bucketize('content_sale'), color: '#3b82f6' },
{ label: 'Routing fees', data: bucketize('routing_fee'), color: '#a78bfa' },
])
const hasRecentEarnings = computed(() =>
earningsDatasets.value.some((d) => d.data.some((v) => v > 0)),
)
// Revenue-by-service bars, largest first, scaled to the biggest earner.
const serviceBars = computed(() => {
const by = sessionsSummary.value?.revenue_by_service || {}
const entries = Object.entries(by).sort((a, b) => b[1] - a[1])
const max = entries[0]?.[1] || 1
return entries.map(([id, sats]) => ({
id,
name: services.value.find((s) => s.service_id === id)?.name || id,
sats,
pct: Math.max(4, Math.round((sats / max) * 100)),
}))
})
// Chart sizing (same measure-the-card approach as Monitoring)
const chartCard = ref<HTMLElement | null>(null)
const chartWidth = ref(600)
function updateChartWidth() {
chartWidth.value = Math.max(280, (chartCard.value?.clientWidth || 640) - 40)
}
onMounted(() => {
void load()
void loadTollgate()
void loadDashboard()
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateChartWidth)
})
</script>
<template>
<div class="pb-6">
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
<div class="mb-4">
<h1 class="text-3xl font-bold text-white">Networking Profits</h1>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
<!-- Tabs: Dashboard | Configure -->
<div class="flex gap-1 mb-6 border-b border-white/10">
<button
@click="tab = 'dashboard'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Dashboard
</button>
<button
@click="tab = 'configure'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="tab === 'configure' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Configure
</button>
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
<!-- ============ DASHBOARD ============ -->
<div v-show="tab === 'dashboard'">
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings</div>
<template v-else>
<!-- Stat tiles -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
<p class="text-xs text-white/40">sats, all time</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
<p class="text-xs text-white/40">sats from paid services</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Content sales</p>
<p class="text-2xl font-bold text-blue-400">{{ formatSats(profits?.content_sales_sats) }}</p>
<p class="text-xs text-white/40">sats from ecash sales</p>
</div>
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide">Routing fees</p>
<p class="text-2xl font-bold text-violet-400">{{ formatSats(profits?.routing_fees_sats) }}</p>
<p class="text-xs text-white/40">sats from Lightning</p>
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
<!-- Earnings chart -->
<div ref="chartCard" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 class="text-sm font-medium text-white/80">Earnings last 7 days</h3>
<div class="flex items-center gap-4">
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
{{ d.label }}
</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
<LineChart
v-if="hasRecentEarnings"
:datasets="earningsDatasets"
:labels="dayLabels"
:width="chartWidth"
:height="200"
/>
<div v-else class="py-10 text-center text-white/40 text-sm">
No earnings in the last 7 days enable a paid service under Configure to start earning.
</div>
</div>
<!-- Sessions + revenue by service -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="glass-card p-5">
<p class="text-xs text-white/50 uppercase tracking-wide mb-1">Active sessions</p>
<p class="text-4xl font-bold text-white mb-1">{{ sessionsSummary?.total_active ?? 0 }}</p>
<p class="text-xs text-white/40">peers currently paying for services</p>
<p class="text-sm text-white/70 mt-4">
Session revenue:
<span class="text-white font-medium">{{ formatSats(sessionsSummary?.total_revenue_sats) }} sats</span>
</p>
</div>
<div class="glass-card p-5 lg:col-span-2">
<h3 class="text-sm font-medium text-white/80 mb-4">Revenue by service</h3>
<div v-if="serviceBars.length === 0" class="py-6 text-center text-white/40 text-sm">
No service revenue yet.
</div>
<div v-else class="space-y-3">
<div v-for="bar in serviceBars" :key="bar.id">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-white/80">{{ bar.name }}</span>
<span class="text-white/50">{{ formatSats(bar.sats) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-orange-500/70" :style="{ width: bar.pct + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- ============ CONFIGURE ============ -->
<div v-show="tab === 'configure'">
<!-- Intro copy, boxed so it reads as its own thing rather than page dressing -->
<div class="glass-card p-4 mb-6">
<p class="text-sm text-white/70 leading-relaxed">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
<!-- TollGate: paid WiFi on the OpenWrt gateway -->
<div v-if="tollgateChecked" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">TollGate WiFi</h2>
<p class="text-sm text-white/60 mt-0.5">
Sell WiFi access on your OpenWrt gateway visitors pay per {{ tollgateUnit }} in ecash.
</p>
</div>
<div v-if="tollgate?.installed" class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="tollgateEnabled ? 'text-orange-400' : 'text-white/40'">
{{ tollgateEnabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="tollgateEnabled" @update:model-value="(v) => (tollgateEnabled = v)" />
</div>
</div>
<div v-if="tollgate?.installed" class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !tollgateEnabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="tollgatePrice"
type="number"
min="1"
step="1"
:disabled="!tollgateEnabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ tollgateUnit }}</span>
</div>
</div>
<button
@click="saveTollgate"
:disabled="tollgateSaving"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ tollgateSaving ? 'Saving…' : 'Save' }}
</button>
</div>
<div v-else class="flex flex-col sm:flex-row sm:items-center gap-3">
<p class="text-sm text-white/50 flex-1">
TollGate isn't set up yet it needs an OpenWrt gateway paired with this node.
</p>
<button
@click="router.push('/dashboard/server/openwrt')"
class="glass-button px-4 py-2 rounded-lg text-sm shrink-0"
>
Set up gateway
</button>
</div>
</div>
</div>
</div>