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:
co-authored by
Claude Opus 4.6
parent
5ce8b7965c
commit
e3aa95a103
@@ -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'
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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>
|
||||
Reference in New Issue
Block a user