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:
parent
90bedc2a25
commit
aaa3477d5e
@ -2382,6 +2382,23 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
case 'streaming.list-services': {
|
case 'streaming.list-services': {
|
||||||
return res.json({ result: { services: mockState.streamingServices || [] } })
|
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': {
|
case 'streaming.configure-service': {
|
||||||
const p = params || {}
|
const p = params || {}
|
||||||
const list = mockState.streamingServices || []
|
const list = mockState.streamingServices || []
|
||||||
@ -3566,13 +3583,32 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'wallet.networking-profits': {
|
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({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
total_sats: 5_231_978,
|
total_sats: 5_231_978,
|
||||||
content_sales_sats: 3_180_000,
|
content_sales_sats: 3_180_000,
|
||||||
routing_fees_sats: 1_281_978,
|
routing_fees_sats: 1_281_978,
|
||||||
relay_sats: 770_000,
|
streaming_revenue_sats: 770_000,
|
||||||
|
recent,
|
||||||
// legacy aliases kept for older UI builds
|
// legacy aliases kept for older UI builds
|
||||||
|
relay_sats: 770_000,
|
||||||
total_earned_sats: 5_231_978,
|
total_earned_sats: 5_231_978,
|
||||||
total_forwarded_sats: 1_281_978,
|
total_forwarded_sats: 1_281_978,
|
||||||
forward_count: 1284,
|
forward_count: 1284,
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
import LineChart from '@/components/LineChart.vue'
|
||||||
|
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -23,6 +25,10 @@ interface ServicePricing {
|
|||||||
accepted_mints: string[]
|
accepted_mints: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabs ──
|
||||||
|
const tab = ref<'dashboard' | 'configure'>('dashboard')
|
||||||
|
|
||||||
|
// ── Configure state ──
|
||||||
const services = ref<ServicePricing[]>([])
|
const services = ref<ServicePricing[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
@ -32,7 +38,7 @@ const statusIsError = ref(false)
|
|||||||
|
|
||||||
// "Free everything" is the default — every service ships disabled. The banner
|
// "Free everything" is the default — every service ships disabled. The banner
|
||||||
// reassures the user nothing is being charged for until they opt in.
|
// 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) {
|
function showStatus(msg: string, isError: boolean) {
|
||||||
statusMsg.value = msg
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
|
||||||
|
|
||||||
<div class="mb-6">
|
<div class="mb-4">
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits — Settings</h1>
|
<h1 class="text-3xl font-bold text-white">Networking Profits</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>
|
</div>
|
||||||
|
|
||||||
<!-- Status message -->
|
<!-- Tabs: Dashboard | Configure -->
|
||||||
<div
|
<div class="flex gap-1 mb-6 border-b border-white/10">
|
||||||
v-if="statusMsg"
|
<button
|
||||||
role="status"
|
@click="tab = 'dashboard'"
|
||||||
aria-live="polite"
|
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||||
class="mb-4 p-3 rounded-lg text-sm"
|
:class="tab === 'dashboard' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||||
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
>
|
||||||
>
|
Dashboard
|
||||||
{{ statusMsg }}
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Everything-free reassurance banner -->
|
<!-- ============ DASHBOARD ============ -->
|
||||||
<div
|
<div v-show="tab === 'dashboard'">
|
||||||
v-if="!loading && allFree"
|
<div v-if="dashboardLoading" class="glass-card p-6 text-white/60 text-sm">Loading earnings…</div>
|
||||||
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
|
<template v-else>
|
||||||
>
|
<!-- Stat tiles -->
|
||||||
<span class="text-xl">✓</span>
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<p class="text-sm text-green-200">
|
<div class="glass-card p-5">
|
||||||
Everything is free. Your node isn't charging for anything — enable a service below to
|
<p class="text-xs text-white/50 uppercase tracking-wide">Total earned</p>
|
||||||
start earning.
|
<p class="text-2xl font-bold text-white">{{ formatSats(profits?.total_sats) }}</p>
|
||||||
</p>
|
<p class="text-xs text-white/40">sats, all time</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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="glass-card p-5">
|
||||||
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
|
<p class="text-xs text-white/50 uppercase tracking-wide">Streaming</p>
|
||||||
{{ svc.enabled ? 'Paid' : 'Free' }}
|
<p class="text-2xl font-bold text-orange-400">{{ formatSats(profits?.streaming_revenue_sats) }}</p>
|
||||||
</span>
|
<p class="text-xs text-white/40">sats from paid services</p>
|
||||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
|
<!-- Earnings chart -->
|
||||||
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
|
<div ref="chartCard" class="glass-card p-5 mb-6">
|
||||||
<label class="text-xs text-white/50 block mb-1">Price</label>
|
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<h3 class="text-sm font-medium text-white/80">Earnings — last 7 days</h3>
|
||||||
<input
|
<div class="flex items-center gap-4">
|
||||||
v-model.number="svc.price_per_step"
|
<span v-for="d in earningsDatasets" :key="d.label" class="flex items-center gap-1.5 text-xs text-white/60">
|
||||||
type="number"
|
<span class="w-2.5 h-2.5 rounded-full" :style="{ backgroundColor: d.color }"></span>
|
||||||
min="1"
|
{{ d.label }}
|
||||||
step="1"
|
</span>
|
||||||
: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>
|
</div>
|
||||||
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<LineChart
|
||||||
@click="saveService(svc)"
|
v-if="hasRecentEarnings"
|
||||||
:disabled="savingId === svc.service_id"
|
:datasets="earningsDatasets"
|
||||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
:labels="dayLabels"
|
||||||
>
|
:width="chartWidth"
|
||||||
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
|
:height="200"
|
||||||
</button>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user