chore: snapshot release workspace
This commit is contained in:
@@ -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! 🎨⚡
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -27,7 +27,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'tailscale': 8240,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">•</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">
|
||||
×
|
||||
</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>
|
||||
@@ -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...')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user