chore: snapshot release workspace

This commit is contained in:
archipelago
2026-06-12 03:00:15 -04:00
parent 6a30ff11bd
commit d6f108d818
76 changed files with 792 additions and 3613 deletions

View File

@@ -110,7 +110,7 @@ tail -f /tmp/neode-dev.log
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
- **Auto-Detection** - Automatically detects container runtime and adapts
- **WebSocket Support** - Real-time state updates via JSON patches
- **Pre-loaded Apps** - 8 apps always visible in My Apps
- **Pre-loaded Apps** - 7 apps always visible in My Apps
### Pre-installed Apps (always running in mock mode)
- `bitcoin` - Bitcoin Core (port 8332)
@@ -119,7 +119,6 @@ tail -f /tmp/neode-dev.log
- `mempool` - Blockchain explorer (port 4080)
- `filebrowser` - Web file manager (port 8083)
- `lorabell` - LoRa doorbell (no UI port)
- `thunderhub` - Lightning node management (port 3010)
- `fedimint` - Federated Bitcoin mint (port 8175)
Additional apps can be installed from the Marketplace (30+ available).
@@ -226,4 +225,3 @@ The warning is non-fatal - Vite still works, but upgrading is recommended.
---
Happy coding! 🎨⚡

View File

@@ -55,7 +55,7 @@ The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
**Pre-installed apps** (always visible in My Apps):
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, ThunderHub, Fedimint
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation

View File

