feat: Discover view, Fleet dashboard, MeshMap, type fixes

- New Discover.vue (app store redesign)
- Fleet.vue dashboard for .228
- MeshMap.vue component
- Fixed Discover.vue type errors (unused var, type predicate)
- Various UI updates (Apps, Dashboard, Marketplace, Mesh, Web5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-19 16:12:01 +00:00
parent 851d8001d6
commit 623c0fa954
18 changed files with 3067 additions and 174 deletions

View File

@@ -0,0 +1,590 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import type { NodePosition } from '@/stores/mesh'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
const mesh = useMeshStore()
const mapContainer = ref<HTMLElement | null>(null)
let map: L.Map | null = null
const markersLayer = ref<L.LayerGroup | null>(null)
const linesLayer = ref<L.LayerGroup | null>(null)
// Whether we have any position data to show
const hasPositions = computed(() => mesh.nodePositions.size > 0)
// Location sharing state
const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
let geoWatchId: number | null = null
function toggleLocationSharing() {
if (sharingLocation.value) {
stopSharing()
} else {
startSharing()
}
}
function switchSource(source: 'browser' | 'device') {
locationSource.value = source
if (sharingLocation.value) {
stopSharing()
startSharing()
}
}
function startSharing() {
locationError.value = ''
if (locationSource.value === 'browser') {
if (!navigator.geolocation) {
locationError.value = 'Geolocation not supported'
return
}
geoWatchId = navigator.geolocation.watchPosition(
(pos) => {
mesh.updateSelfPosition(pos.coords.latitude, pos.coords.longitude, 'This Node')
sharingLocation.value = true
locationError.value = ''
},
(err) => {
locationError.value = err.code === 1 ? 'Location permission denied' : err.message
sharingLocation.value = false
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 },
)
sharingLocation.value = true
} else {
// Device GPS — position data comes from deadman/mesh GPS module
sharingLocation.value = true
}
}
function stopSharing() {
if (geoWatchId !== null) {
navigator.geolocation.clearWatch(geoWatchId)
geoWatchId = null
}
sharingLocation.value = false
mesh.nodePositions.delete(-1)
}
function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon {
const colorMap = {
self: { bg: '#fb923c', border: '#f59e0b', shadow: 'rgba(251,146,60,0.5)' },
online: { bg: '#4ade80', border: '#22c55e', shadow: 'rgba(74,222,128,0.4)' },
offline: { bg: '#6b7280', border: '#4b5563', shadow: 'rgba(107,114,128,0.3)' },
}
const c = colorMap[type]
const size = type === 'self' ? 16 : 12
const pulse = type === 'self'
? `<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:${size + 12}px;height:${size + 12}px;border-radius:50%;background:${c.shadow};animation:mesh-map-pulse 2s infinite;"></div>`
: ''
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size + 12, size + 12],
iconAnchor: [(size + 12) / 2, (size + 12) / 2],
popupAnchor: [0, -(size / 2 + 6)],
html: `
${pulse}
<div style="
width:${size}px;height:${size}px;
border-radius:50%;
background:${c.bg};
border:2px solid ${c.border};
box-shadow:0 0 8px ${c.shadow};
position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%);
z-index:2;
"></div>
`,
})
}
function getSignalBars(rssi: number | null): string {
if (rssi === null) return 'Unknown'
if (rssi >= -70) return 'Strong'
if (rssi >= -90) return 'Good'
if (rssi >= -110) return 'Weak'
return 'Very Weak'
}
function formatLastHeard(timestamp: string): string {
const now = Date.now()
const then = new Date(timestamp).getTime()
const diffSecs = Math.floor((now - then) / 1000)
if (diffSecs < 60) return 'Just now'
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`
return `${Math.floor(diffSecs / 86400)}d ago`
}
function truncatePubkey(pubkey: string | null): string {
if (!pubkey) return 'No pubkey'
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
function buildPopupContent(
name: string,
pubkey: string | null,
rssi: number | null,
lastHeard: string,
hops: number,
isSelf: boolean,
): string {
const signal = getSignalBars(rssi)
const heard = formatLastHeard(lastHeard)
const truncPk = truncatePubkey(pubkey)
const selfBadge = isSelf
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">THIS NODE</span>'
: ''
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:160px;">
<div style="font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
${name}${selfBadge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:8px;word-break:break-all;">
${truncPk}
</div>
<div style="display:flex;flex-direction:column;gap:3px;font-size:0.78rem;">
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Signal: <span style="color:${rssi !== null && rssi >= -90 ? '#4ade80' : '#fbbf24'};">${signal}</span>${rssi !== null ? ` (${rssi} dBm)` : ''}</div>` : ''}
${!isSelf ? `<div style="color:rgba(255,255,255,0.7);">Hops: <span style="color:rgba(255,255,255,0.9);">${hops}</span></div>` : ''}
<div style="color:rgba(255,255,255,0.7);">Last heard: <span style="color:rgba(255,255,255,0.9);">${heard}</span></div>
</div>
</div>
`
}
function initMap() {
if (!mapContainer.value || map) return
const el = mapContainer.value
const rect = el.getBoundingClientRect()
// If container has no height yet, retry
if (rect.height < 10) {
setTimeout(initMap, 150)
return
}
map = L.map(el, {
zoomControl: true,
attributionControl: true,
center: [30, 0],
zoom: 3,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19,
detectRetina: true,
}).addTo(map)
markersLayer.value = L.layerGroup().addTo(map)
linesLayer.value = L.layerGroup().addTo(map)
// Give Leaflet a frame to measure, then invalidate
requestAnimationFrame(() => {
if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
// Style attribution for dark theme
const attrib = el.querySelector('.leaflet-control-attribution')
if (attrib instanceof HTMLElement) {
attrib.style.background = 'rgba(0,0,0,0.6)'
attrib.style.color = 'rgba(255,255,255,0.4)'
attrib.style.fontSize = '0.65rem'
}
updateMarkers()
}
function updateMarkers() {
if (!map || !markersLayer.value || !linesLayer.value) return
markersLayer.value.clearLayers()
linesLayer.value.clearLayers()
const positions = mesh.nodePositions
if (positions.size === 0) return
const bounds: L.LatLngExpression[] = []
const selfPos = positions.get(-1)
// Find which contact_ids are in the peers list (for online status)
const peerMap = new Map(mesh.peers.map(p => [p.contact_id, p]))
positions.forEach((pos: NodePosition, contactId: number) => {
const isSelf = contactId === -1
const peer = peerMap.get(contactId)
const isOnline = isSelf || !!peer
const marker = L.marker([pos.lat, pos.lng], {
icon: createMarkerIcon(isSelf ? 'self' : isOnline ? 'online' : 'offline'),
})
const name = isSelf
? (mesh.status?.self_advert_name ?? 'This Node')
: (peer?.advert_name ?? pos.label ?? `Node ${contactId}`)
const pubkey = isSelf ? null : (peer?.pubkey_hex ?? null)
const rssi = peer?.rssi ?? null
const lastHeard = isSelf ? new Date().toISOString() : (peer?.last_heard ?? pos.timestamp)
const hops = peer?.hops ?? 0
marker.bindPopup(buildPopupContent(name, pubkey, rssi, lastHeard, hops, isSelf), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([pos.lat, pos.lng])
// Draw dashed line from self to each connected peer
if (!isSelf && selfPos) {
const line = L.polyline(
[[selfPos.lat, selfPos.lng], [pos.lat, pos.lng]],
{
color: isOnline ? 'rgba(74,222,128,0.4)' : 'rgba(107,114,128,0.3)',
weight: 1.5,
dashArray: '6, 8',
opacity: 0.7,
},
)
linesLayer.value!.addLayer(line)
}
})
// Fit map to show all markers
if (bounds.length > 1) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
} else if (bounds.length === 1 && bounds[0]) {
map.setView(bounds[0], 12)
}
}
function handleResize() {
if (map) {
map.invalidateSize()
}
}
// Watch for changes in node positions and peers
watch(
() => [mesh.nodePositions.size, mesh.peers.length],
() => {
updateMarkers()
},
)
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
window.addEventListener('resize', handleResize)
if (mapContainer.value) {
resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { height } = entry.contentRect
if (!map && height > 10) {
initMap()
} else if (map) {
try { map.invalidateSize() } catch { /* destroyed */ }
}
})
resizeObserver.observe(mapContainer.value)
}
// Fallback init
setTimeout(initMap, 300)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopSharing()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (map) {
map.remove()
map = null
}
markersLayer.value = null
linesLayer.value = null
})
</script>
<template>
<div class="mesh-map-outer">
<div ref="mapContainer" class="mesh-map-inner"></div>
<!-- Floating hint when no positions -->
<div v-if="!hasPositions && !sharingLocation" class="mesh-map-hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>Enable location sharing to see nodes on the map</span>
</div>
<!-- Location sharing overlay -->
<div class="mesh-map-location-bar">
<div class="mesh-map-location-row">
<svg class="mesh-map-location-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span class="mesh-map-location-label">Share Location</span>
<button
class="mesh-map-toggle"
:class="{ active: sharingLocation }"
role="switch"
:aria-checked="sharingLocation"
@click="toggleLocationSharing"
>
<span class="mesh-map-toggle-knob" />
</button>
</div>
<!-- Source selector (visible when sharing and device GPS available) -->
<div v-if="sharingLocation && hasDeviceGps" class="mesh-map-source-row">
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'browser' }"
@click="switchSource('browser')"
>This Machine</button>
<button
class="mesh-map-source-btn"
:class="{ active: locationSource === 'device' }"
@click="switchSource('device')"
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
</div>
</div>
</template>
<style>
/* Must be unscoped — Leaflet creates DOM nodes dynamically */
/* CRITICAL: Override Tailwind's img { max-width: 100% } which breaks Leaflet tiles */
.mesh-map-inner img {
max-width: none !important;
max-height: none !important;
}
.mesh-map-outer {
width: 100%;
height: 100%;
min-height: 420px;
position: relative;
overflow: hidden;
}
.mesh-map-inner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #1a1a2e;
}
/* Leaflet adds .leaflet-container to the element itself (not a child) */
.mesh-map-inner.leaflet-container {
background: #1a1a2e;
}
.mesh-map-hint {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
z-index: 400;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 0.78rem;
white-space: nowrap;
pointer-events: none;
}
/* ─── Location sharing overlay ─── */
.mesh-map-location-bar {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 500;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.mesh-map-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.mesh-map-location-icon {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.mesh-map-location-label {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 1;
}
/* Toggle switch */
.mesh-map-toggle {
width: 36px;
height: 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.1);
cursor: pointer;
position: relative;
padding: 0;
flex-shrink: 0;
transition: all 0.25s ease;
}
.mesh-map-toggle.active {
background: rgba(251, 146, 60, 0.35);
border-color: rgba(251, 146, 60, 0.5);
}
.mesh-map-toggle-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
position: absolute;
top: 2px;
left: 2px;
transition: all 0.25s ease;
}
.mesh-map-toggle.active .mesh-map-toggle-knob {
left: 18px;
background: #fb923c;
}
/* Source selector */
.mesh-map-source-row {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 2px;
}
.mesh-map-source-btn {
flex: 1;
padding: 4px 8px;
font-size: 0.7rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.mesh-map-source-btn:hover {
color: rgba(255, 255, 255, 0.7);
}
.mesh-map-source-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.mesh-map-location-error {
font-size: 0.7rem;
color: #ef4444;
}
</style>
<style>
/* Global styles for Leaflet popup theming - must not be scoped */
.mesh-map-popup .leaflet-popup-content-wrapper {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: #fff;
}
.mesh-map-popup .leaflet-popup-tip {
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mesh-map-popup .leaflet-popup-close-button {
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
padding: 4px 8px;
}
.mesh-map-popup .leaflet-popup-close-button:hover {
color: rgba(255, 255, 255, 0.9);
}
/* Marker wrapper reset */
.mesh-map-marker-wrapper {
background: none !important;
border: none !important;
}
/* Pulse animation for self marker */
@keyframes mesh-map-pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
/* Dark theme for Leaflet zoom controls */
.mesh-map-inner .leaflet-control-zoom a {
background: rgba(0, 0, 0, 0.7) !important;
color: rgba(255, 255, 255, 0.8) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.mesh-map-inner .leaflet-control-zoom a:hover {
background: rgba(0, 0, 0, 0.85) !important;
color: #fff !important;
}
</style>

View File

@@ -103,6 +103,13 @@ export interface BlockHeader {
announced_by: string
}
export interface NodePosition {
lat: number
lng: number
label?: string
timestamp: string
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
@@ -111,6 +118,9 @@ export const useMeshStore = defineStore('mesh', () => {
const error = ref<string | null>(null)
const sending = ref(false)
// Node position tracking for map view (contact_id -> position)
const nodePositions = ref<Map<number, NodePosition>>(new Map())
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
@@ -161,11 +171,72 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
messages.value = res.messages
// Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages)
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh messages'
}
}
// Convert microdegrees (from mesh protocol) to degrees for Leaflet
// Values > 90 for lat or > 180 for lng indicate microdegrees
function toDegreesIfMicro(lat: number, lng: number): { lat: number; lng: number } {
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return { lat: lat / 1000000, lng: lng / 1000000 }
}
return { lat, lng }
}
function updateNodePositionsFromMessages(msgs: MeshMessage[]) {
for (const msg of msgs) {
if (msg.message_type === 'coordinate' && msg.typed_payload) {
const payload = msg.typed_payload as CoordinateData
if (typeof payload.lat === 'number' && typeof payload.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.lat, payload.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.label,
timestamp: msg.timestamp,
})
}
}
}
// Also extract coordinates from alert messages that include location
if (msg.message_type === 'alert' && msg.typed_payload) {
const payload = msg.typed_payload as AlertData
if (payload.coordinate && typeof payload.coordinate.lat === 'number' && typeof payload.coordinate.lng === 'number') {
const existing = nodePositions.value.get(msg.peer_contact_id)
if (!existing || msg.timestamp > existing.timestamp) {
const deg = toDegreesIfMicro(payload.coordinate.lat, payload.coordinate.lng)
nodePositions.value.set(msg.peer_contact_id, {
lat: deg.lat,
lng: deg.lng,
label: payload.coordinate.label,
timestamp: msg.timestamp,
})
}
}
}
}
}
function getNodePositions(): Map<number, NodePosition> {
return nodePositions.value
}
// Update self node position from deadman GPS data (contact_id = -1 for self)
function updateSelfPosition(lat: number, lng: number, label?: string) {
nodePositions.value.set(-1, {
lat,
lng,
label: label ?? 'This Node',
timestamp: new Date().toISOString(),
})
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
@@ -368,6 +439,7 @@ export const useMeshStore = defineStore('mesh', () => {
sending,
unreadCounts,
totalUnread,
nodePositions,
deadmanStatus,
blockHeaders,
latestBlockHeight,
@@ -385,6 +457,8 @@ export const useMeshStore = defineStore('mesh', () => {
sendAlert,
getSessionStatus,
rotatePrekeys,
getNodePositions,
updateSelfPosition,
fetchDeadmanStatus,
configureDeadman,
deadmanCheckin,

View File

@@ -6,7 +6,7 @@
<div class="hidden md:flex items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
@@ -100,7 +100,8 @@
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
role="link"
class="glass-card card-stagger p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="goToApp(id as string)"
@keydown.enter="goToApp(id as string)"
@@ -307,21 +308,30 @@
</div>
</template>
<script lang="ts">
// Module-level — persists across component unmount/remount within same session
// Prevents stagger animation replaying every time user navigates back to Apps
let appsAnimationDone = false
</script>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
const { t } = useI18n()
import { useAppLauncherStore } from '../stores/appLauncher'
import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
// Only stagger-animate on first mount — skip on revisits
const showStagger = !appsAnimationDone
// Tabs — support ?tab=services from Marketplace link
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
@@ -406,6 +416,7 @@ const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {

View File

@@ -197,9 +197,10 @@
to="/dashboard/apps"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
>My Apps</RouterLink>
<RouterLink
to="/dashboard/marketplace"
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
>App Store</RouterLink>
@@ -302,7 +303,7 @@
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/app-session'))
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
@@ -428,6 +429,7 @@ const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard': 'bg-home.jpg',
'/dashboard/': 'bg-home.jpg',
'/dashboard/apps': 'bg-myapps.jpg',
'/dashboard/discover': 'bg-appstore.jpg',
'/dashboard/marketplace': 'bg-appstore.jpg',
'/dashboard/cloud': 'bg-cloud.jpg',
'/dashboard/mesh': 'bg-mesh.jpg',
@@ -449,6 +451,7 @@ const isDarkRoute = computed(() => {
p.includes('/dashboard/settings') ||
(p.includes('/dashboard/apps') && !isDetailRoute(p)) ||
p.includes('/dashboard/marketplace') ||
p.includes('/dashboard/discover') ||
p.includes('/dashboard/cloud')
})

View File

@@ -6,13 +6,14 @@
<div class="hidden md:flex mb-6 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn mode-switcher-btn-active"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
<button
v-for="category in categoriesWithApps"
@@ -670,9 +671,6 @@ const categoriesWithApps = computed(() => {
})
})
const currentCategoryName = computed(() => {
return categories.value.find(c => c.id === selectedCategory.value)?.name || 'All'
})
const filteredApps = computed(() => {
let apps = allApps.value
@@ -727,7 +725,7 @@ const featuredApps = computed<FeaturedApp[]>(() => {
.map(f => {
const app = allApps.value.find(a => a.id === f.id)
if (!app) return null
return { ...app, featuredDescription: f.desc, privacyTag: f.tag }
return { ...app, featuredDescription: f.desc, privacyTag: f.tag } as FeaturedApp
})
.filter((a): a is FeaturedApp => a !== null)
})

View File

@@ -0,0 +1,733 @@
<template>
<div class="pb-6 mobile-scroll-pad">
<!-- Header -->
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
<p class="text-white/70">Beta Telemetry monitoring {{ nodes.length }} node{{ nodes.length !== 1 ? 's' : '' }}</p>
</div>
<div class="flex gap-2 items-center">
<span v-if="autoRefresh" class="text-xs text-white/40">Auto-refresh 60s</span>
<button class="glass-button text-sm px-4 py-2" @click="toggleAutoRefresh">
{{ autoRefresh ? 'Pause' : 'Resume' }}
</button>
<button class="glass-button text-sm px-4 py-2" @click="refreshAll">
Refresh
</button>
<button class="glass-button text-sm px-4 py-2" @click="exportFleetData">
Export JSON
</button>
</div>
</div>
</div>
<!-- Mobile Header -->
<div class="md:hidden mb-6">
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
<p class="text-white/60 text-sm mb-3">Monitoring {{ nodes.length }} node{{ nodes.length !== 1 ? 's' : '' }}</p>
<div class="flex gap-2">
<button class="glass-button text-xs px-3 py-2 flex-1" @click="refreshAll">Refresh</button>
<button class="glass-button text-xs px-3 py-2 flex-1" @click="exportFleetData">Export</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-20">
<div class="text-white/50 text-sm">Loading fleet data...</div>
</div>
<!-- Error State -->
<div v-else-if="errorMessage" class="glass-card p-6 mb-6">
<div class="alert-error rounded-lg mb-4">{{ errorMessage }}</div>
<button class="glass-button text-sm px-4 py-2" @click="refreshAll">Retry</button>
</div>
<!-- Dashboard Content -->
<template v-else>
<!-- Section 1: Fleet Overview Cards -->
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p>
<p class="text-2xl font-bold text-white">{{ nodes.length }}</p>
<p class="text-xs text-white/40">
<span class="fleet-dot-online"></span> {{ onlineCount }} online
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodes.length }} no alerts</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
<p class="text-xs text-white/40">across fleet</p>
</div>
</div>
<!-- Section 2: Node Grid -->
<div class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">Nodes</h3>
<div class="flex gap-2">
<button
v-for="opt in sortOptions"
:key="opt.value"
class="fleet-sort-btn"
:class="{ 'fleet-sort-btn-active': sortBy === opt.value }"
@click="sortBy = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-if="!nodes.length" class="text-white/40 text-sm py-8 text-center">
No nodes reporting. Ensure telemetry is enabled on beta nodes.
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-node-card"
:class="{ 'fleet-node-card-selected': selectedNodeId === node.node_id }"
@click="selectNode(node.node_id)"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="fleet-status-dot"
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
></span>
<span class="text-sm font-mono text-white">{{ node.node_id.slice(0, 8) }}</span>
</div>
<span class="fleet-version-badge">v{{ node.version }}</span>
</div>
<div class="space-y-2 mb-3">
<div class="fleet-metric-row">
<span class="text-xs text-white/50">CPU</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.cpu_pct)"
:style="{ width: Math.min(node.cpu_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.cpu_pct.toFixed(0) }}%</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">RAM</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.mem_pct)"
:style="{ width: Math.min(node.mem_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.mem_pct.toFixed(0) }}%</span>
</div>
<div class="fleet-metric-row">
<span class="text-xs text-white/50">Disk</span>
<div class="fleet-bar-track">
<div
class="fleet-bar-fill"
:class="healthBarClass(node.disk_pct)"
:style="{ width: Math.min(node.disk_pct, 100) + '%' }"
></div>
</div>
<span class="text-xs text-white/60 w-10 text-right">{{ node.disk_pct.toFixed(0) }}%</span>
</div>
</div>
<div class="flex items-center justify-between text-xs text-white/40">
<span>{{ node.running_count }}/{{ node.container_count }} containers</span>
<span>{{ node.federation_peers }} peers</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 mt-1">
<span>Up {{ formatUptime(node.uptime_secs) }}</span>
<span>{{ timeAgo(node.reported_at) }}</span>
</div>
</div>
</div>
</div>
<!-- Section 3: Fleet Alerts Timeline -->
<div class="glass-card p-5 mb-6">
<h3 class="text-sm font-medium text-white/80 mb-4">Fleet Alerts</h3>
<div v-if="alertsLoading" class="text-white/40 text-sm py-4 text-center">
Loading alerts...
</div>
<div v-else-if="!fleetAlerts.length" class="text-white/40 text-sm py-4 text-center">
No alerts across the fleet.
</div>
<div v-else class="space-y-2 max-h-80 overflow-y-auto">
<div
v-for="(alert, idx) in fleetAlerts.slice(0, 50)"
:key="idx"
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
:class="alertSeverityDot(alert.rule)"
></span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="fleet-node-badge">{{ alert.node_id.slice(0, 8) }}</span>
<span class="text-xs text-white/40">{{ alertTypeLabel(alert.rule) }}</span>
</div>
<p class="text-sm text-white/80">{{ alert.message }}</p>
<p class="text-xs text-white/30 mt-0.5">{{ formatTimestamp(alert.timestamp) }}</p>
</div>
</div>
</div>
</div>
<!-- Section 4: Node Detail (expanded view) -->
<div v-if="selectedNodeId && selectedNode" class="glass-card p-5 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-white/80">
Node Detail <span class="font-mono">{{ selectedNodeId.slice(0, 8) }}</span>
</h3>
<button class="glass-button text-xs px-3 py-1" @click="selectedNodeId = null">Close</button>
</div>
<!-- Node Info Summary -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
<p class="text-lg font-bold text-white">v{{ selectedNode.version }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
<p class="text-lg font-bold text-white">{{ formatUptime(selectedNode.uptime_secs) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">CPU Cores</p>
<p class="text-lg font-bold text-white">{{ selectedNode.cpu_cores }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
<p class="text-lg font-bold text-white">{{ selectedNode.federation_peers }}</p>
</div>
</div>
<!-- History Charts -->
<div v-if="nodeHistoryLoading" class="text-white/40 text-sm py-4 text-center mb-4">
Loading history...
</div>
<div v-else-if="nodeHistory.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
<LineChart
:datasets="nodeHistoryCpuDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">RAM History</h4>
<LineChart
:datasets="nodeHistoryMemDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">Disk History</h4>
<LineChart
:datasets="nodeHistoryDiskDatasets"
:labels="nodeHistoryLabels"
:width="chartWidth"
:height="160"
:y-max="100"
/>
</div>
</div>
<!-- Container List -->
<div class="mb-4">
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Containers</h4>
<div v-if="!selectedNode.containers.length" class="text-white/40 text-sm py-2">
No containers reported.
</div>
<div v-else class="space-y-1">
<div
v-for="c in selectedNode.containers"
:key="c.id"
class="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full flex-shrink-0"
:class="c.state === 'running' ? 'bg-green-400' : 'bg-red-400'"
></span>
<span class="text-sm text-white flex-1 truncate">{{ c.id }}</span>
<span class="text-xs text-white/40">{{ c.state }}</span>
<span class="text-xs text-white/30">{{ c.version }}</span>
</div>
</div>
</div>
<!-- Node Alerts -->
<div>
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Recent Alerts</h4>
<div v-if="!selectedNode.recent_alerts.length" class="text-white/40 text-sm py-2">
No recent alerts for this node.
</div>
<div v-else class="space-y-1">
<div
v-for="(alert, idx) in selectedNode.recent_alerts"
:key="idx"
class="flex items-start gap-3 p-2 bg-white/5 rounded-lg"
>
<span
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
:class="alertSeverityDot(alert.rule)"
></span>
<div class="flex-1 min-w-0">
<p class="text-sm text-white/80">{{ alert.message }}</p>
<p class="text-xs text-white/30">{{ formatTimestamp(alert.timestamp) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Section 5: Container Matrix -->
<div class="glass-card p-5">
<h3 class="text-sm font-medium text-white/80 mb-4">Container Matrix</h3>
<div v-if="!nodes.length" class="text-white/40 text-sm py-4 text-center">
No nodes to display.
</div>
<div v-else class="overflow-x-auto">
<table class="fleet-matrix-table">
<thead>
<tr>
<th class="fleet-matrix-header-cell">App</th>
<th
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-matrix-header-cell font-mono"
>
{{ node.node_id.slice(0, 6) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="app in allAppIds" :key="app">
<td class="fleet-matrix-cell text-white/70">{{ app }}</td>
<td
v-for="node in sortedNodes"
:key="node.node_id"
class="fleet-matrix-cell text-center"
>
<span v-if="getContainerState(node, app) === 'running'" class="text-green-400">&#10003;</span>
<span v-else-if="getContainerState(node, app) === 'stopped'" class="text-red-400">&#10007;</span>
<span v-else class="text-white/20">&mdash;</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-xs text-white/30 mt-4 text-center">
{{ autoRefresh ? 'Auto-refreshing every 60s' : 'Auto-refresh paused' }}
&middot; Last updated {{ lastRefreshed ? timeAgo(lastRefreshed) : 'never' }}
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
// --- Types ---
interface FleetNode {
node_id: string
version: string
uptime_secs: number
cpu_cores: number
cpu_pct: number
mem_pct: number
disk_pct: number
container_count: number
running_count: number
federation_peers: number
recent_alerts: Array<{ rule: string; message: string; timestamp: string }>
containers: Array<{ id: string; state: string; version: string }>
reported_at: string
}
interface FleetAlert {
node_id: string
rule: string
message: string
timestamp: string
}
interface NodeHistoryEntry {
timestamp: string
cpu_pct: number
mem_pct: number
disk_pct: number
}
type SortOption = 'status' | 'last-seen' | 'name'
// --- State ---
const loading = ref(true)
const errorMessage = ref('')
const nodes = ref<FleetNode[]>([])
const fleetAlerts = ref<FleetAlert[]>([])
const alertsLoading = ref(false)
const selectedNodeId = ref<string | null>(null)
const nodeHistory = ref<NodeHistoryEntry[]>([])
const nodeHistoryLoading = ref(false)
const autoRefresh = ref(true)
const lastRefreshed = ref('')
const sortBy = ref<SortOption>('status')
const chartWidth = ref(300)
let pollTimer: ReturnType<typeof setInterval> | null = null
const sortOptions: Array<{ label: string; value: SortOption }> = [
{ label: 'Status', value: 'status' },
{ label: 'Last Seen', value: 'last-seen' },
{ label: 'Name', value: 'name' },
]
// --- Computed ---
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
const offlineCount = computed(() => nodes.value.length - onlineCount.value)
const healthyCount = computed(() => nodes.value.filter(n => n.recent_alerts.length === 0).length)
const fleetHealthPct = computed(() => {
if (!nodes.value.length) return 0
return Math.round((healthyCount.value / nodes.value.length) * 100)
})
const avgCpu = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.cpu_pct, 0) / nodes.value.length
})
const avgMem = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.mem_pct, 0) / nodes.value.length
})
const avgDisk = computed(() => {
if (!nodes.value.length) return 0
return nodes.value.reduce((sum, n) => sum + n.disk_pct, 0) / nodes.value.length
})
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
})
const sortedNodes = computed(() => {
const sorted = [...nodes.value]
switch (sortBy.value) {
case 'status':
// Offline first, then by last seen descending
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? 1 : -1
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
})
break
case 'last-seen':
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
break
case 'name':
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
break
}
return sorted
})
const allAppIds = computed(() => {
const appSet = new Set<string>()
for (const node of nodes.value) {
for (const c of node.containers) {
appSet.add(c.id)
}
}
return Array.from(appSet).sort()
})
// Node history chart datasets
const nodeHistoryLabels = computed(() => {
return nodeHistory.value.map(h => {
const d = new Date(h.timestamp)
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
})
})
const nodeHistoryCpuDatasets = computed<ChartDataset[]>(() => [{
label: 'CPU',
data: nodeHistory.value.map(h => h.cpu_pct),
color: '#fb923c',
}])
const nodeHistoryMemDatasets = computed<ChartDataset[]>(() => [{
label: 'RAM',
data: nodeHistory.value.map(h => h.mem_pct),
color: '#3b82f6',
}])
const nodeHistoryDiskDatasets = computed<ChartDataset[]>(() => [{
label: 'Disk',
data: nodeHistory.value.map(h => h.disk_pct),
color: '#a78bfa',
}])
// --- Utility Functions ---
function formatUptime(secs: number): string {
if (secs < 60) return `${secs}s`
const days = Math.floor(secs / 86400)
const hours = Math.floor((secs % 86400) / 3600)
const mins = Math.floor((secs % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffMs = now - then
if (diffMs < 0) return 'just now'
const diffSecs = Math.floor(diffMs / 1000)
if (diffSecs < 60) return `${diffSecs}s ago`
const diffMins = Math.floor(diffSecs / 60)
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
function isOnline(reportedAt: string): boolean {
const thirtyMinMs = 30 * 60 * 1000
return Date.now() - new Date(reportedAt).getTime() < thirtyMinMs
}
function healthBarClass(pct: number): string {
if (pct >= 85) return 'monitoring-bar-danger'
if (pct >= 60) return 'monitoring-bar-warn'
return 'monitoring-bar-ok'
}
function healthTextClass(pct: number): string {
if (pct >= 85) return 'fleet-text-danger'
if (pct >= 60) return 'fleet-text-warn'
return ''
}
function alertSeverityDot(rule: string): string {
const critical = ['container_crash', 'disk_critical', 'node_offline']
if (critical.includes(rule)) return 'bg-red-400'
return 'bg-orange-400'
}
function alertTypeLabel(rule: string): string {
const labels: Record<string, string> = {
container_crash: 'Container Crash',
disk_critical: 'Disk Critical',
disk_warning: 'Disk Warning',
ram_high: 'High RAM',
cpu_high: 'High CPU',
node_offline: 'Node Offline',
version_mismatch: 'Version Mismatch',
}
return labels[rule] ?? rule
}
function formatTimestamp(ts: string): string {
const d = new Date(ts)
return d.toLocaleString()
}
function getContainerState(node: FleetNode, appId: string): string | null {
const container = node.containers.find(c => c.id === appId)
if (!container) return null
return container.state
}
// --- Data Fetching ---
async function fetchFleetStatus() {
try {
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
method: 'telemetry.fleet-status',
})
if (data?.nodes) {
nodes.value = data.nodes
lastRefreshed.value = new Date().toISOString()
}
} catch (err) {
if (loading.value) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to load fleet data'
}
}
}
async function fetchFleetAlerts() {
alertsLoading.value = true
try {
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
method: 'telemetry.fleet-alerts',
})
if (data?.alerts) {
fleetAlerts.value = data.alerts
}
} catch {
// Non-critical, retry on next poll
} finally {
alertsLoading.value = false
}
}
async function fetchNodeHistory(nodeId: string) {
nodeHistoryLoading.value = true
nodeHistory.value = []
try {
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
method: 'telemetry.fleet-node-history',
params: { node_id: nodeId },
})
if (data?.history) {
nodeHistory.value = data.history
}
} catch {
// Non-critical
} finally {
nodeHistoryLoading.value = false
}
}
async function refreshAll() {
loading.value = !nodes.value.length
errorMessage.value = ''
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
loading.value = false
}
function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null
nodeHistory.value = []
} else {
selectedNodeId.value = nodeId
fetchNodeHistory(nodeId)
}
}
function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
function startAutoRefresh() {
stopAutoRefresh()
pollTimer = setInterval(async () => {
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
// Refresh selected node history if one is selected
if (selectedNodeId.value) {
await fetchNodeHistory(selectedNodeId.value)
}
}, 60000)
}
function stopAutoRefresh() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function exportFleetData() {
const exportData = {
exported_at: new Date().toISOString(),
nodes: nodes.value,
alerts: fleetAlerts.value,
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `fleet-telemetry-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
function updateChartWidth() {
const container = document.querySelector('.glass-card')
if (container) {
// For 3-column layout, approximate each chart container width
const cardWidth = container.clientWidth
chartWidth.value = Math.max(Math.floor((cardWidth - 80) / 3), 200)
}
}
// Fetch node history when selection changes
watch(selectedNodeId, (newId) => {
if (newId) {
fetchNodeHistory(newId)
} else {
nodeHistory.value = []
}
})
// --- Lifecycle ---
onMounted(async () => {
updateChartWidth()
window.addEventListener('resize', updateChartWidth)
await refreshAll()
if (autoRefresh.value) {
startAutoRefresh()
}
})
onUnmounted(() => {
stopAutoRefresh()
window.removeEventListener('resize', updateChartWidth)
})
</script>

