ui updates

This commit is contained in:
Dorian 2026-04-11 13:35:52 +01:00
parent 0a493593b8
commit ed4e95a914
20 changed files with 614 additions and 113 deletions

View File

@ -2778,6 +2778,153 @@ app.post('/rpc/v1', (req, res) => {
}) })
} }
// ── Monitoring ──────────────────────────────────────────────
case 'monitoring.current': {
return res.json({
result: {
system: {
cpu_percent: +(12 + Math.random() * 18).toFixed(1),
load_avg_1: +(0.5 + Math.random() * 1.5).toFixed(2),
load_avg_5: +(0.8 + Math.random()).toFixed(2),
load_avg_15: +(0.6 + Math.random()).toFixed(2),
mem_used_bytes: 6_200_000_000 + Math.floor(Math.random() * 500_000_000),
mem_total_bytes: 16_000_000_000,
disk_used_bytes: 620_000_000_000,
disk_total_bytes: 1_800_000_000_000,
net_rx_bytes: 12_400_000_000 + Math.floor(Math.random() * 100_000_000),
net_tx_bytes: 8_900_000_000 + Math.floor(Math.random() * 50_000_000),
uptime_secs: Math.floor(process.uptime()) + 604800,
},
rpc: { avg_latency_ms: +(2 + Math.random() * 8).toFixed(1), requests_per_minute: Math.floor(30 + Math.random() * 40) },
},
})
}
case 'monitoring.history': {
const points = 60
const history = []
for (let i = 0; i < points; i++) {
history.push({
timestamp: new Date(Date.now() - (points - i) * 60000).toISOString(),
system: {
cpu_percent: +(10 + Math.random() * 25).toFixed(1),
mem_used_bytes: 5_800_000_000 + Math.floor(Math.random() * 1_000_000_000),
mem_total_bytes: 16_000_000_000,
net_rx_bytes: Math.floor(Math.random() * 5_000_000),
net_tx_bytes: Math.floor(Math.random() * 3_000_000),
},
rpc: { avg_latency_ms: +(1 + Math.random() * 10).toFixed(1) },
})
}
return res.json({ result: { history } })
}
case 'monitoring.alerts': {
return res.json({
result: {
alerts: [
{ id: 'a1', kind: 'cpu_high', message: 'CPU usage exceeded 80% for 5 minutes', timestamp: new Date(Date.now() - 7200000).toISOString(), acknowledged: false },
{ id: 'a2', kind: 'disk_high', message: 'Disk usage at 85%', timestamp: new Date(Date.now() - 86400000).toISOString(), acknowledged: true },
],
},
})
}
case 'monitoring.alert-rules': {
return res.json({
result: {
rules: [
{ kind: 'cpu_high', enabled: true, threshold: 80, description: 'Alert when CPU usage exceeds threshold' },
{ kind: 'mem_high', enabled: true, threshold: 85, description: 'Alert when memory usage exceeds threshold' },
{ kind: 'disk_high', enabled: true, threshold: 90, description: 'Alert when disk usage exceeds threshold' },
{ kind: 'backend_error_spike', enabled: false, threshold: 500, description: 'Alert when RPC latency exceeds threshold' },
],
},
})
}
case 'monitoring.configure-alert':
case 'monitoring.acknowledge-alert': {
return res.json({ result: { success: true } })
}
case 'monitoring.export': {
return res.json({ result: { data: 'timestamp,cpu,mem\n2026-04-10T12:00:00Z,15.2,38.5\n' } })
}
case 'monitoring.container-metrics': {
return res.json({
result: {
containers: [
{ name: 'bitcoin-knots', cpu_percent: 8.2, mem_used_bytes: 1_200_000_000, net_rx_bytes: 5_000_000, net_tx_bytes: 3_200_000 },
{ name: 'lnd', cpu_percent: 3.1, mem_used_bytes: 480_000_000, net_rx_bytes: 2_100_000, net_tx_bytes: 1_800_000 },
{ name: 'electrs', cpu_percent: 12.4, mem_used_bytes: 890_000_000, net_rx_bytes: 800_000, net_tx_bytes: 600_000 },
{ name: 'mempool', cpu_percent: 5.6, mem_used_bytes: 320_000_000, net_rx_bytes: 1_500_000, net_tx_bytes: 900_000 },
{ name: 'filebrowser', cpu_percent: 0.8, mem_used_bytes: 45_000_000, net_rx_bytes: 200_000, net_tx_bytes: 150_000 },
],
},
})
}
// ── Fleet / Telemetry ─────────────────────────────────────
case 'telemetry.fleet-status': {
return res.json({
result: {
nodes: [
{
id: 'node-1', name: 'archy-main', did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9',
status: 'online', last_seen: new Date().toISOString(),
version: '1.3.0', uptime_secs: 604800,
system: { cpu_percent: 15.2, mem_used_bytes: 6_200_000_000, mem_total_bytes: 16_000_000_000, disk_used_bytes: 620_000_000_000, disk_total_bytes: 1_800_000_000_000 },
apps: ['bitcoin-knots', 'lnd', 'electrs', 'mempool', 'filebrowser'],
tor_connected: true,
},
{
id: 'node-2', name: 'archy-198', did: 'did:key:z6Mkp2z3PJbJHbQ95fGk3CqYVNEPE3VNnFGA7yUYkQjXoZTL',
status: 'online', last_seen: new Date(Date.now() - 120000).toISOString(),
version: '1.2.1', uptime_secs: 259200,
system: { cpu_percent: 8.7, mem_used_bytes: 3_100_000_000, mem_total_bytes: 8_000_000_000, disk_used_bytes: 180_000_000_000, disk_total_bytes: 500_000_000_000 },
apps: ['bitcoin-knots', 'lnd', 'mempool'],
tor_connected: true,
},
{
id: 'node-3', name: 'archy-vps', did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5ER7eLYwBqPR4NkDhCUD7Li',
status: 'offline', last_seen: new Date(Date.now() - 3600000).toISOString(),
version: '1.3.0', uptime_secs: 0,
system: { cpu_percent: 0, mem_used_bytes: 0, mem_total_bytes: 4_000_000_000, disk_used_bytes: 45_000_000_000, disk_total_bytes: 80_000_000_000 },
apps: ['lnd'],
tor_connected: false,
},
],
},
})
}
case 'telemetry.fleet-alerts': {
return res.json({
result: {
alerts: [
{ id: 'fa1', node_id: 'node-3', node_name: 'archy-vps', kind: 'node_offline', message: 'Node went offline', timestamp: new Date(Date.now() - 3600000).toISOString(), acknowledged: false },
{ id: 'fa2', node_id: 'node-2', node_name: 'archy-198', kind: 'disk_high', message: 'Disk usage at 36%', timestamp: new Date(Date.now() - 86400000).toISOString(), acknowledged: true },
],
},
})
}
case 'telemetry.fleet-node-history': {
const nodeId = params?.node_id || 'node-1'
const points = 60
const history = []
for (let i = 0; i < points; i++) {
history.push({
timestamp: new Date(Date.now() - (points - i) * 60000).toISOString(),
cpu_percent: +(8 + Math.random() * 20).toFixed(1),
mem_used_bytes: 5_000_000_000 + Math.floor(Math.random() * 2_000_000_000),
})
}
return res.json({ result: { node_id: nodeId, history } })
}
default: { default: {
console.log(`[RPC] Unknown method: ${method}`) console.log(`[RPC] Unknown method: ${method}`)
return res.json({ return res.json({

View File

@ -7,8 +7,12 @@
@click="store.deactivate()" @click="store.deactivate()"
@keydown.escape="store.deactivate()" @keydown.escape="store.deactivate()"
> >
<!-- Logo with audio viz ring - explicitly centered in viewport --> <!-- ASCII variant (every 3rd activation) -->
<div class="screensaver-content"> <div v-if="store.isAsciiMode" class="screensaver-ascii-content">
<BitcoinFaceAscii />
</div>
<!-- Normal logo with audio viz ring -->
<div v-else class="screensaver-content">
<!-- Radial audio visualization - bars around the logo --> <!-- Radial audio visualization - bars around the logo -->
<div class="screensaver-viz-ring"> <div class="screensaver-viz-ring">
<div <div
@ -31,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue' import { onMounted, onBeforeUnmount } from 'vue'
import ScreensaverLogo from '@/components/ScreensaverLogo.vue' import ScreensaverLogo from '@/components/ScreensaverLogo.vue'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import { useScreensaverStore } from '@/stores/screensaver' import { useScreensaverStore } from '@/stores/screensaver'
const store = useScreensaverStore() const store = useScreensaverStore()
@ -180,4 +185,24 @@ onBeforeUnmount(() => {
z-index: 10; z-index: 10;
filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15)); filter: drop-shadow(0 0 40px rgba(255, 255, 255, 0.15));
} }
/* ASCII variant — centered Bitcoin face animation */
.screensaver-ascii-content {
display: flex;
align-items: center;
justify-content: center;
transform: scale(2);
}
@media (min-width: 640px) {
.screensaver-ascii-content {
transform: scale(2.5);
}
}
@media (min-width: 768px) {
.screensaver-ascii-content {
transform: scale(3);
}
}
</style> </style>

View File

@ -1,13 +1,18 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, computed } from 'vue'
const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes const INACTIVITY_MS = 3 * 60 * 1000 // 3 minutes
export const useScreensaverStore = defineStore('screensaver', () => { export const useScreensaverStore = defineStore('screensaver', () => {
const isActive = ref(false) const isActive = ref(false)
const activationCount = ref(0)
let inactivityTimer: ReturnType<typeof setTimeout> | null = null let inactivityTimer: ReturnType<typeof setTimeout> | null = null
/** True when the current activation is the ASCII variant (every 3rd time) */
const isAsciiMode = computed(() => activationCount.value > 0 && activationCount.value % 3 === 0)
function activate() { function activate() {
activationCount.value++
isActive.value = true isActive.value = true
clearInactivityTimer() clearInactivityTimer()
} }
@ -34,6 +39,8 @@ export const useScreensaverStore = defineStore('screensaver', () => {
return { return {
isActive, isActive,
isAsciiMode,
activationCount,
activate, activate,
deactivate, deactivate,
resetInactivityTimer, resetInactivityTimer,

View File

@ -330,14 +330,13 @@ input[type="radio"]:active + * {
} }
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar. /* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar.
Use dvh (dynamic viewport height) instead of 100% on a normal mobile browser, Subtract both the bottom tab bar AND the top safe-area offset (status bar on
100% resolves through the parent chain to the large viewport (100vh) which Android WebView / companion app) so the AIUI's internal tabs (chat/content/
is taller than the visible area when the browser chrome is showing. dvh context) stay above the tab bar instead of sliding underneath it. */
tracks the actual visible viewport. */
@media (max-width: 767px) { @media (max-width: 767px) {
.chat-iframe-mobile { .chat-iframe-mobile {
height: calc(100vh - var(--mobile-tab-bar-height, 72px)) !important; height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px)) !important; height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
flex: none; flex: none;
} }
} }

View File

@ -125,7 +125,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher' import { useAppLauncherStore } from '../stores/appLauncher'
@ -161,8 +161,10 @@ const isHomeRoute = computed(() => route.path === '/dashboard' || route.path ===
const isGlitching = ref(false) const isGlitching = ref(false)
const backgroundImage = computed(() => { const backgroundImage = computed(() => {
const mapped = ROUTE_BACKGROUNDS[route.path]
if (mapped) return mapped
if (isDetailRoute(route.path)) return 'bg-intro.jpg' if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return ROUTE_BACKGROUNDS[route.path] || 'bg-home.jpg' return 'bg-home.jpg'
}) })
const isDarkRoute = computed(() => { const isDarkRoute = computed(() => {
@ -188,7 +190,10 @@ watch(isDarkRoute, (dark) => {
} }
}) })
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path)) const WEB5_DETAIL_ROUTES = ['/dashboard/server/federation', '/dashboard/monitoring', '/dashboard/fleet']
const needsMobileBackButtonSpace = computed(() =>
isDetailRoute(route.path) || WEB5_DETAIL_ROUTES.includes(route.path)
)
const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null) const mobileNavRef = ref<InstanceType<typeof DashboardMobileNav> | null>(null)
@ -196,9 +201,49 @@ const mobileTabPaddingTop = computed(() => {
return mobileNavRef.value?.mobileTabPaddingTop ?? 0 return mobileNavRef.value?.mobileTabPaddingTop ?? 0
}) })
// Scroll position save/restore only restore when coming BACK from a detail page
const savedScrollPositions = new Map<string, number>()
let previousRoutePath = ''
function isAnyDetailRoute(path: string): boolean {
return isDetailRoute(path) || WEB5_DETAIL_ROUTES.includes(path)
}
function saveCurrentScroll() {
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
if (el && previousRoutePath) {
savedScrollPositions.set(previousRoutePath, el.scrollTop)
}
}
function restoreScroll(path: string) {
const saved = savedScrollPositions.get(path)
if (saved == null) return
nextTick(() => {
// Wait for transition to settle
setTimeout(() => {
const el = document.querySelector<HTMLElement>('.perspective-container .view-wrapper > div[class*="overflow-y-auto"]')
if (el) el.scrollTop = saved
}, 50)
})
}
watch(() => route.path, (newPath) => { watch(() => route.path, (newPath) => {
const isAppDetails = isDetailRoute(newPath) const isAppDetails = isDetailRoute(newPath)
const wasAppDetails = showAltBackground.value const wasAppDetails = showAltBackground.value
const oldPath = previousRoutePath
const wasDetail = isAnyDetailRoute(oldPath)
const isDetail = isAnyDetailRoute(newPath)
// Save scroll position of the page we're leaving
saveCurrentScroll()
// Restore scroll only when returning from a detail page to the parent list
if (wasDetail && !isDetail) {
restoreScroll(newPath)
}
previousRoutePath = newPath
showAltBackground.value = isAppDetails showAltBackground.value = isAppDetails
@ -211,6 +256,7 @@ watch(() => route.path, (newPath) => {
}) })
onMounted(() => { onMounted(() => {
previousRoutePath = route.path
document.body.classList.add('dashboard-active') document.body.classList.add('dashboard-active')
if (loginTransition.justLoggedIn) { if (loginTransition.justLoggedIn) {
playDashboardLoadOomph() playDashboardLoadOomph()

View File

@ -1,5 +1,25 @@
<template> <template>
<div class="pb-6 mobile-scroll-pad"> <div class="pb-16 md:pb-6 mobile-scroll-pad">
<!-- Desktop Back Button -->
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Web5
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<!-- Header --> <!-- Header -->
<div class="hidden md:block mb-8"> <div class="hidden md:block mb-8">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -98,6 +118,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'
import FleetOverviewCards from './fleet/FleetOverviewCards.vue' import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
import FleetNodeGrid from './fleet/FleetNodeGrid.vue' import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
import FleetAlerts from './fleet/FleetAlerts.vue' import FleetAlerts from './fleet/FleetAlerts.vue'
@ -105,5 +126,7 @@ import FleetNodeDetail from './fleet/FleetNodeDetail.vue'
import FleetContainerMatrix from './fleet/FleetContainerMatrix.vue' import FleetContainerMatrix from './fleet/FleetContainerMatrix.vue'
import { useFleetData, timeAgo } from './fleet/useFleetData' import { useFleetData, timeAgo } from './fleet/useFleetData'
const router = useRouter()
const fleet = useFleetData() const fleet = useFleetData()
</script> </script>

View File

@ -1,5 +1,25 @@
<template> <template>
<div class="pb-6"> <div class="pb-16 md:pb-6">
<!-- Desktop Back Button -->
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Web5
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<div class="hidden md:block mb-8"> <div class="hidden md:block mb-8">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@ -213,6 +233,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue' import LineChart from '@/components/LineChart.vue'
@ -274,6 +295,7 @@ interface FiredAlert {
acknowledged: boolean acknowledged: boolean
} }
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const current = ref<MetricSnapshot | null>(null) const current = ref<MetricSnapshot | null>(null)

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="min-h-full"> <div class="min-h-screen">
<BootScreen :visible="showBootScreen" @ready="onServerReady" /> <BootScreen :visible="showBootScreen" @ready="onServerReady" />
<div v-if="!showBootScreen" class="min-h-full flex items-center justify-center"> <div v-if="!showBootScreen" class="min-h-screen flex items-center justify-center">
<div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade"> <div class="flex flex-col items-center gap-4 opacity-0 root-redirect-fade">
<svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-8 w-8 text-white/60" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>

View File

@ -28,7 +28,9 @@ export const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard/mesh': 'bg-mesh.jpg', '/dashboard/mesh': 'bg-mesh.jpg',
'/dashboard/server': 'bg-network.jpg', '/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg', '/dashboard/web5': 'bg-web5.jpg',
'/dashboard/federation': 'bg-web5.jpg', '/dashboard/server/federation': 'bg-web5.jpg',
'/dashboard/monitoring': 'bg-web5.jpg',
'/dashboard/fleet': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.jpg', '/dashboard/settings': 'bg-settings.jpg',
'/dashboard/chat': 'bg-aiui.jpg', '/dashboard/chat': 'bg-aiui.jpg',
} }
@ -81,6 +83,15 @@ export function useRouteTransitions() {
const wasCloudFolder = previousPath.includes('/cloud/') && !previousPath.endsWith('/cloud') const wasCloudFolder = previousPath.includes('/cloud/') && !previousPath.endsWith('/cloud')
const wasCloudList = previousPath === '/dashboard/cloud' const wasCloudList = previousPath === '/dashboard/cloud'
const isFederation = currentPath === '/dashboard/server/federation'
const wasFederation = previousPath === '/dashboard/server/federation'
const isMonitoring = currentPath === '/dashboard/monitoring'
const wasMonitoring = previousPath === '/dashboard/monitoring'
const isFleet = currentPath === '/dashboard/fleet'
const wasFleet = previousPath === '/dashboard/fleet'
const isWeb5 = currentPath === '/dashboard/web5'
const wasWeb5 = previousPath === '/dashboard/web5'
let transitionName = 'fade' let transitionName = 'fade'
// Mobile: Horizontal slide transitions between sub-tabs // Mobile: Horizontal slide transitions between sub-tabs
@ -123,6 +134,18 @@ export function useRouteTransitions() {
transitionName = 'depth-forward' transitionName = 'depth-forward'
} else if (wasCloudFolder && isCloudList) { } else if (wasCloudFolder && isCloudList) {
transitionName = 'depth-back' transitionName = 'depth-back'
} else if (wasWeb5 && isFederation) {
transitionName = 'depth-forward'
} else if (wasFederation && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isMonitoring) {
transitionName = 'depth-forward'
} else if (wasMonitoring && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isFleet) {
transitionName = 'depth-forward'
} else if (wasFleet && isWeb5) {
transitionName = 'depth-back'
} else if (wasMarketplaceList && isAppDetails) { } else if (wasMarketplaceList && isAppDetails) {
transitionName = 'depth-forward' transitionName = 'depth-forward'
} else if (wasAppDetails && isMarketplaceList) { } else if (wasAppDetails && isMarketplaceList) {

View File

@ -2,13 +2,24 @@
<div class="mb-6"> <div class="mb-6">
<button <button
@click="router.push('/dashboard/web5')" @click="router.push('/dashboard/web5')"
class="flex items-center gap-2 text-white/50 hover:text-white/80 transition-colors text-sm mb-4" class="hidden md:flex items-center gap-2 text-white/70 hover:text-white transition-colors mb-4"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back to Web5 Web5
</button> </button>
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1> <h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>

View File

@ -129,7 +129,7 @@
{{ t('common.send') }} {{ t('common.send') }}
</button> </button>
<button @click="$emit('showReceive')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"> <button @click="$emit('showReceive')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
{{ t('web5.receiveBitcoin') }} Receive
</button> </button>
<button @click="$emit('showTransactions')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"> <button @click="$emit('showTransactions')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Transactions Transactions

View File

@ -280,10 +280,12 @@ loadBackups()
<!-- Lightning Channel Backup --> <!-- Lightning Channel Backup -->
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">Lightning Channel Backup</h2> <h2 class="text-xl font-semibold text-white/96 mb-1">Lightning Channel Backup</h2>
<p class="text-sm text-white/60 mb-3">Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.</p> <p class="text-sm text-white/60">Export your channel state so you can restore channels on a new node. Does not include on-chain wallet seed.</p>
<div class="flex gap-3"> </div>
<button @click="exportChannelBackup" :disabled="exportingChannelBackup" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"> <button @click="exportChannelBackup" :disabled="exportingChannelBackup" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 w-full md:w-auto shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>

View File

@ -49,12 +49,12 @@ async function performFactoryReset() {
<template> <template>
<!-- Network Diagnostics Link --> <!-- Network Diagnostics Link -->
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div> <div>
<h2 class="text-xl font-semibold text-white/96">{{ t('common.network') }}</h2> <h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('common.network') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.networkDesc') }}</p> <p class="text-sm text-white/60">{{ t('settings.networkDesc') }}</p>
</div> </div>
<button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2"> <button @click="router.push('/dashboard/server')" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center justify-center gap-2 w-full md:w-auto shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg> </svg>
@ -64,14 +64,14 @@ async function performFactoryReset() {
</div> </div>
<!-- Reboot Section --> <!-- Reboot Section -->
<div class="path-option-card px-6 py-6 mt-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div> <div>
<h2 class="text-xl font-semibold text-white/90 mb-1">Reboot</h2> <h2 class="text-xl font-semibold text-white/96 mb-1">Reboot</h2>
<p class="text-sm text-white/60">Restart the machine. All containers will restart automatically.</p> <p class="text-sm text-white/60">Restart the machine. All containers will restart automatically.</p>
</div> </div>
<button <button
class="glass-button px-6 py-2 text-sm" class="glass-button px-6 py-2 rounded-lg text-sm w-full md:w-auto shrink-0"
:disabled="rebooting" :disabled="rebooting"
@click="showRebootConfirm = true" @click="showRebootConfirm = true"
> >
@ -109,18 +109,22 @@ async function performFactoryReset() {
</Teleport> </Teleport>
<!-- Factory Reset Section --> <!-- Factory Reset Section -->
<div class="path-option-card px-6 py-6 mt-6 border-red-500/30"> <div class="glass-card px-6 py-6 mb-6 border border-red-500/30">
<h2 class="text-xl font-semibold text-red-400/90 mb-3">Factory Reset</h2> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<p class="text-sm text-white/60 mb-4"> <div>
<h2 class="text-xl font-semibold text-red-400/90 mb-1">Factory Reset</h2>
<p class="text-sm text-white/60">
Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen. Wipe all user data, identities, and credentials. Container images are preserved. The node will restart and show the onboarding screen.
</p> </p>
</div>
<button <button
class="glass-button glass-button-danger" class="glass-button glass-button-danger w-full md:w-auto shrink-0"
@click="showFactoryResetConfirm = true" @click="showFactoryResetConfirm = true"
> >
Factory Reset Factory Reset
</button> </button>
</div> </div>
</div>
<!-- Factory Reset Confirmation Modal --> <!-- Factory Reset Confirmation Modal -->
<Teleport to="body"> <Teleport to="body">

View File

@ -55,50 +55,26 @@
</div> </div>
</Teleport> </Teleport>
<!-- Core Services Overview Cards -- Row 1 --> <!-- Connected Nodes + Node Visibility -->
<div class="flex flex-col md:flex-row gap-6 mb-6"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<Web5Domains ref="domainsRef" :showStagger="showStagger" :managedIdentities="identitiesRef?.managedIdentities ?? []" /> <Web5ConnectedNodes ref="connectedNodesRef" @toast="showToast" />
<Web5Wallet
:showStagger="showStagger"
:walletConnected="walletConnected"
:walletError="walletError"
:lndOnchainBalance="lndOnchainBalance"
:lndChannelBalance="lndChannelBalance"
:ecashBalance="ecashBalance"
:incomingTransactions="incomingTransactions"
:incomingTxCount="incomingTxCount"
:txActivityCount="txActivityCount"
:meshRelayActive="sendReceiveRef?.meshRelayActive ?? false"
:meshRelayStatus="sendReceiveRef?.meshRelayStatus ?? ''"
:sendResultTxid="sendReceiveRef?.sendResultTxid ?? ''"
@openSend="sendReceiveRef?.openSend()"
@openReceive="sendReceiveRef?.openReceive()"
/>
</div>
<!-- Core Services Overview Cards -- Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<Web5NostrRelays ref="nostrRelaysRef" :showStagger="showStagger" />
<Web5NodeVisibility :showStagger="showStagger" ref="nodeVisibilityRef" @toast="showToast" /> <Web5NodeVisibility :showStagger="showStagger" ref="nodeVisibilityRef" @toast="showToast" />
</div> </div>
<!-- Connected Nodes + Shared Content grid --> <!-- Identities + Nostr Relays -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<Web5ConnectedNodes ref="connectedNodesRef" @toast="showToast" />
<Web5SharedContent ref="sharedContentRef" :showStagger="showStagger" :peers="connectedNodesRef?.peers ?? []" @toast="showToast" />
</div>
<!-- Identities + DWN grid -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<Web5Identities ref="identitiesRef" :showStagger="showStagger" @toast="showToast" /> <Web5Identities ref="identitiesRef" :showStagger="showStagger" @toast="showToast" />
<Web5DWN ref="dwnRef" /> <Web5NostrRelays ref="nostrRelaysRef" :showStagger="showStagger" />
</div> </div>
<!-- Verifiable Credentials --> <!-- Monitoring + Federation -->
<Web5CredentialsSummary ref="credentialsRef" :identityCount="identitiesRef?.managedIdentities?.length ?? 0" /> <div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<Web5Monitoring />
<Web5Federation />
</div>
<!-- Send/Receive Modals --> <!-- Send/Receive Modals hidden wallet card removed -->
<Web5SendReceiveModals ref="sendReceiveRef" @toast="showToast" @balancesChanged="reloadBalances" /> <!-- <Web5SendReceiveModals ref="sendReceiveRef" @toast="showToast" @balancesChanged="reloadBalances" /> -->
<!-- Identity Toast --> <!-- Identity Toast -->
<Transition name="content-fade"> <Transition name="content-fade">
@ -122,16 +98,18 @@ import { safeClipboardWrite } from './utils'
import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types' import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
import Web5QuickActions from './Web5QuickActions.vue' import Web5QuickActions from './Web5QuickActions.vue'
import Web5Wallet from './Web5Wallet.vue' // import Web5Wallet from './Web5Wallet.vue' // hidden for now
import Web5Domains from './Web5Domains.vue' // import Web5Domains from './Web5Domains.vue' // hidden for now
import Web5NostrRelays from './Web5NostrRelays.vue' import Web5NostrRelays from './Web5NostrRelays.vue'
import Web5NodeVisibility from './Web5NodeVisibility.vue' import Web5NodeVisibility from './Web5NodeVisibility.vue'
import Web5ConnectedNodes from './Web5ConnectedNodes.vue' import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
import Web5SharedContent from './Web5SharedContent.vue' // import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
import Web5Identities from './Web5Identities.vue' import Web5Identities from './Web5Identities.vue'
import Web5DWN from './Web5DWN.vue' // import Web5DWN from './Web5DWN.vue' // hidden for now
import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
import Web5SendReceiveModals from './Web5SendReceiveModals.vue' import Web5Monitoring from './Web5Monitoring.vue'
import Web5Federation from './Web5Federation.vue'
// import Web5SendReceiveModals from './Web5SendReceiveModals.vue' // wallet hidden
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
@ -139,15 +117,15 @@ const { t } = useI18n()
const showStagger = !web5AnimationDone const showStagger = !web5AnimationDone
// Child refs // Child refs
const domainsRef = ref<InstanceType<typeof Web5Domains> | null>(null) // const domainsRef = ref(null) // hidden for now
const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null) const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null)
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null) const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null) const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null) const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
const dwnRef = ref<InstanceType<typeof Web5DWN> | null>(null) // const dwnRef = ref(null) // hidden for now
const credentialsRef = ref<InstanceType<typeof Web5CredentialsSummary> | null>(null) // const credentialsRef = ref(null) // hidden for now
const sharedContentRef = ref<InstanceType<typeof Web5SharedContent> | null>(null) // const sharedContentRef = ref(null) // hidden for now
const sendReceiveRef = ref<InstanceType<typeof Web5SendReceiveModals> | null>(null) // const sendReceiveRef = ref(null) // wallet hidden
// --- Toast --- // --- Toast ---
const identityToastText = ref('') const identityToastText = ref('')
@ -323,13 +301,8 @@ const lndChannelBalance = ref(0)
const walletError = ref('') const walletError = ref('')
const ecashBalance = ref(0) const ecashBalance = ref(0)
// Transactions // Transactions wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
const walletTransactions = ref<WalletTransaction[]>([]) const walletTransactions = ref<WalletTransaction[]>([])
const incomingTransactions = computed(() =>
walletTransactions.value.filter(tx => tx.direction === 'incoming' && tx.num_confirmations < 3)
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
const txActivityCount = computed(() => incomingTxCount.value + (sendReceiveRef.value?.meshRelayActive ? 1 : 0))
// Hardware wallets // Hardware wallets
const detectedHwWallets = ref<HwWalletDevice[]>([]) const detectedHwWallets = ref<HwWalletDevice[]>([])
@ -392,11 +365,11 @@ async function detectHardwareWallets() {
} }
} }
function reloadBalances() { // function reloadBalances() { // wallet hidden
loadLndBalances() // loadLndBalances()
loadEcashBalance() // loadEcashBalance()
loadTransactions() // loadTransactions()
} // }
// Auto-refresh wallet data every 30s // Auto-refresh wallet data every 30s
let walletRefreshInterval: ReturnType<typeof setInterval> | null = null let walletRefreshInterval: ReturnType<typeof setInterval> | null = null
@ -418,12 +391,12 @@ onMounted(() => {
connectedNodesRef.value?.loadConnectionRequests() connectedNodesRef.value?.loadConnectionRequests()
identitiesRef.value?.loadIdentities() identitiesRef.value?.loadIdentities()
nodeVisibilityRef.value?.loadVisibility() nodeVisibilityRef.value?.loadVisibility()
domainsRef.value?.loadDomainNames() // domainsRef.value?.loadDomainNames() // hidden for now
nostrRelaysRef.value?.loadNostrRelays() nostrRelaysRef.value?.loadNostrRelays()
dwnRef.value?.loadDwnStatus() // dwnRef.value?.loadDwnStatus() // hidden for now
dwnRef.value?.loadDwnProtocols() // dwnRef.value?.loadDwnProtocols() // hidden for now
credentialsRef.value?.loadCredentials() // credentialsRef.value?.loadCredentials() // hidden for now
sharedContentRef.value?.loadContentItems() // sharedContentRef.value?.loadContentItems() // hidden for now
// Load local state data // Load local state data
loadEcashBalance() loadEcashBalance()

View File

@ -10,7 +10,6 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2> <h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.peerNodesDescription') }}</p>
</div> </div>
<div class="flex gap-2 shrink-0"> <div class="flex gap-2 shrink-0">
<button <button
@ -37,7 +36,6 @@
</div> </div>
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2> <h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
</div> </div>
<p class="text-white/70 text-sm mb-3">{{ t('web5.peerNodesDescription') }}</p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button <button
@click="router.push('/dashboard/server/federation')" @click="router.push('/dashboard/server/federation')"

View File

@ -0,0 +1,88 @@
<template>
<!-- Federation Summary -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Federation</h2>
<p class="text-xs text-white/60">Federated nodes &amp; peers</p>
</div>
</div>
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Details
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
<div class="space-y-3">
<!-- Node Count -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="nodeCount > 0 ? 'bg-green-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Known Nodes</span>
</div>
<span class="text-sm font-medium text-white">{{ nodeCount }}</span>
</div>
<!-- Online -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="onlineCount > 0 ? 'bg-green-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Online</span>
</div>
<span class="text-sm font-medium" :class="onlineCount > 0 ? 'text-green-400' : 'text-white/40'">{{ onlineCount }}</span>
</div>
<!-- Pending Requests -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full" :class="pendingCount > 0 ? 'bg-orange-400' : 'bg-white/30'" />
<span class="text-sm text-white/80">Pending Requests</span>
</div>
<span class="text-sm font-medium" :class="pendingCount > 0 ? 'text-orange-400' : 'text-white/40'">{{ pendingCount }}</span>
</div>
<!-- Self DID -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span class="text-sm text-white/80">Node DID</span>
<span class="text-sm font-medium text-white/40 truncate max-w-[140px]">{{ selfDid || 'Not set' }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const nodeCount = ref(0)
const onlineCount = ref(0)
const pendingCount = ref(0)
const selfDid = ref('')
onMounted(async () => {
try {
const res = await rpcClient.call<{
nodes: Array<{ status: string }>
pending_requests: Array<unknown>
}>({ method: 'federation.list', timeout: 5000 })
const nodes = res.nodes || []
nodeCount.value = nodes.length
onlineCount.value = nodes.filter(n => n.status === 'online' || n.status === 'connected').length
pendingCount.value = (res.pending_requests || []).length
} catch { /* unavailable */ }
try {
const res = await rpcClient.getNodeDid()
selfDid.value = res.did || ''
} catch { /* unavailable */ }
})
</script>

View File

@ -0,0 +1,133 @@
<template>
<!-- System Monitoring Summary -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">Monitoring</h2>
<p class="text-xs text-white/60">System resources &amp; health</p>
</div>
</div>
<RouterLink to="/dashboard/monitoring" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
Details
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</RouterLink>
</div>
<div class="space-y-3">
<!-- CPU -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">CPU</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(cpuPercent)" :style="{ width: cpuPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ cpuPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Memory -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">Memory</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(memPercent)" :style="{ width: memPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ memPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Disk -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-sm text-white/80">Disk</span>
</div>
<div class="flex items-center gap-3">
<div class="w-24 h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="barColor(diskPercent)" :style="{ width: diskPercent + '%' }" />
</div>
<span class="text-sm font-medium text-white min-w-[3rem] text-right">{{ diskPercent.toFixed(0) }}%</span>
</div>
</div>
<!-- Uptime -->
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span class="text-sm text-white/80">Uptime</span>
<span class="text-sm font-medium text-white/60">{{ uptimeDisplay }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const cpuPercent = ref(0)
const memUsed = ref(0)
const memTotal = ref(0)
const diskUsed = ref(0)
const diskTotal = ref(0)
const uptimeSecs = ref(0)
const memPercent = computed(() => memTotal.value > 0 ? (memUsed.value / memTotal.value) * 100 : 0)
const diskPercent = computed(() => diskTotal.value > 0 ? (diskUsed.value / diskTotal.value) * 100 : 0)
const uptimeDisplay = computed(() => {
const s = uptimeSecs.value
if (s === 0) return '--'
const days = Math.floor(s / 86400)
const hours = Math.floor((s % 86400) / 3600)
const mins = Math.floor((s % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
return `${hours}h ${mins}m`
})
function barColor(pct: number): string {
if (pct > 85) return 'bg-red-400'
if (pct > 60) return 'bg-orange-400'
return 'bg-green-400'
}
async function loadStats() {
try {
const res = await rpcClient.call<{
cpu_usage_percent: number
mem_used_bytes: number
mem_total_bytes: number
disk_used_bytes: number
disk_total_bytes: number
uptime_secs: number
}>({ method: 'system.stats' })
cpuPercent.value = res.cpu_usage_percent
memUsed.value = res.mem_used_bytes
memTotal.value = res.mem_total_bytes
diskUsed.value = res.disk_used_bytes
diskTotal.value = res.disk_total_bytes
uptimeSecs.value = res.uptime_secs
} catch { /* unavailable */ }
}
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
loadStats()
refreshInterval = setInterval(loadStats, 30000)
})
onBeforeUnmount(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<!-- Node Visibility --> <!-- Node Visibility -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 3"> <div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0"> <div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center"> <div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -1,6 +1,6 @@
<template> <template>
<!-- Nostr Relays --> <!-- Nostr Relays -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 2"> <div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 2">
<div class="flex items-start gap-4 mb-4 shrink-0"> <div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center"> <div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -1,6 +1,6 @@
<template> <template>
<!-- Wallet --> <!-- Wallet -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col md:w-1/2" style="--stagger-index: 1"> <div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="glass-card p-6 flex flex-col" style="--stagger-index: 1">
<div class="flex items-start gap-4 mb-4 shrink-0"> <div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center"> <div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">