@@ -102,6 +102,26 @@ const toastMessage = messageToast.toastMessage
useControllerNav()
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
@@ -330,6 +350,7 @@ function onVisibilityChange() {
}
onMounted(async () => {
syncKioskSafeArea()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
@@ -393,6 +414,9 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)

View File

@@ -463,9 +463,6 @@
"noIdentities": "No identities yet",
"createFirstIdentity": "Create your first sovereign digital identity.",
"deleting": "Deleting...",
"decentralizedWebNode": "Decentralized Web Node",
"dwnDescription": "Personal data store with DID-based access control",
"manageDwn": "Manage DWN",
"syncing": "Syncing...",
"syncNow": "Sync Now",
"verifiableCredentials": "Verifiable Credentials",
@@ -590,6 +587,7 @@
},
"marketplaceDetails": {
"backToStore": "Back to App Store",
"backToHome": "Back to Home",
"screenshots": "Screenshots",
"screenshotPlaceholder": "Screenshot placeholders - images coming soon",
"about": "About {name}",

View File

@@ -463,9 +463,6 @@
"noIdentities": "A\u00fan no hay identidades",
"createFirstIdentity": "Cree su primera identidad digital soberana.",
"deleting": "Eliminando...",
"decentralizedWebNode": "Nodo web descentralizado",
"dwnDescription": "Almac\u00e9n de datos personal con control de acceso basado en DID",
"manageDwn": "Administrar DWN",
"syncing": "Sincronizando...",
"syncNow": "Sincronizar ahora",
"verifiableCredentials": "Credenciales verificables",
@@ -589,6 +586,7 @@
},
"marketplaceDetails": {
"backToStore": "Volver a la tienda",
"backToHome": "Volver al inicio",
"screenshots": "Capturas de pantalla",
"screenshotPlaceholder": "Capturas de pantalla de ejemplo \u2014 im\u00e1genes disponibles pronto",
"about": "Acerca de {name}",

View File

@@ -86,11 +86,26 @@ const router = createRouter({
{
path: '/kiosk',
name: 'kiosk',
redirect: '/',
beforeEnter: () => {
component: () => import('../views/Kiosk.vue'),
beforeEnter: (to) => {
// Persist kiosk mode before redirect so App.vue can skip the remote relay
// (relay duplicates xdotool input on the kiosk display)
localStorage.setItem('kiosk', 'true')
const safeArea = to.query.safe_area
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
if (safeAreaPx && /^\d{1,3}$/.test(safeAreaPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_px', safeAreaPx)
}
const safeAreaX = to.query.safe_area_x
const safeAreaXPx = Array.isArray(safeAreaX) ? safeAreaX[0] : safeAreaX
if (safeAreaXPx && /^\d{1,3}$/.test(safeAreaXPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_x_px', safeAreaXPx)
}
const safeAreaY = to.query.safe_area_y
const safeAreaYPx = Array.isArray(safeAreaY) ? safeAreaY[0] : safeAreaY
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
}
},
},
{
@@ -386,4 +401,3 @@ router.afterEach((to) => {
})
export default router

View File

@@ -111,10 +111,8 @@ const PORT_TO_APP_ID: Record<string, string> = {
'4080': 'mempool',
'8175': 'fedimint',
'8176': 'fedimint-gateway',
'3100': 'dwn',
'7778': 'indeedhub',
'50002': 'electrumx',
'3010': 'thunderhub',
}
const APP_ID_TO_PORT: Record<string, string> = {

View File

@@ -1294,6 +1294,21 @@ body {
min-height: 100dvh;
}
html.kiosk-safe-area,
html.kiosk-safe-area body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #000;
}
html.kiosk-safe-area #app {
width: 100vw;
height: 100vh;
min-height: 0;
overflow: hidden;
}
/* Custom scrollbar for glass containers */
.custom-scrollbar::-webkit-scrollbar {
width: 10px;

View File

@@ -416,8 +416,8 @@ function isStartingUp(appId: string): boolean {
}
function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'

View File

@@ -125,55 +125,21 @@
</div>
</div>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 2"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" 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="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
@@ -213,21 +179,54 @@
</div>
</div>
<!-- Wallet Overview -->
<HomeWalletCard
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable lg:col-span-2"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 4"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" 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="home-card-stats grid grid-cols-1 md:grid-cols-3 gap-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Quick Start Goals -->
<div
@@ -299,7 +298,7 @@ import { rpcClient } from '@/api/rpc-client'
import { getAppUsage } from '@/utils/appUsage'
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getHomeRecommendedApps } from './home/homeRecommendations'
import HomeWalletCard from './home/HomeWalletCard.vue'
import HomeSystemCard from './home/HomeSystemCard.vue'
@@ -389,7 +388,7 @@ const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppL
function viewRecommendedApp(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'home' } }).catch(() => {})
}
function marketplaceDescription(app: MarketplaceApp) {

View File

@@ -107,7 +107,6 @@ const launchableApps = computed<KioskApp[]>(() => {
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'indeedhub': 'http://localhost:7778',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
@@ -172,7 +171,10 @@ onUnmounted(() => {
<style scoped>
.kiosk-root {
position: fixed;
inset: 0;
left: var(--kiosk-safe-area-x, 0px);
top: var(--kiosk-safe-area-y, 0px);
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
background: #000;
outline: none;
overflow: hidden;
@@ -180,11 +182,12 @@ onUnmounted(() => {
}
.kiosk-launcher {
height: 100vh;
height: 100%;
display: flex;
flex-direction: column;
padding: 2rem 3rem;
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
box-sizing: border-box;
}
.kiosk-header {

View File

@@ -5,7 +5,7 @@
<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>
{{ t('marketplaceDetails.backToStore') }}
{{ backButtonLabel }}
</button>
<!-- Mobile Full-Width Back Button -->
@@ -20,7 +20,7 @@
<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>{{ t('marketplaceDetails.backToStore') }}</span>
<span>{{ backButtonLabel }}</span>
</button>
<Transition name="content-fade" mode="out-in">
@@ -397,6 +397,7 @@ const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const appId = computed(() => route.params.id as string)
@@ -550,7 +551,9 @@ onBeforeUnmount(() => {
})
function goBack() {
if (route.query.from === 'discover') {
if (route.query.from === 'home') {
router.push('/dashboard').catch(() => {})
} else if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
} else {
router.push('/dashboard/marketplace').catch(() => {})

View File

@@ -347,6 +347,7 @@
:wifi-submitting="wifiSubmitting"
:wifi-selected-ssid="wifiSelectedSsid"
:wifi-error="wifiError"
:wifi-scan-error="wifiScanError"
:dns-selected-provider="dnsSelectedProvider"
:dns-servers="networkData.dnsServers"
:dns-applying="dnsApplying"
@@ -358,6 +359,7 @@
@close-wifi="showWifiModal = false"
@select-wifi="selectWifi"
@connect-wifi="connectToWifi"
@scan-wifi="scanWifi"
@cancel-wifi-connect="wifiConnecting = false; wifiPassword = ''; wifiError = ''"
@close-dns="showDnsModal = false; dnsError = ''"
@select-dns-provider="(v: string) => { dnsSelectedProvider = v }"
@@ -564,6 +566,7 @@ const wifiSubmitting = ref(false)
const wifiSelectedSsid = ref('')
const wifiPassword = ref('')
const wifiError = ref('')
const wifiScanError = ref('')
// DNS
const showDnsModal = ref(false)
@@ -610,15 +613,33 @@ async function loadInterfaces() {
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
}
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []
try { const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' }); wifiNetworks.value = res.networks } catch { wifiNetworks.value = [] } finally { wifiScanning.value = false }
function wifiRequiresPassword(network: WifiNetwork | undefined): boolean {
const security = (network?.security || '').trim().toLowerCase()
return security.length > 0 && security !== '--' && security !== 'none' && security !== 'open'
}
function selectWifi(ssid: string) { wifiSelectedSsid.value = ssid; wifiPassword.value = ''; wifiConnecting.value = true }
async function scanWifi() {
wifiScanning.value = true; wifiNetworks.value = []; wifiScanError.value = ''; wifiError.value = ''
try {
const res = await rpcClient.call<{ networks: WifiNetwork[] }>({ method: 'network.scan-wifi' })
wifiNetworks.value = res.networks
} catch (e) {
wifiNetworks.value = []
wifiScanError.value = e instanceof Error ? e.message : 'WiFi scan failed.'
} finally { wifiScanning.value = false }
}
function selectWifi(network: WifiNetwork) {
wifiSelectedSsid.value = network.ssid; wifiPassword.value = ''; wifiError.value = ''
if (wifiRequiresPassword(network)) {
wifiConnecting.value = true
} else {
connectToWifi('')
}
}
async function connectToWifi(password: string) {
if (!password || !wifiSelectedSsid.value) return
if (!wifiSelectedSsid.value) return
wifiError.value = ''; wifiSubmitting.value = true
try {
await rpcClient.call({ method: 'network.configure-wifi', params: { ssid: wifiSelectedSsid.value, password } })

View File

@@ -27,7 +27,6 @@ export const APP_PORTS: Record<string, number> = {
'tailscale': 8240,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'dwn': 3100,
'endurain': 8080,
}

View File

@@ -27,7 +27,6 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"strfry": 8082,
"uptime-kuma": 3002,
"vaultwarden": 8082,
"web5-dwn": 3000,
}
export const GENERATED_APP_TITLES: Record<string, string> = {
@@ -69,7 +68,6 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"strfry": "Strfry Nostr Relay",
"uptime-kuma": "Uptime Kuma",
"vaultwarden": "Vaultwarden",
"web5-dwn": "Decentralized Web Node",
}
export const GENERATED_NEW_TAB_APPS = new Set<string>([

View File

@@ -269,7 +269,7 @@ const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'

View File

@@ -58,7 +58,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'searxng': 'community', 'ollama': 'community', 'grafana': 'data', 'gitea': 'data',
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'uptime-kuma': 'networking',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
}

View File

@@ -100,7 +100,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },

View File

@@ -2,13 +2,21 @@
<div v-if="node" 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">{{ nodeId.slice(0, 8) }}</span>
Node Detail <span>{{ fleetNodeDisplayName(node) }}</span>
</h3>
<button class="glass-button text-xs px-3 py-1" @click="$emit('close')">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">Hostname</p>
<p class="text-lg font-bold text-white truncate">{{ node.hostname || 'Unknown' }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
<p class="text-lg font-bold text-white truncate">{{ node.server_url || nodeId.slice(0, 8) }}</p>
</div>
<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{{ node.version }}</p>
@@ -17,10 +25,6 @@
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
<p class="text-lg font-bold text-white">{{ formatUptime(node.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">{{ node.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">{{ node.federation_peers }}</p>
@@ -119,7 +123,7 @@
<script setup lang="ts">
import LineChart from '@/components/LineChart.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp } from './useFleetData'
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp, fleetNodeDisplayName } from './useFleetData'
defineProps<{
node: FleetNode | null

View File

@@ -34,10 +34,13 @@
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>
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
</div>
<span class="fleet-version-badge">v{{ node.version }}</span>
</div>
<div class="mb-3 truncate text-xs text-white/40">
{{ fleetNodeSubtitle(node) }}
</div>
<div class="space-y-2 mb-3">
<div class="fleet-metric-row">
@@ -91,7 +94,7 @@
<script setup lang="ts">
import {
type FleetNode, type SortOption, SORT_OPTIONS,
isOnline, healthBarClass, formatUptime, timeAgo,
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
} from './useFleetData'
defineProps<{

View File

@@ -1,9 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
import {
fleetNodeDisplayName,
fleetNodeSubtitle,
isOnline,
normalizeFleetNode,
normalizeNodeHistoryResponse,
sortFleetNodes,
type FleetNode,
} from '../useFleetData'
function node(id: string, reportedAt: string): FleetNode {
return {
node_id: id,
node_name: null,
hostname: null,
server_url: null,
version: '1.8-alpha',
uptime_secs: 60,
cpu_cores: 4,
@@ -49,10 +60,12 @@ describe('fleet data helpers', () => {
})
it('sorts by name alphabetically', () => {
expect(sortFleetNodes([
node('zulu', '2026-06-10T11:59:00Z'),
node('alpha', '2026-06-10T11:59:00Z'),
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
const zulu = node('zulu', '2026-06-10T11:59:00Z')
zulu.node_name = 'Workshop'
const alpha = node('alpha', '2026-06-10T11:59:00Z')
alpha.node_name = 'Kitchen'
expect(sortFleetNodes([zulu, alpha], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
})
it('normalizes older telemetry reports with missing metric and container fields', () => {
@@ -63,6 +76,9 @@ describe('fleet data helpers', () => {
})
expect(normalized.node_id).toBe('legacy-node')
expect(normalized.node_name).toBeNull()
expect(normalized.hostname).toBeNull()
expect(normalized.server_url).toBeNull()
expect(normalized.cpu_pct).toBe(0)
expect(normalized.mem_pct).toBe(0)
expect(normalized.disk_pct).toBe(0)
@@ -70,6 +86,28 @@ describe('fleet data helpers', () => {
expect(normalized.recent_alerts).toEqual([])
})
it('uses node name, hostname, then node id for fleet display labels', () => {
const named = normalizeFleetNode({
node_id: 'abcdef123456',
node_name: 'Kitchen Node',
hostname: 'kitchen-node',
server_url: 'https://192.168.1.20',
})
const hostOnly = normalizeFleetNode({
node_id: '123456abcdef',
hostname: 'workshop-node',
server_url: 'https://192.168.1.21',
})
const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' })
expect(fleetNodeDisplayName(named)).toBe('Kitchen Node')
expect(fleetNodeSubtitle(named)).toBe('kitchen-node')
expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node')
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.168.1.21')
expect(fleetNodeDisplayName(idOnly)).toBe('feedface')
expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef')
})
it('normalizes node history responses from backend entries or legacy history fields', () => {
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }

View File

@@ -8,6 +8,9 @@ import type { ChartDataset } from '@/components/LineChart.vue'
export interface FleetNode {
node_id: string
node_name?: string | null
hostname?: string | null
server_url?: string | null
version: string
uptime_secs: number
cpu_cores: number
@@ -112,6 +115,17 @@ export function getContainerState(node: FleetNode, appId: string): string | null
return container.state
}
export function fleetNodeDisplayName(node: FleetNode): string {
const name = node.node_name?.trim() || node.hostname?.trim()
return name || node.node_id.slice(0, 8)
}
export function fleetNodeSubtitle(node: FleetNode): string {
const host = node.hostname?.trim()
if (host && host !== fleetNodeDisplayName(node)) return host
return node.server_url?.trim() || node.node_id
}
export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
{ label: 'Status', value: 'status' },
{ label: 'Last Seen', value: 'last-seen' },
@@ -133,7 +147,7 @@ export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNod
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))
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
break
}
return sorted
@@ -146,6 +160,9 @@ function numberOrZero(value: unknown): number {
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
return {
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
node_name: typeof node.node_name === 'string' ? node.node_name : null,
hostname: typeof node.hostname === 'string' ? node.hostname : null,
server_url: typeof node.server_url === 'string' ? node.server_url : null,
version: typeof node.version === 'string' ? node.version : 'unknown',
uptime_secs: numberOrZero(node.uptime_secs),
cpu_cores: numberOrZero(node.cpu_cores),

View File

@@ -7,6 +7,8 @@ const apps: MarketplaceApp[] = [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
{ id: 'thunderhub', title: 'ThunderHub', dockerImage: 'thunderhub:latest' },
{ id: 'dwn', title: 'DWN', dockerImage: 'dwn:latest' },
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
]
@@ -22,16 +24,18 @@ describe('homeRecommendations', () => {
expect(recommended.map((app) => app.id)).toEqual([
'bitcoin-knots',
'vaultwarden',
'homeassistant',
])
})
it('returns no recommendations once matching apps are installed', () => {
it('fills from optional apps once core and recommended apps are installed', () => {
const recommended = getHomeRecommendedApps(apps, {
'bitcoin-knots': {},
'mempool-web': {},
vaultwarden: {},
})
expect(recommended).toEqual([])
expect(recommended.map((app) => app.id)).toEqual(['homeassistant'])
expect(recommended.some((app) => app.id === 'dwn' || app.id === 'thunderhub')).toBe(false)
})
})

View File

@@ -16,18 +16,23 @@ export function getHomeRecommendedApps(
installedPackages: InstalledPackageMap,
limit = 3,
): MarketplaceApp[] {
return apps
const candidates = apps
.filter((app) => app.id !== 'dwn' && app.id !== 'thunderhub')
.filter((app) => {
if (!app.dockerImage) return false
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
const tier = getAppTier(app.id)
return tier === 'core' || tier === 'recommended'
return true
})
.sort((a, b) => {
const tierRank = (app: MarketplaceApp) => getAppTier(app.id) === 'core' ? 0 : 1
const tierRank = (app: MarketplaceApp) => {
const tier = getAppTier(app.id)
if (tier === 'core') return 0
if (tier === 'recommended') return 1
return 2
}
const tierDiff = tierRank(a) - tierRank(b)
if (tierDiff !== 0) return tierDiff
return (a.title || a.id).localeCompare(b.title || b.id)
})
.slice(0, limit)
return candidates.slice(0, limit)
}

View File

@@ -50,6 +50,10 @@
{{ typeof app.description === 'object' ? app.description.short : (app.description || 'No description available') }}
</p>
<p v-if="!installed && installBlockedReason" class="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-100">
Requires a full archive Bitcoin node before install.
</p>
<div class="flex gap-2 mt-auto">
<!-- Installed & starting up (transitional state) -->
<span

View File

@@ -15,7 +15,7 @@ const app: MarketplaceApp = {
source: 'community',
}
function mountCard(installed: boolean) {
function mountCard(installed: boolean, installBlockedReason?: string) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -39,6 +39,7 @@ function mountCard(installed: boolean) {
startingUp: false,
containersScanned: true,
tierLabel: 'recommended',
installBlockedReason,
},
global: {
plugins: [i18n],
@@ -58,4 +59,10 @@ describe('MarketplaceAppCard', () => {
expect(wrapper.find('.tier-badge').exists()).toBe(false)
expect(wrapper.text()).not.toContain('recommended')
})
it('explains archive-node-only install blocks on cards', () => {
const wrapper = mountCard(false, 'You need a full archival bitcoin node before downloading ElectrumX')
expect(wrapper.text()).toContain('Requires a full archive Bitcoin node before install.')
expect(wrapper.text()).toContain('Bitcoin Pruned')
})
})

View File

@@ -72,8 +72,8 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
/** Get app tier classification (matches backend get_app_tier) */
export function getAppTier(appId: string): string {
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'filebrowser']
const recommended = ['fedimint', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
if (core.includes(appId)) return 'core'
if (recommended.includes(appId)) return 'recommended'
return 'optional'
@@ -174,17 +174,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/lightningnetwork/lnd'
},
{
id: 'thunderhub',
title: 'ThunderHub',
version: '0.13.31',
description: 'Lightning node management UI. Manage channels, send and receive payments, view routing fees, and monitor your Lightning node.',
icon: '/assets/img/app-icons/thunderhub.svg',
author: 'Anthony Potdevin',
dockerImage: 'docker.io/apotdevin/thunderhub:v0.13.31',
manifestUrl: undefined,
repoUrl: 'https://github.com/apotdevin/thunderhub'
},
{
id: 'mempool',
title: 'Mempool Explorer',
@@ -405,17 +394,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
{
id: 'dwn',
title: 'Decentralized Web Node',
version: '0.4.0',
description: 'Store and sync your personal data across devices using decentralized web node protocols. Own your data with DID-based access control.',
icon: '/assets/img/app-icons/dwn.svg',
author: 'TBD',
dockerImage: `${REGISTRY}/dwn-server:main`,
manifestUrl: undefined,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostrudel',
title: 'noStrudel',

View File

@@ -98,11 +98,14 @@
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
<div class="flex items-center gap-2">
<button @click="$emit('scanWifi')" :disabled="wifiScanning" class="text-xs text-white/50 hover:text-white disabled:opacity-40 transition-colors">Refresh</button>
<button @click="$emit('closeWifi')" class="text-white/40 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</button>
</div>
</div>
<template v-if="wifiScanning">
<div class="space-y-3">
@@ -115,7 +118,7 @@
v-for="net in wifiNetworks"
:key="net.ssid"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
@click="$emit('selectWifi', net.ssid)"
@click="$emit('selectWifi', net)"
>
<div>
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
@@ -130,6 +133,12 @@
</button>
</div>
</template>
<template v-else-if="wifiScanError">
<div class="rounded-lg border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-200">
<p>{{ wifiScanError }}</p>
<button @click="$emit('scanWifi')" class="mt-3 text-white/80 hover:text-white underline underline-offset-4">Try again</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
</template>
@@ -232,6 +241,7 @@ defineProps<{
wifiSubmitting: boolean
wifiSelectedSsid: string
wifiError: string
wifiScanError: string
dnsSelectedProvider: string
dnsServers: string[]
dnsApplying: boolean
@@ -244,8 +254,9 @@ defineEmits<{
createServiceForApp: [appId: string]
createService: [name: string, port: number | null]
closeWifi: []
selectWifi: [ssid: string]
selectWifi: [network: { ssid: string; signal: number; security: string }]
connectWifi: [password: string]
scanWifi: []
cancelWifiConnect: []
closeDns: []
selectDnsProvider: [provider: string]

View File

@@ -105,7 +105,6 @@ import Web5NodeVisibility from './Web5NodeVisibility.vue'
import Web5ConnectedNodes from './Web5ConnectedNodes.vue'
// import Web5SharedContent from './Web5SharedContent.vue' // hidden for now
import Web5Identities from './Web5Identities.vue'
// import Web5DWN from './Web5DWN.vue' // hidden for now
// import Web5CredentialsSummary from './Web5CredentialsSummary.vue' // hidden for now
import Web5Monitoring from './Web5Monitoring.vue'
import Web5Federation from './Web5Federation.vue'
@@ -122,7 +121,6 @@ const nostrRelaysRef = ref<InstanceType<typeof Web5NostrRelays> | null>(null)
const nodeVisibilityRef = ref<InstanceType<typeof Web5NodeVisibility> | null>(null)
const connectedNodesRef = ref<InstanceType<typeof Web5ConnectedNodes> | null>(null)
const identitiesRef = ref<InstanceType<typeof Web5Identities> | null>(null)
// const dwnRef = ref(null) // hidden for now
// const credentialsRef = ref(null) // hidden for now
// const sharedContentRef = ref(null) // hidden for now
// const sendReceiveRef = ref(null) // wallet hidden
@@ -393,8 +391,6 @@ onMounted(() => {
nodeVisibilityRef.value?.loadVisibility()
// domainsRef.value?.loadDomainNames() // hidden for now
nostrRelaysRef.value?.loadNostrRelays()
// dwnRef.value?.loadDwnStatus() // hidden for now
// dwnRef.value?.loadDwnProtocols() // hidden for now
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now

View File

@@ -1,284 +0,0 @@
<template>
<!-- Decentralized Web Node (DWN) -->
<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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.decentralizedWebNode') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
</div>
</div>
<router-link v-if="dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
{{ t('web5.manageDwn') }}
</router-link>
</div>
<!-- DWN not installed or not running -->
<div v-if="!dwnInstalled || !dwnRunning" class="py-6 text-center">
<p class="text-white/60 text-sm mb-4">
{{ !dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' }}
{{ !dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' }}
</p>
<router-link to="/dashboard/marketplace" class="glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
<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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
</svg>
Open App Store
</router-link>
</div>
<!-- Status (only shown when DWN is installed and running) -->
<template v-if="dwnInstalled && dwnRunning">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="dwnStatus?.running ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white font-medium">{{ dwnStatus?.running ? t('common.running') : t('common.stopped') }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Sync</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="{
'bg-green-400': dwnSyncStatus === 'synced',
'bg-yellow-400 animate-pulse': dwnSyncStatus === 'syncing',
'bg-red-400': dwnSyncStatus === 'error',
'bg-white/30': dwnSyncStatus === 'idle'
}"></div>
<span class="text-sm text-white font-medium capitalize">{{ dwnSyncStatus }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Storage</div>
<span class="text-sm text-white font-medium">{{ formatDwnStorage }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Messages</div>
<span class="text-sm text-white font-medium">{{ dwnStatus?.message_count ?? 0 }}</span>
</div>
</div>
<!-- Protocols -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-white/50">Registered Protocols ({{ dwnProtocols.length }})</div>
<button @click="showRegisterProtocol = !showRegisterProtocol" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
{{ showRegisterProtocol ? 'Cancel' : '+ Register' }}
</button>
</div>
<div v-if="showRegisterProtocol" class="bg-white/5 rounded-lg p-3 mb-3">
<div class="flex gap-2 items-end">
<div class="flex-1">
<label class="text-xs text-white/50 block mb-1">Protocol URI</label>
<input v-model="newProtocolUri" type="text" placeholder="https://example.com/protocol" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
</div>
<label class="flex items-center gap-1.5 text-xs text-white/60 cursor-pointer whitespace-nowrap pb-1.5">
<input v-model="newProtocolPublished" type="checkbox" class="rounded bg-black/30 border-white/20" />
Published
</label>
<button @click="registerDwnProtocol" :disabled="registeringProtocol || !newProtocolUri.trim()" class="glass-button glass-button-sm px-3 rounded-lg text-xs font-medium disabled:opacity-50 whitespace-nowrap">
{{ registeringProtocol ? 'Registering...' : 'Register' }}
</button>
</div>
</div>
<div v-if="dwnProtocols.length" class="flex flex-wrap gap-2">
<div v-for="proto in dwnProtocols" :key="proto.protocol" class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/15 border border-blue-500/20 text-xs text-blue-300 group">
<span>{{ proto.protocol }}</span>
<span v-if="proto.published" class="text-green-400/60" title="Published">&#x2022;</span>
<button @click="removeDwnProtocol(proto.protocol)" :disabled="removingProtocol === proto.protocol" class="opacity-0 group-hover:opacity-100 text-red-400/60 hover:text-red-400 transition-all ml-1" title="Remove">
&times;
</button>
</div>
</div>
<div v-else class="text-xs text-white/30 italic">No protocols registered</div>
</div>
<!-- Sync Targets -->
<div v-if="dwnStatus?.peer_sync_targets?.length" class="mb-4">
<div class="text-xs text-white/50 mb-2">Peer Sync Targets</div>
<div class="space-y-1">
<div v-for="target in dwnStatus.peer_sync_targets" :key="target" class="flex items-center gap-2 text-xs text-white/70 bg-white/5 rounded-lg px-3 py-2">
<svg class="w-3 h-3 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /></svg>
<span class="truncate font-mono">{{ target }}</span>
</div>
</div>
</div>
<!-- Messages Browser -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-white/50">Messages</div>
<button @click="toggleDwnMessages" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
{{ showDwnMessages ? 'Hide' : 'Browse' }}
</button>
</div>
<div v-if="showDwnMessages">
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<div v-if="loadingDwnMessages" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing messages...
</div>
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span class="text-white/70">{{ msg.author }}</span>
<span v-if="msg.descriptor.protocol" class="text-blue-300/80">{{ msg.descriptor.protocol }}</span>
<span v-if="msg.descriptor.schema" class="text-purple-300/80">{{ msg.descriptor.schema }}</span>
</div>
<div v-if="msg.data" class="mt-1 text-xs text-white/40 font-mono truncate">{{ JSON.stringify(msg.data).slice(0, 120) }}</div>
</div>
</div>
</div>
</div>
<!-- Last Sync & Actions -->
<div class="flex items-center justify-between pt-3 border-t border-white/10">
<div class="text-xs text-white/40">
{{ dwnStatus?.last_sync ? `Last sync: ${new Date(dwnStatus.last_sync).toLocaleString()}` : 'Never synced' }}
</div>
<button @click="syncDWNs" :disabled="syncingDWNs || !dwnStatus?.running" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50">
<svg class="w-4 h-4" :class="{ 'animate-spin': syncingDWNs }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import type { DwnStatusData, DwnProtocol, DwnMessageEntry } from './types'
const { t } = useI18n()
const appStore = useAppStore()
const dwnStatus = ref<DwnStatusData | null>(null)
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
const syncingDWNs = ref(false)
const dwnProtocols = ref<DwnProtocol[]>([])
const dwnMessages = ref<DwnMessageEntry[]>([])
const showDwnMessages = ref(false)
const loadingDwnMessages = ref(false)
const showRegisterProtocol = ref(false)
const newProtocolUri = ref('')
const newProtocolPublished = ref(false)
const registeringProtocol = ref(false)
const removingProtocol = ref<string | null>(null)
const formatDwnStorage = computed(() => {
if (!dwnStatus.value) return '0 B'
const bytes = dwnStatus.value.storage_bytes
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
})
async function loadDwnStatus() {
try {
const res = await rpcClient.call<DwnStatusData>({ method: 'dwn.status' })
dwnStatus.value = res
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'idle'
} catch {
dwnStatus.value = null
dwnSyncStatus.value = 'idle'
}
}
async function syncDWNs() {
syncingDWNs.value = true
dwnSyncStatus.value = 'syncing'
try {
const res = await rpcClient.call<{ sync_status: string; last_sync: string; messages_synced: number }>({ method: 'dwn.sync' })
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'synced'
await loadDwnStatus()
} catch {
dwnSyncStatus.value = 'error'
} finally {
syncingDWNs.value = false
}
}
async function loadDwnProtocols() {
try {
const res = await rpcClient.call<{ protocols: DwnProtocol[] }>({ method: 'dwn.list-protocols' })
dwnProtocols.value = res.protocols || []
} catch {
dwnProtocols.value = []
}
}
async function registerDwnProtocol() {
if (registeringProtocol.value || !newProtocolUri.value.trim()) return
registeringProtocol.value = true
try {
await rpcClient.call({ method: 'dwn.register-protocol', params: { protocol: newProtocolUri.value.trim(), published: newProtocolPublished.value } })
newProtocolUri.value = ''
newProtocolPublished.value = false
showRegisterProtocol.value = false
await loadDwnProtocols()
await loadDwnStatus()
} catch {
if (import.meta.env.DEV) console.error('Failed to register protocol')
} finally {
registeringProtocol.value = false
}
}
async function removeDwnProtocol(protocol: string) {
removingProtocol.value = protocol
try {
await rpcClient.call({ method: 'dwn.remove-protocol', params: { protocol } })
await loadDwnProtocols()
await loadDwnStatus()
} catch {
if (import.meta.env.DEV) console.error('Failed to remove protocol')
} finally {
removingProtocol.value = null
}
}
async function toggleDwnMessages() {
showDwnMessages.value = !showDwnMessages.value
if (showDwnMessages.value) {
await loadDwnMessages()
}
}
async function loadDwnMessages() {
const hadMessages = dwnMessages.value.length > 0
loadingDwnMessages.value = true
try {
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
dwnMessages.value = res.messages || []
} catch {
if (!hadMessages) dwnMessages.value = []
} finally {
loadingDwnMessages.value = false
}
}
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
</script>

View File

@@ -1,94 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Web5DWN from '../Web5DWN.vue'
import { rpcClient } from '@/api/rpc-client'
import { useSyncStore } from '@/stores/sync'
import { PackageState } from '@/types/api'
import type { DwnMessageEntry } from '../types'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function makeMessage(recordId: string): DwnMessageEntry {
return {
record_id: recordId,
author: 'did:key:alice',
date_created: '2026-06-10T10:00:00Z',
descriptor: {
interface: 'Records',
method: 'Write',
protocol: 'https://example.com/protocol',
},
data: { title: recordId },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('Web5DWN', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
useSyncStore().data = {
'package-data': {
dwn: {
state: PackageState.Running,
manifest: { id: 'dwn', title: 'DWN' },
'static-files': {},
},
},
} as never
})
it('keeps stored messages visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
messages: [makeMessage('record-one')],
count: 1,
})
const wrapper = mount(Web5DWN, {
global: {
stubs: { RouterLink: true },
},
})
;(wrapper.vm as unknown as { showDwnMessages: boolean }).showDwnMessages = true
await (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
await flushPromises()
expect(wrapper.text()).toContain('record-o')
const pending = deferred<{ messages: DwnMessageEntry[]; count: number }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadDwnMessages: () => Promise<void> }).loadDwnMessages()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('record-o')
expect(wrapper.text()).toContain('Refreshing messages...')
expect(wrapper.text()).not.toContain('Loading messages...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('record-o')
expect(wrapper.text()).not.toContain('Refreshing messages...')
})
})