View File

@@ -78,13 +78,14 @@
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
<button
v-for="category in categoriesWithApps"

View File

@@ -5,6 +5,7 @@ import { useTransportStore } from '@/stores/transport'
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import MeshMap from '@/components/MeshMap.vue'
import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore()
@@ -38,7 +39,7 @@ const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman'>('chat')
const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat')
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
@@ -55,7 +56,7 @@ const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Tools tab for 3rd column on wide desktop and mobile below-chat
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
const toolsTab = ref<'bitcoin' | 'deadman' | 'map'>('bitcoin')
// Panel visibility computeds
const showChatPanel = computed(() =>
@@ -70,6 +71,10 @@ const showDeadmanPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showMapPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map'
return activeTab.value === 'map'
})
// Mobile tools: show on first view (peers), hide when in chat
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar)
@@ -548,6 +553,10 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: activeTab === 'map' }" @click="activeTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
@@ -673,8 +682,15 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<!-- Map Panel -->
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
@@ -839,7 +855,12 @@ function truncatePubkey(hex: string | null): string {
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">
Map
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
<!-- Reuse same content via a shared approach - for now inline -->
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
@@ -1646,6 +1667,10 @@ function truncatePubkey(hex: string | null): string {
min-height: 320px;
}
.mesh-mobile-tools .mesh-map-panel {
min-height: 400px;
}
.mesh-status-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -1878,6 +1903,16 @@ function truncatePubkey(hex: string | null): string {
.mesh-relay-result.success { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.mesh-relay-result.error { background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.2); }
/* ─── Map panel ─── */
.mesh-map-panel {
flex: 1;
min-height: 400px;
padding: 0 !important; /* Override glass-card padding */
overflow: hidden;
border-radius: 12px;
position: relative;
}
/* ─── Dead Man panel ─── */
.mesh-deadman-panel {
display: flex;

View File

@@ -5,7 +5,7 @@
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<span class="text-2xl text-orange-500 font-bold"></span>
@@ -22,7 +22,7 @@
</div>
<!-- DID Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
@@ -59,7 +59,7 @@
</div>
<!-- did:dht Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="dhtDid ? 'bg-blue-400' : 'bg-gray-500'"></div>
@@ -96,7 +96,7 @@
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
@@ -117,7 +117,7 @@
</div>
<!-- Nostr Relay Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 3">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="(nostrRelayStats?.connected_count ?? 0) > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
@@ -137,7 +137,7 @@
</div>
<!-- Connected Nodes -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 4">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
@@ -268,7 +268,7 @@
<!-- Core Services Overview Cards Row 1 -->
<div class="flex flex-col md:flex-row gap-6 mb-6">
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<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: 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">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -321,7 +321,7 @@
</div>
<!-- Wallet -->
<div data-controller-container tabindex="0" class="glass-card card-stagger 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 md:w-1/2" style="--stagger-index: 1">
<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">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -470,7 +470,7 @@
<!-- Core Services Overview Cards Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" class="glass-card card-stagger 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 md:w-1/2" style="--stagger-index: 2">
<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">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -523,7 +523,7 @@
</div>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" class="glass-card card-stagger 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 md:w-1/2" style="--stagger-index: 3">
<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">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -909,7 +909,7 @@
<div
v-for="(item, idx) in contentItems"
:key="item.id"
class="card-stagger p-4 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="flex items-start justify-between gap-3 mb-3">
@@ -999,7 +999,7 @@
<div
v-for="(pItem, idx) in peerContentItems"
:key="pItem.id"
class="card-stagger flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Media type icon -->
@@ -1237,7 +1237,7 @@
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
class="card-stagger flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<!-- Avatar (clickable to edit profile) -->
@@ -2127,6 +2127,10 @@
</div>
</template>
<script lang="ts">
let web5AnimationDone = false
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -2141,6 +2145,8 @@ import { useTransportStore } from '@/stores/transport'
import { useMeshStore } from '@/stores/mesh'
const route = useRoute()
const showStagger = !web5AnimationDone
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
@@ -3761,6 +3767,7 @@ async function deleteIdentity() {
}
onMounted(() => {
web5AnimationDone = true
loadPeers()
loadReceivedMessages()
loadIdentities()