fix: prevent tokio runtime deadlock in credential issue/verify

The credential issuance and verification handlers used
Handle::block_on() directly inside the tokio runtime, causing a
deadlock. Wrapped with block_in_place() to properly yield the
runtime thread.

Also completed full feature verification across all 25 test groups
(~175 checks) on live server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 07:43:12 +00:00
co-authored by Claude Opus 4.6
parent 5ce8b7965c
commit e3aa95a103
81 changed files with 11492 additions and 649 deletions
+45
View File
@@ -52,6 +52,16 @@
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
v-if="packageKey === 'lnd'"
@click="router.push('/dashboard/apps/lnd/channels')"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Channels
</button>
<button
v-if="canLaunch"
@click="launchApp"
@@ -266,6 +276,27 @@
</div>
</div>
<!-- Fedimint Services Card -->
<div v-if="packageKey === 'fedimint'" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Services</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 py-2 border-b border-white/10">
<span class="w-2 h-2 rounded-full" :class="pkg.state === 'running' ? 'bg-green-400' : 'bg-yellow-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Guardian</p>
<p class="text-white/50 text-xs capitalize">{{ pkg.state }}</p>
</div>
</div>
<div class="flex items-center gap-3 py-2">
<span class="w-2 h-2 rounded-full" :class="gatewayState === 'running' ? 'bg-green-400' : gatewayState === 'stopped' ? 'bg-yellow-400' : 'bg-red-400'"></span>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">Gateway</p>
<p class="text-white/50 text-xs capitalize">{{ gatewayState }}</p>
</div>
</div>
</div>
</div>
<!-- Access (LAN + Tor) Card -->
<div v-if="interfaceAddresses" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
@@ -444,6 +475,7 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
btcpay: 'btcpay-server',
'btcpay-server': 'btcpay-server',
fedimint: 'fedimint',
'fedimint-gateway': 'fedimint-gateway',
lnd: 'lnd',
'lnd-ui': 'lnd',
bitcoin: 'bitcoin-knots',
@@ -532,6 +564,15 @@ const torUrl = computed(() => {
return addr.startsWith('http') ? addr : `http://${addr}`
})
/** Resolved package key for the current route */
const packageKey = computed(() => resolvePackageKey(appId.value))
/** Fedimint Gateway companion container state */
const gatewayState = computed(() => {
const gw = store.packages['fedimint-gateway']
return gw ? gw.state : 'not installed'
})
const uninstallModal = ref({
show: false,
appTitle: ''
@@ -679,6 +720,10 @@ function launchApp() {
dev: 'http://localhost:8175',
prod: 'http://192.168.1.228:8175'
},
'fedimint-gateway': {
dev: 'http://localhost:8176',
prod: 'http://192.168.1.228:8176'
},
'morphos-server': {
dev: 'http://localhost:8081',
prod: 'http://localhost:8081'
+3 -2
View File
@@ -40,12 +40,13 @@
<!-- Apps Grid (alphabetically by title, stable across run state) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div
v-for="[id, pkg] in filteredPackageEntries"
v-for="([id, pkg], index) in filteredPackageEntries"
:key="id"
data-controller-container
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
class="glass-card card-stagger p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:style="{ '--stagger-index': index }"
@click="goToApp(id as string)"
>
<!-- Uninstall Icon -->
+5 -3
View File
@@ -24,11 +24,12 @@
</div>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<Transition name="content-fade" mode="out-in">
<div v-if="loading" key="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
</div>
<div v-else-if="error" class="glass-card p-6">
<div v-else-if="error" key="error" class="glass-card p-6">
<div class="flex items-center gap-3 text-red-400">
<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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -37,7 +38,7 @@
</div>
</div>
<div v-else-if="container" class="space-y-6">
<div v-else-if="container" key="content" class="space-y-6">
<!-- Container Info Card -->
<div class="glass-card p-6">
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
@@ -125,6 +126,7 @@
</div>
</div>
</div>
</Transition>
</div>
</template>
+23 -6
View File
@@ -94,6 +94,10 @@
/>
</svg>
<span>{{ item.label }}</span>
<span
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ web5Badge.pendingRequestCount }}</span>
</RouterLink>
<!-- Chat launcher button -->
@@ -298,7 +302,7 @@
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
class="flex items-center justify-center w-full py-3 rounded-lg text-white/70 transition-all duration-300 relative z-10"
class="flex flex-col items-center justify-center w-full py-1.5 rounded-lg text-white/70 transition-all duration-300 relative z-10 gap-0.5"
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
@@ -308,7 +312,7 @@
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg class="w-7 h-7 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
@@ -318,15 +322,17 @@
:d="path"
/>
</svg>
<span class="text-[10px] leading-tight">{{ item.label }}</span>
</RouterLink>
<!-- Chat launcher -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn-mobile flex items-center justify-center w-full py-3 rounded-lg transition-all duration-300 relative z-10"
class="chat-launcher-btn-mobile flex flex-col items-center justify-center w-full py-1.5 rounded-lg transition-all duration-300 relative z-10 gap-0.5"
>
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span class="text-[10px] leading-tight">Chat</span>
</button>
</div>
</nav>
@@ -344,6 +350,7 @@ import ControllerIndicator from '@/components/ControllerIndicator.vue'
import ModeSwitcher from '@/components/ModeSwitcher.vue'
import { useUIModeStore } from '@/stores/uiMode'
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
const uiMode = useUIModeStore()
@@ -355,6 +362,7 @@ const router = useRouter()
const route = useRoute()
const store = useAppStore()
const loginTransition = useLoginTransitionStore()
const web5Badge = useWeb5BadgeStore()
const showZoomIn = ref(false)
const pendingTimers: ReturnType<typeof setTimeout>[] = []
@@ -537,6 +545,7 @@ onMounted(() => {
onResize()
window.addEventListener('resize', onResize)
web5Badge.refresh()
})
onBeforeUnmount(() => {
@@ -1329,45 +1338,53 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
/* Mobile: Slide left transition (Apps -> Marketplace) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-left-enter-from.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
.slide-left-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-to.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
/* Mobile: Slide right transition (Marketplace -> Apps) */
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-right-enter-from.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-to.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
/* Slide down: Moving down the menu (content slides up like a scroll) */
+2 -2
View File
@@ -65,7 +65,7 @@
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Installed</p>
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
@@ -108,7 +108,7 @@
</svg>
</RouterLink>
</div>
<div class="home-card-stats grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="home-card-stats grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Storage Used</p>
<p class="text-2xl font-bold text-white">{{ cloudStorageDisplay }}</p>
+26 -3
View File
@@ -122,12 +122,13 @@
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="app in filteredApps"
v-for="(app, index) in filteredApps"
:key="app.id"
data-controller-container
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
class="glass-card p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
class="glass-card card-stagger p-6 hover:bg-white/10 transition-all cursor-pointer flex flex-col"
:style="{ '--stagger-index': index }"
@click="viewAppDetails(app)"
>
<div class="flex items-start gap-4 mb-4">
@@ -238,7 +239,7 @@
</div>
<!-- Category Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@@ -790,6 +791,28 @@ function getCuratedAppList() {
dockerImage: 'localhost/indeedhub:latest',
manifestUrl: null,
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: 'ghcr.io/tbd54566975/dwn-server:main',
manifestUrl: null,
repoUrl: 'https://github.com/TBD54566975/dwn-server'
},
{
id: 'nostr-rs-relay',
title: 'Nostr Relay',
version: '0.9.0',
description: 'Run your own Nostr relay. Store your events locally, relay for friends, and publish over Tor. A sovereign relay for your sovereign node.',
icon: '/assets/img/app-icons/nostr-rs-relay.svg',
author: 'scsiblade',
dockerImage: 'docker.io/scsiblade/nostr-rs-relay:latest',
manifestUrl: null,
repoUrl: 'https://sr.ht/~gheartsfield/nostr-rs-relay/'
}
]
}
+124 -27
View File
@@ -23,8 +23,9 @@
<span>Back to App Store</span>
</button>
<Transition name="content-fade" mode="out-in">
<!-- Loading State -->
<div v-if="loading" class="glass-card p-12 text-center">
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-12 w-12 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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>
@@ -33,7 +34,7 @@
</div>
<!-- App Details -->
<div v-else-if="app">
<div v-else-if="app" key="content">
<!-- Compact Hero Section -->
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
@@ -83,7 +84,7 @@
<button
v-else
@click="installApp"
:disabled="installing || !app.manifestUrl"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -115,7 +116,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<!-- App Info -->
<div class="min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ app.title }}</h1>
@@ -148,7 +149,7 @@
<button
v-else
@click="installApp"
:disabled="installing || !app.manifestUrl"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -269,6 +270,47 @@
<div class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
<div class="space-y-3">
<!-- App Dependencies -->
<div v-if="dependencies.length > 0" class="space-y-2 mb-4">
<div
v-for="dep in dependencies"
:key="dep.id"
class="flex items-center gap-3 py-2 border-b border-white/10"
>
<!-- Status indicator -->
<svg v-if="dep.status === 'running'" class="w-5 h-5 text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="dep.status === 'stopped'" class="w-5 h-5 text-yellow-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<svg v-else class="w-5 h-5 text-red-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-white/80 font-medium text-sm">{{ dep.title }}</p>
<p class="text-white/50 text-xs">
{{ dep.status === 'running' ? 'Running' : dep.status === 'stopped' ? 'Installed but stopped' : 'Not installed' }}
</p>
</div>
</div>
<!-- Install missing dependencies button -->
<button
v-if="dependencies.some(d => d.status === 'missing')"
@click="installDependencies"
:disabled="installingDeps"
class="glass-button w-full mt-3 px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<svg v-if="installingDeps" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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>
{{ installingDeps ? 'Installing...' : 'Install Requirements' }}
</button>
</div>
<div v-else class="py-2 border-b border-white/10">
<p class="text-white/60 text-sm">No additional dependencies required</p>
</div>
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
@@ -312,13 +354,14 @@
</div>
<!-- App Not Found -->
<div v-else class="glass-card p-12 text-center">
<div v-else key="not-found" class="glass-card p-12 text-center">
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">App Not Found</h3>
<p class="text-white/70">The requested application could not be found in the marketplace</p>
</div>
</Transition>
</div>
</template>
@@ -340,6 +383,7 @@ const { getCurrentApp } = useMarketplaceApp()
const app = ref<MarketplaceAppInfo | null>(null)
const installing = ref(false)
const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
@@ -390,6 +434,33 @@ const features = computed(() => {
]
})
/** App dependency definitions */
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrs': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'btcpay-server': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
'mempool': [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' },
{ id: 'electrs', title: 'Electrs', dockerImage: 'docker.io/mempool/electrs:latest' },
],
'fedimint': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'docker.io/bitcoinknots/bitcoin:latest' }],
}
/** Check dependency status against installed packages */
const dependencies = computed(() => {
if (!app.value) return []
const deps = APP_DEPENDENCIES[app.value.id]
if (!deps) return []
return deps.map(dep => {
const pkg = store.packages[dep.id]
let status: 'running' | 'stopped' | 'missing' = 'missing'
if (pkg) {
status = pkg.state === 'running' ? 'running' : 'stopped'
}
return { ...dep, status }
})
})
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
@@ -436,9 +507,40 @@ function goToInstalledApp() {
}).catch(() => {})
}
async function installDependencies() {
if (installingDeps.value) return
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
if (!missingDeps.length) return
installingDeps.value = true
installError.value = null
try {
// Install dependencies sequentially (order matters: bitcoin before electrs)
for (const dep of missingDeps) {
await rpcClient.call({
method: 'package.install',
params: {
id: dep.id,
dockerImage: dep.dockerImage,
},
timeout: 180000,
})
// Wait for package to register before installing next
await new Promise(resolve => setTimeout(resolve, 2000))
}
} catch (err: unknown) {
installError.value = err instanceof Error ? err.message : 'Failed to install dependencies.'
console.error('[MarketplaceAppDetails] Failed to install dependencies:', err)
} finally {
installingDeps.value = false
}
}
async function installApp() {
if (installing.value || !app.value?.manifestUrl) {
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl:', app.value)
if (installing.value || !app.value) return
if (!app.value.manifestUrl && !app.value.dockerImage) {
console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return
}
@@ -446,15 +548,20 @@ async function installApp() {
installError.value = null
try {
const installUrl = app.value.url || app.value.manifestUrl
console.log('[MarketplaceAppDetails] Installing app:', {
id: app.value.id,
url: installUrl,
version: app.value.version,
source: app.value.source
})
if (app.value.source === 'local') {
if (app.value.dockerImage) {
// Docker-based app installation
await rpcClient.call({
method: 'package.install',
params: {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: app.value.version,
},
timeout: 180000,
})
} else {
// Package-based installation
const installUrl = app.value.url || app.value.manifestUrl
await rpcClient.call({
method: 'package.install',
params: {
@@ -463,16 +570,6 @@ async function installApp() {
version: app.value.version,
},
})
} else {
// Community marketplace app
await rpcClient.call({
method: 'package.install',
params: {
id: app.value.id,
url: installUrl,
version: app.value.version || 'latest',
},
})
}
// Wait a moment for the package to be registered
+44 -13
View File
@@ -14,15 +14,16 @@
<!-- Content Area -->
<div class="flex flex-col items-center gap-6 mb-6">
<!-- Connecting state -->
<!-- Generating state — spinning lock -->
<div v-if="!generatedDid && isGenerating" class="text-center">
<div class="flex items-center justify-center gap-3 mb-4">
<svg class="animate-spin h-6 w-6 text-white/80" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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>
<span class="text-lg text-white/80">Connecting to your server...</span>
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center onb-lock-spin">
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
</div>
<p class="text-lg text-white/80">Generating your identity key...</p>
</div>
<!-- Connection failed - retry -->
@@ -57,13 +58,25 @@
<div class="path-option-card cursor-default px-6 py-6">
<div class="text-left">
<h3 class="text-sm font-semibold text-white/80 mb-2 uppercase tracking-wide">Your DID</h3>
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10">
<p class="text-white/95 font-mono text-sm break-all leading-relaxed">
<div class="bg-black/40 rounded-lg p-4 mb-3 backdrop-blur-sm border border-white/10 flex items-start gap-3">
<p class="text-white/95 font-mono text-sm break-all leading-relaxed flex-1">
{{ generatedDid }}
</p>
<button
@click="copyDid"
class="shrink-0 p-1.5 rounded hover:bg-white/10 transition-colors text-white/50 hover:text-white/90"
:title="didCopied ? 'Copied!' : 'Copy DID'"
>
<svg v-if="!didCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<p class="text-base text-white/60">
This identifier is stored securely on your node
This is your sovereign digital identity. It proves you are you, without any company in the middle.
</p>
</div>
</div>
@@ -101,6 +114,7 @@ const isGenerating = ref(false)
const connectionFailed = ref(false)
const autoAdvancing = ref(false)
const errorMessage = ref<string>('')
const didCopied = ref(false)
function storeDidState(did: string, pubkey: string) {
localStorage.setItem('neode_did', did)
@@ -135,7 +149,7 @@ async function fetchDid() {
function autoAdvanceAfterDelay() {
autoAdvancing.value = true
setTimeout(() => {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}, 2000)
}
@@ -149,10 +163,27 @@ onMounted(() => {
})
function proceed() {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}
function skipForNow() {
router.push('/onboarding/backup').catch(() => {})
router.push('/onboarding/identity').catch(() => {})
}
function copyDid() {
if (!generatedDid.value) return
navigator.clipboard.writeText(generatedDid.value).catch(() => {})
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
</script>
<style scoped>
.onb-lock-spin {
animation: onb-lock-pulse 1.2s ease-in-out infinite;
}
@keyframes onb-lock-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.7; }
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-[800px] w-full relative z-10 path-glass-container">
<!-- Header -->
<div class="text-center flex-shrink-0">
<h1 class="text-[26px] font-semibold text-white/96 mb-6 drop-shadow-[0_2px_6px_rgba(0,0,0,0.4)]">
Name your identity
</h1>
<p class="text-[20px] text-white/75 leading-relaxed max-w-[600px] mx-auto mb-6">
Give your first identity a name and choose how you'll use it. You can create more identities later.
</p>
</div>
<!-- Content -->
<div class="flex flex-col items-center gap-6 mb-6">
<div class="w-full max-w-[600px] space-y-6">
<!-- Name Input -->
<div class="path-option-card cursor-default px-6 py-6">
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Identity Name</label>
<input
v-model="identityName"
type="text"
placeholder="Personal"
class="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 text-white/95 placeholder-white/40 focus:outline-none focus:border-white/30 focus:bg-black/50 transition-all"
/>
</div>
<!-- Purpose Selection -->
<div class="path-option-card cursor-default px-6 py-6">
<label class="block text-sm font-semibold text-white/80 mb-3 uppercase tracking-wide">Purpose</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
v-for="p in purposes"
:key="p.value"
@click="selectedPurpose = p.value"
class="px-4 py-3 rounded-lg border text-left transition-all"
:class="selectedPurpose === p.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-black/20 border-white/10 text-white/60 hover:bg-white/10 hover:text-white/80'"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0" :class="p.color">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<span class="font-medium text-sm">{{ p.label }}</span>
</div>
<p class="text-xs text-white/50 ml-7">{{ p.desc }}</p>
</button>
</div>
</div>
</div>
</div>
<!-- Error -->
<p v-if="errorMessage" class="text-red-400 text-sm text-center mb-4">{{ errorMessage }}</p>
<!-- Action Buttons -->
<div class="flex gap-4 max-w-[600px] mx-auto flex-shrink-0">
<button
@click="skip"
class="path-action-button path-action-button--skip"
>
Skip
</button>
<button
@click="createIdentity"
:disabled="isCreating"
class="path-action-button path-action-button--continue"
>
<span v-if="isCreating">Creating...</span>
<span v-else>Continue</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const router = useRouter()
const identityName = ref('Personal')
const selectedPurpose = ref('personal')
const isCreating = ref(false)
const errorMessage = ref('')
const purposes = [
{ value: 'personal', label: 'Personal', desc: 'Everyday use', color: 'bg-blue-500/30 text-blue-400' },
{ value: 'business', label: 'Business', desc: 'Professional', color: 'bg-orange-500/30 text-orange-400' },
{ value: 'anonymous', label: 'Anonymous', desc: 'Private', color: 'bg-purple-500/30 text-purple-400' },
]
async function createIdentity() {
isCreating.value = true
errorMessage.value = ''
try {
await rpcClient.call({
method: 'identity.create',
params: {
name: identityName.value || 'Personal',
purpose: selectedPurpose.value
}
})
router.push('/onboarding/backup').catch(() => {})
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Failed to create identity'
} finally {
isCreating.value = false
}
}
function skip() {
router.push('/onboarding/backup').catch(() => {})
}
</script>
+48 -8
View File
@@ -1,25 +1,25 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<div class="glass-card p-12 pt-20 text-center animate-fade-up relative overflow-visible">
<div class="glass-card p-12 pt-20 text-center relative overflow-visible onb-card">
<!-- Logo - half in, half out of container -->
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10 onb-logo">
<div class="logo-gradient-border w-20 h-20">
<AnimatedLogo no-border fit />
</div>
</div>
<h1 class="text-4xl font-bold text-white mb-4">
<h1 class="text-4xl font-bold text-white mb-4 onb-title">
Welcome to Archipelago
</h1>
<p class="text-xl text-white/80 mb-12 max-w-2xl mx-auto">
<p class="text-xl text-white/80 mb-12 max-w-2xl mx-auto onb-tagline">
Your personal server for a sovereign digital life
</p>
<button
@click="goToOptions"
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
class="glass-button px-8 py-4 rounded-lg text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
>
Unlock your sovereignty
</button>
@@ -39,3 +39,43 @@ function goToOptions() {
}
</script>
<style scoped>
.onb-card {
opacity: 0;
animation: onb-card-in 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.1s forwards;
}
.onb-logo {
opacity: 0;
animation: onb-scale-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.3s forwards;
}
.onb-title {
opacity: 0;
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.7s forwards;
}
.onb-tagline {
opacity: 0;
animation: onb-slide-up 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 1.0s forwards;
}
.onb-cta {
opacity: 0;
animation: onb-fade-in 0.6s ease 1.4s forwards;
}
@keyframes onb-card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes onb-scale-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
@keyframes onb-slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes onb-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+44 -2
View File
@@ -49,7 +49,7 @@
<div class="perspective-container-wrapper">
<div class="perspective-container">
<RouterView v-slot="{ Component, route }">
<Transition name="depth-forward">
<Transition :name="transitionName">
<div :key="route.path" class="view-wrapper">
<component :is="Component" class="view-container" />
</div>
@@ -70,6 +70,14 @@ const currentBackground = ref('bg-intro.jpg')
const isGlitching = ref(false)
const isTransitioning = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const transitionName = ref('depth-forward')
// Ordered onboarding steps for direction detection
const onboardingOrder = [
'/onboarding/intro', '/onboarding/path', '/onboarding/options',
'/onboarding/did', '/onboarding/identity', '/onboarding/backup',
'/onboarding/verify', '/onboarding/done', '/login'
]
// Routes that should use video background (smooth transition from splash, loops through login)
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
@@ -89,6 +97,7 @@ const routeBackgrounds: Record<string, string> = {
'/onboarding/options': 'bg-intro-4.jpg',
'/onboarding/path': 'bg-intro-3.jpg',
'/onboarding/did': 'bg-intro-5.jpg',
'/onboarding/identity': 'bg-intro-5.jpg',
'/onboarding/backup': 'bg-intro-6.jpg',
'/onboarding/verify': 'bg-intro-2.jpg',
'/onboarding/done': 'bg-intro-1.jpg',
@@ -221,8 +230,16 @@ watch(videoElement, (element) => {
}
})
// Watch route changes for background swaps, zoom, and glitch
// Watch route changes for background swaps, zoom, glitch, and transition direction
watch(() => route.path, (newPath, oldPath) => {
// Determine slide direction based on route order
const oldIdx = onboardingOrder.indexOf(oldPath || '')
const newIdx = onboardingOrder.indexOf(newPath)
if (oldIdx >= 0 && newIdx >= 0) {
transitionName.value = newIdx >= oldIdx ? 'slide-left' : 'slide-right'
} else {
transitionName.value = 'depth-forward'
}
const newBg = routeBackgrounds[newPath]
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
@@ -398,6 +415,31 @@ onMounted(() => {
filter: blur(10px);
}
/* Horizontal slide transitions (direction-aware onboarding steps) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper,
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s ease;
}
.slide-left-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
.slide-left-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px);
}
.slide-right-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px);
}
/* Background zoom - 2advanced fluid */
.bg-zoom {
transition: transform 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+178
View File
@@ -541,6 +541,112 @@
</div>
</div>
</div>
<!-- Network & Connectivity Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white/96">Network</h2>
<p class="text-sm text-white/60 mt-1">Network connectivity, UPnP, and diagnostics</p>
</div>
<!-- Loading -->
<div v-if="networkLoading" class="py-4 text-center">
<p class="text-white/50 text-sm">Running diagnostics...</p>
</div>
<template v-else-if="networkDiag">
<!-- Status Grid -->
<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">WAN IP</div>
<span class="text-sm text-white font-mono">{{ networkDiag.wan_ip || 'Unknown' }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">NAT Type</div>
<span class="text-sm text-white">{{ networkDiag.nat_type }}</span>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">UPnP</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="networkDiag.upnp_available ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white">{{ networkDiag.upnp_available ? 'Available' : 'Unavailable' }}</span>
</div>
</div>
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">Tor</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="networkDiag.tor_connected ? 'bg-green-400' : 'bg-red-400'"></div>
<span class="text-sm text-white">{{ networkDiag.tor_connected ? 'Connected' : 'Offline' }}</span>
</div>
</div>
</div>
<!-- Recommendations -->
<div v-if="networkDiag.recommendations?.length" class="mb-4">
<div class="text-xs text-white/50 mb-2">Recommendations</div>
<div class="space-y-1">
<div v-for="(rec, idx) in networkDiag.recommendations" :key="idx" class="flex items-start gap-2 text-xs text-yellow-400/80 bg-yellow-500/10 rounded-lg px-3 py-2">
<svg class="w-3 h-3 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
<span>{{ rec }}</span>
</div>
</div>
</div>
<!-- Port Forwards -->
<div class="border-t border-white/10 pt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-white">Port Forwards</h3>
<button @click="showAddForwardModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-xs">Add</button>
</div>
<div v-if="portForwards.length" class="space-y-2">
<div v-for="fwd in portForwards" :key="fwd.id" class="flex items-center justify-between p-2 bg-white/5 rounded-lg">
<div class="text-sm text-white">
<span class="font-medium">{{ fwd.service_name }}</span>
<span class="text-white/40 text-xs ml-2">:{{ fwd.internal_port }} :{{ fwd.external_port }} ({{ fwd.protocol }})</span>
</div>
<button @click="removePortForward(fwd.id)" class="text-white/30 hover:text-red-400 transition-colors p-1">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div v-else class="text-center text-white/40 text-xs py-3">No port forwards configured</div>
</div>
</template>
<div class="flex justify-end pt-3">
<button @click="runNetworkDiag" :disabled="networkLoading" class="glass-button glass-button-sm px-4 rounded-lg text-sm disabled:opacity-50">
{{ networkLoading ? 'Running...' : 'Run Diagnostics' }}
</button>
</div>
</div>
<!-- Add Port Forward Modal -->
<div v-if="showAddForwardModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showAddForwardModal = false">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-4">Add Port Forward</h2>
<div class="space-y-3">
<div>
<label class="text-white/60 text-xs block mb-1">Service Name</label>
<input v-model="newFwdService" type="text" placeholder="Bitcoin RPC" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-white/60 text-xs block mb-1">Internal Port</label>
<input v-model.number="newFwdInternal" type="number" placeholder="8332" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
<div>
<label class="text-white/60 text-xs block mb-1">External Port</label>
<input v-model.number="newFwdExternal" type="number" placeholder="8332" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30" />
</div>
</div>
</div>
<div v-if="fwdError" class="text-xs text-red-400 mt-2">{{ fwdError }}</div>
<div class="flex gap-3 mt-4">
<button @click="showAddForwardModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="addPortForward" :disabled="!newFwdService.trim() || !newFwdInternal || !newFwdExternal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30 disabled:opacity-50">Add</button>
</div>
</div>
</div>
</div>
</template>
@@ -850,9 +956,81 @@ function closeChangePasswordModal() {
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
// --- Network & Connectivity ---
interface NetworkDiagData {
wan_ip: string | null
nat_type: string
upnp_available: boolean
tor_connected: boolean
dns_working: boolean
recommendations: string[]
}
interface PortForwardData {
id: string
service_name: string
internal_port: number
external_port: number
protocol: string
enabled: boolean
}
const networkDiag = ref<NetworkDiagData | null>(null)
const networkLoading = ref(false)
const portForwards = ref<PortForwardData[]>([])
const showAddForwardModal = ref(false)
const newFwdService = ref('')
const newFwdInternal = ref(0)
const newFwdExternal = ref(0)
const fwdError = ref('')
async function runNetworkDiag() {
networkLoading.value = true
try {
const [diagRes, fwdRes] = await Promise.all([
rpcClient.call<NetworkDiagData>({ method: 'network.diagnostics' }),
rpcClient.call<{ forwards: PortForwardData[] }>({ method: 'router.list-forwards' }),
])
networkDiag.value = diagRes
portForwards.value = fwdRes.forwards || []
} catch {
networkDiag.value = null
} finally {
networkLoading.value = false
}
}
async function addPortForward() {
if (!newFwdService.value.trim() || !newFwdInternal.value || !newFwdExternal.value) return
fwdError.value = ''
try {
await rpcClient.call({ method: 'router.add-forward', params: {
service_name: newFwdService.value.trim(),
internal_port: newFwdInternal.value,
external_port: newFwdExternal.value,
protocol: 'TCP',
}})
showAddForwardModal.value = false
newFwdService.value = ''
newFwdInternal.value = 0
newFwdExternal.value = 0
await runNetworkDiag()
} catch (e: unknown) {
fwdError.value = e instanceof Error ? e.message : 'Failed to add forward'
}
}
async function removePortForward(id: string) {
try {
await rpcClient.call({ method: 'router.remove-forward', params: { id } })
await runNetworkDiag()
} catch {
// Silent
}
}
onMounted(async () => {
checkClaudeStatus()
loadTotpStatus()
runNetworkDiag()
if (!serverTorAddressFromStore.value) {
try {
const res = await rpcClient.getTorAddress()
+2163 -253
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,308 @@
<template>
<div class="pb-16 md:pb-4">
<!-- Back Button -->
<button @click="router.push('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to LND
</button>
<h1 class="text-2xl font-bold text-white mb-6">Lightning Channels</h1>
<!-- Liquidity Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Outbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_outbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Total Inbound</p>
<p class="text-white text-xl font-bold">{{ formatSats(summary.total_inbound) }}</p>
</div>
<div class="glass-card p-4">
<p class="text-white/60 text-sm mb-1">Channels</p>
<p class="text-white text-xl font-bold">{{ channels.length }}</p>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
Open Channel
</button>
</div>
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<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>
<p class="text-white/70">Loading channels...</p>
</div>
<!-- Error -->
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/70 mb-2">No channels yet</p>
<p class="text-white/50 text-sm">Open a channel to start sending and receiving Lightning payments.</p>
</div>
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
Close
</button>
</div>
<!-- Peer -->
<p class="text-white/50 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<!-- Capacity Bar -->
<div class="mb-2">
<div class="flex justify-between text-xs text-white/60 mb-1">
<span>Local: {{ formatSats(ch.local_balance) }}</span>
<span>Remote: {{ formatSats(ch.remote_balance) }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full overflow-hidden flex">
<div
class="bg-blue-400 h-full transition-all"
:style="{ width: capacityPercent(ch.local_balance, ch.capacity) + '%' }"
></div>
<div
class="bg-orange-400 h-full transition-all"
:style="{ width: capacityPercent(ch.remote_balance, ch.capacity) + '%' }"
></div>
</div>
<p class="text-white/40 text-xs mt-1 text-center">
Capacity: {{ formatSats(ch.capacity) }}
</p>
</div>
</div>
</div>
</Transition>
<!-- Open Channel Modal -->
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Peer URI</label>
<input
v-model="openForm.peerUri"
type="text"
placeholder="pubkey@host:port"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
/>
<p class="text-white/40 text-xs mt-1">Format: pubkey@host:port</p>
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Amount (sats)</label>
<input
v-model.number="openForm.amount"
type="number"
min="20000"
placeholder="100000"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30"
/>
<p class="text-white/40 text-xs mt-1">Minimum 20,000 sats</p>
</div>
</div>
<div v-if="openError" class="mt-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ openError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showOpenModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="openChannel"
:disabled="openingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30"
>
{{ openingChannel ? 'Opening...' : 'Open Channel' }}
</button>
</div>
</div>
</div>
<!-- Close Confirmation Modal -->
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<div v-if="closeError" class="mb-3 p-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p class="text-red-300 text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/20 border-red-500/30"
>
{{ closingChannel ? 'Closing...' : 'Close' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '../../api/rpc-client'
const router = useRouter()
interface Channel {
chan_id: string
remote_pubkey: string
capacity: number
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
}
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
const showOpenModal = ref(false)
const openForm = ref({ peerUri: '', amount: 100000 })
const openingChannel = ref(false)
const openError = ref<string | null>(null)
const closeTarget = ref<Channel | null>(null)
const closingChannel = ref(false)
const closeError = ref<string | null>(null)
function formatSats(sats: number): string {
if (sats >= 100_000_000) return `${(sats / 100_000_000).toFixed(2)} BTC`
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1)}M sats`
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1)}k sats`
return `${sats} sats`
}
function capacityPercent(amount: number, capacity: number): number {
if (capacity <= 0) return 0
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
} finally {
loading.value = false
}
}
async function openChannel() {
if (openingChannel.value) return
openError.value = null
const uri = openForm.value.peerUri.trim()
if (!uri) { openError.value = 'Peer URI is required'; return }
if (openForm.value.amount < 20000) { openError.value = 'Minimum 20,000 sats'; return }
const parts = uri.split('@')
const pubkey = parts[0]
const address = parts[1] || undefined
openingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.openchannel',
params: { pubkey, address, amount: openForm.value.amount },
timeout: 30000,
})
showOpenModal.value = false
openForm.value = { peerUri: '', amount: 100000 }
await loadChannels()
} catch (err: unknown) {
openError.value = err instanceof Error ? err.message : 'Failed to open channel'
} finally {
openingChannel.value = false
}
}
function confirmClose(ch: Channel) {
closeTarget.value = ch
closeError.value = null
}
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
})
closeTarget.value = null
await loadChannels()
} catch (err: unknown) {
closeError.value = err instanceof Error ? err.message : 'Failed to close channel'
} finally {
closingChannel.value = false
}
}
onMounted(loadChannels)
</script>