frontend: polish app launch and release experience
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
:package-key="packageKey"
|
||||
:can-launch="canLaunch"
|
||||
:is-web-only="isWebOnly"
|
||||
:pending-action="pendingAction"
|
||||
@launch="launchApp"
|
||||
@start="startApp"
|
||||
@stop="stopApp"
|
||||
@@ -57,6 +58,7 @@
|
||||
:tor-url="torUrl"
|
||||
:show-tor-address="showTorAddress"
|
||||
:credentials="credentials"
|
||||
:credentials-loading="credentialsLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,52 +72,13 @@
|
||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Uninstall Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="uninstallModal.show"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="closeUninstallModal()"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="uninstallModalRef"
|
||||
@click.stop
|
||||
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-6">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-red-400" 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>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm">
|
||||
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
||||
<button
|
||||
@click="closeUninstallModal()"
|
||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="pendingAction === 'uninstall'"
|
||||
@close="closeUninstallModal"
|
||||
@confirm="confirmUninstall"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
@@ -135,14 +98,15 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { dummyApps } from '../utils/dummyApps'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
import AppHeroSection from './appDetails/AppHeroSection.vue'
|
||||
import AppContentSection from './appDetails/AppContentSection.vue'
|
||||
import AppSidebar from './appDetails/AppSidebar.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppUrl } from './appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
|
||||
import {
|
||||
WEB_ONLY_APP_URLS,
|
||||
@@ -217,6 +181,8 @@ const bitcoinSyncPercent = ref(0)
|
||||
const bitcoinBlockHeight = ref(0)
|
||||
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
|
||||
const credentials = ref<AppCredentialsResponse | null>(null)
|
||||
const credentialsLoading = ref(false)
|
||||
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
|
||||
|
||||
async function loadBitcoinSync() {
|
||||
if (!needsBitcoinSync.value) return
|
||||
@@ -235,15 +201,18 @@ async function loadBitcoinSync() {
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!appId.value) return
|
||||
credentialsLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: packageKey.value },
|
||||
timeout: 5000,
|
||||
})
|
||||
credentials.value = result.credentials?.length ? result : null
|
||||
credentials.value = resolveAppCredentials(packageKey.value, result)
|
||||
} catch {
|
||||
credentials.value = null
|
||||
credentials.value = resolveAppCredentials(packageKey.value, null)
|
||||
} finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,21 +231,11 @@ function showActionError(msg: string) {
|
||||
}
|
||||
|
||||
const uninstallModal = ref({ show: false, appTitle: '' })
|
||||
const uninstallModalRef = ref<HTMLElement | null>(null)
|
||||
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUninstallModal() {
|
||||
uninstallRestoreFocusRef.value?.focus?.()
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
useModalKeyboard(
|
||||
uninstallModalRef,
|
||||
computed(() => uninstallModal.value.show),
|
||||
closeUninstallModal,
|
||||
{ restoreFocusRef: uninstallRestoreFocusRef }
|
||||
)
|
||||
|
||||
const backButtonText = computed(() => {
|
||||
if (route.query.from === 'discover') return 'Back to Discover'
|
||||
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
|
||||
@@ -300,7 +259,15 @@ const features = computed(() => [
|
||||
])
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
if (route.query.from === 'discover') {
|
||||
router.push('/dashboard/discover').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (route.query.from === 'marketplace') {
|
||||
router.push('/dashboard/marketplace').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
}
|
||||
|
||||
function launchApp() {
|
||||
@@ -335,34 +302,46 @@ function launchApp() {
|
||||
|
||||
|
||||
async function startApp() {
|
||||
pendingAction.value = 'start'
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopApp() {
|
||||
pendingAction.value = 'stop'
|
||||
try {
|
||||
await store.stopPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
pendingAction.value = 'restart'
|
||||
try {
|
||||
await store.restartPackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
pendingAction.value = 'update'
|
||||
try {
|
||||
await store.updatePackage(appId.value)
|
||||
} catch (err) {
|
||||
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,13 +350,16 @@ function showUninstallModal() {
|
||||
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
|
||||
}
|
||||
|
||||
async function confirmUninstall() {
|
||||
async function confirmUninstall(deleteAppData: boolean) {
|
||||
uninstallModal.value.show = false
|
||||
pendingAction.value = 'uninstall'
|
||||
try {
|
||||
await store.uninstallPackage(appId.value)
|
||||
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
} catch (err) {
|
||||
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="addingRegistry"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
|
||||
@click.self="cancelAddRegistry"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full mx-4">
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
:must-open-new-tab="mustOpenNewTab"
|
||||
:auto-retry-count="autoRetryCount"
|
||||
:refresh-key="refreshKey"
|
||||
:blocked-reason="blockedReason"
|
||||
:blocked-title="blockedTitle"
|
||||
@iframe-load="onLoad"
|
||||
@iframe-error="onError"
|
||||
@refresh="refresh"
|
||||
@@ -101,6 +103,7 @@ import {
|
||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||
resolveAppUrl, resolveAppTitle,
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason } from './apps/appsConfig'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
|
||||
@@ -147,6 +150,9 @@ const appId = computed(() => {
|
||||
})
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
@@ -344,8 +350,9 @@ watch(displayMode, (mode) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Apps that block iframes open externally instead of landing in a broken webview.
|
||||
if (mustOpenNewTab.value && appUrl.value) {
|
||||
// Apps that block iframes open externally on desktop. On mobile, keep the
|
||||
// session surface visible so launcher taps do not bounce straight out.
|
||||
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
|
||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
@@ -355,7 +362,7 @@ onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('message', onMessage)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
|
||||
loading.value = false
|
||||
iframeBlocked.value = true
|
||||
} else {
|
||||
|
||||
+73
-12
@@ -4,12 +4,12 @@
|
||||
<div class="mb-4">
|
||||
<!-- Desktop: page tabs + category tabs + search -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex flex-shrink-0">
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
|
||||
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
|
||||
</div>
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
|
||||
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@@ -18,7 +18,7 @@
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div class="app-header-search-wrap flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -88,6 +88,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container scanner still warming up -->
|
||||
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-8 max-w-md mx-auto">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<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>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
|
||||
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
|
||||
<div class="glass-card p-12 max-w-md mx-auto">
|
||||
@@ -134,6 +146,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
|
||||
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
|
||||
<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>Refreshing container state. Showing the last known app list until the scan finishes.</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: iPhone-style icon grid -->
|
||||
<div class="md:hidden">
|
||||
<AppIconGrid
|
||||
@@ -176,7 +199,7 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="credentialModal.show"
|
||||
class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
|
||||
@click.self="closeCredentialModal"
|
||||
>
|
||||
<div class="sideload-modal credential-modal">
|
||||
@@ -290,7 +313,10 @@ import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry,
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { resolveAppCredentials } from './apps/appCredentials'
|
||||
import { useLastKnownPackages } from './apps/appPackageCache'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import { validateSideloadRequest } from './apps/sideloadValidation'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
@@ -351,9 +377,16 @@ const selectedCategory = ref('all')
|
||||
|
||||
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
|
||||
|
||||
const livePackages = computed(() => store.packages || {})
|
||||
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
|
||||
const {
|
||||
packages: stablePackages,
|
||||
isUsingLastKnownPackages,
|
||||
} = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
// Merge real packages from store with web-only app bookmarks + installing placeholders
|
||||
const packages = computed(() => {
|
||||
const realPackages = store.packages || {}
|
||||
const realPackages = stablePackages.value
|
||||
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
|
||||
|
||||
// Inject placeholder entries for apps being installed that aren't in backend data yet
|
||||
@@ -393,6 +426,14 @@ const marketplaceMatches = computed(() => {
|
||||
})
|
||||
|
||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||
const isCheckingContainers = computed(() => (
|
||||
store.hasLoadedInitialData &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
!isUsingLastKnownPackages.value &&
|
||||
sortedPackageEntries.value.length === 0 &&
|
||||
!searchQuery.value &&
|
||||
!containersScanned.value
|
||||
))
|
||||
|
||||
// Connection error state
|
||||
const connectionError = ref('')
|
||||
@@ -457,13 +498,13 @@ function closeUninstallModal() {
|
||||
uninstallModal.value.show = false
|
||||
}
|
||||
|
||||
async function onConfirmUninstall() {
|
||||
async function onConfirmUninstall(deleteAppData: boolean) {
|
||||
const { appId } = uninstallModal.value
|
||||
// Close the modal immediately so the user can fire off concurrent
|
||||
// uninstalls. Each AppCard surfaces its own live stage label while
|
||||
// its uninstall is in flight.
|
||||
uninstallModal.value.show = false
|
||||
await actions.confirmUninstall(appId)
|
||||
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
@@ -508,18 +549,29 @@ async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
if (!result.credentials?.length) return false
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +639,11 @@ async function submitSideload() {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
const validationError = validateSideloadRequest(id, port, store.packages)
|
||||
if (validationError) {
|
||||
sideloadError.value = validationError
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
@@ -693,6 +750,10 @@ async function submitSideload() {
|
||||
.credential-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-body {
|
||||
min-height: 0;
|
||||
|
||||
@@ -83,6 +83,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing peer nodes...
|
||||
</div>
|
||||
|
||||
<!-- No Peers placeholder (only if no peers found) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0"
|
||||
@@ -263,12 +274,13 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
async function loadPeers() {
|
||||
const hadPeers = peerNodes.value.length > 0
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peerNodes.value = result?.nodes ?? []
|
||||
} catch (e) {
|
||||
peerNodes.value = []
|
||||
if (!hadPeers) peerNodes.value = []
|
||||
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
@@ -283,4 +295,6 @@ function peerDisplayName(did: string): string {
|
||||
function openSection(section: ContentSection) {
|
||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||
}
|
||||
|
||||
defineExpose({ loadPeers })
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cloud
|
||||
{{ backLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
|
||||
@@ -18,7 +18,7 @@
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Cloud</span>
|
||||
<span>{{ backLabel }}</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<CloudToolbar
|
||||
:breadcrumbs="cloudStore.breadcrumbs"
|
||||
:view-mode="viewMode"
|
||||
@navigate="cloudStore.navigate($event)"
|
||||
@navigate="navigateCloudPath"
|
||||
@refresh="cloudStore.refresh()"
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@@ -115,7 +115,7 @@
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="cloudStore.navigate($event)"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@@ -178,6 +178,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
|
||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||
import { getFileCategory } from '../composables/useFileType'
|
||||
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -189,6 +190,7 @@ const audioPlayer = useAudioPlayer()
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
@@ -286,14 +288,20 @@ const section = computed(() => {
|
||||
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
|
||||
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
|
||||
const iframeUrl = computed(() => section.value?.iframeUrl || '')
|
||||
const backLabel = computed(() => {
|
||||
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
|
||||
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
|
||||
})
|
||||
|
||||
// Initialize native file browser when entering a native-UI section
|
||||
watch([useNativeUI, section], async ([native, sec]) => {
|
||||
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
|
||||
if (native && sec) {
|
||||
cloudStore.reset()
|
||||
if (cloudStore.currentPath !== path) {
|
||||
cloudStore.reset()
|
||||
}
|
||||
const ok = await cloudStore.init()
|
||||
if (ok) {
|
||||
await cloudStore.navigate(sec.initialPath)
|
||||
await cloudStore.navigate(path)
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -370,7 +378,20 @@ function openExternal() {
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateCloudPath(path: string) {
|
||||
const target = normalizeCloudPath(path, section.value?.initialPath || '/')
|
||||
await router.push({
|
||||
name: 'cloud-folder',
|
||||
params: { folderId: folderId.value },
|
||||
query: target === section.value?.initialPath ? {} : { path: target },
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
|
||||
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
|
||||
return
|
||||
}
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
No credentials yet. Issue one above or receive one from a peer.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="loadingCreds" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing credentials...
|
||||
</div>
|
||||
<div
|
||||
v-for="cred in credentials"
|
||||
:key="cred.id"
|
||||
@@ -115,7 +122,7 @@
|
||||
<!-- Credential Detail Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
||||
<div class="fixed inset-0 bg-black/10 backdrop-blur-md"></div>
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||
@@ -403,6 +410,8 @@ async function copyCredentialJson() {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadIdentities(), loadCredentials()])
|
||||
})
|
||||
|
||||
defineExpose({ credentials, loadCredentials })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
|
||||
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
||||
:class="['h-full dashboard-scroll-panel mobile-scroll-pad', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
|
||||
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
|
||||
class="mobile-safe-top"
|
||||
>
|
||||
@@ -94,7 +94,7 @@
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top',
|
||||
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
|
||||
needsMobileBackButtonSpace
|
||||
? 'mobile-scroll-pad-back'
|
||||
: 'mobile-scroll-pad'
|
||||
|
||||
@@ -3,25 +3,50 @@
|
||||
<!-- Navigation Bar (always at top) -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div class="hidden md:flex mb-6 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
<div ref="discoverHeaderRef" class="hidden md:flex mb-6 items-center gap-4 relative">
|
||||
<div ref="discoverPrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
||||
>Discover</RouterLink>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="navigateToMarketplace(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
|
||||
>
|
||||
{{ category.name }}
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="discover-category-select">App Store category</label>
|
||||
<select
|
||||
id="discover-category-select"
|
||||
class="segmented-select-control"
|
||||
value="discover"
|
||||
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -29,7 +54,7 @@
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
aria-label="Search apps"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -40,14 +65,14 @@
|
||||
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="navigateToMarketplace(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectDiscoverCategory(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@@ -193,6 +218,8 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import DiscoverHero from './discover/DiscoverHero.vue'
|
||||
import FeaturedApps from './discover/FeaturedApps.vue'
|
||||
import AppGrid from './discover/AppGrid.vue'
|
||||
@@ -211,19 +238,18 @@ const selectedCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||
const discoverHeaderRef = ref<HTMLElement | null>(null)
|
||||
const discoverPrimaryRef = ref<HTMLElement | null>(null)
|
||||
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
discoverHeaderRef,
|
||||
discoverPrimaryRef,
|
||||
discoverCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'l484', name: 'L484' },
|
||||
{ id: 'other', name: 'Other' }
|
||||
])
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation.
|
||||
// The store's watcher (stores/server.ts) handles install-progress updates
|
||||
@@ -236,6 +262,14 @@ function navigateToMarketplace(categoryId: string) {
|
||||
router.push({ name: 'marketplace', query: { category: categoryId } })
|
||||
}
|
||||
|
||||
function selectDiscoverCategory(categoryId: string) {
|
||||
if (categoryId === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
navigateToMarketplace(categoryId)
|
||||
}
|
||||
|
||||
// Community & Nostr marketplace state
|
||||
const loadingCommunity = ref(false)
|
||||
const communityError = ref('')
|
||||
@@ -303,14 +337,6 @@ const allApps = computed(() => {
|
||||
return base
|
||||
})
|
||||
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.value.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||
|
||||
@@ -371,14 +371,23 @@ function isOnlineCheck(node: FederatedNode): boolean {
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
return loadNodesWithOptions()
|
||||
}
|
||||
|
||||
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
|
||||
const showLoader = options.showLoader ?? nodes.value.length === 0
|
||||
const surfaceErrors = options.surfaceErrors ?? true
|
||||
try {
|
||||
loading.value = true
|
||||
if (showLoader) loading.value = true
|
||||
const result = await rpcClient.federationListNodes()
|
||||
nodes.value = result.nodes
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||
if (surfaceErrors) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (showLoader) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +549,7 @@ async function rotateDid(password: string) {
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadNodes()
|
||||
loadNodesWithOptions({ showLoader: true })
|
||||
loadDwnStatus()
|
||||
loadDiscoveryState()
|
||||
loadPendingRequests()
|
||||
@@ -552,7 +561,7 @@ onMounted(async () => {
|
||||
// Self DID not available
|
||||
}
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
loadNodes()
|
||||
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
|
||||
loadPendingRequests()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
@@ -22,18 +22,27 @@
|
||||
|
||||
<!-- Header -->
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-center min-[1440px]:justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
|
||||
<p class="text-white/70">Beta Telemetry — monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span v-if="fleet.autoRefresh.value" class="text-xs text-white/40">Auto-refresh 60s</span>
|
||||
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact col-span-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap min-[1440px]:col-span-1 min-[1440px]:min-w-[220px]">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">
|
||||
Refresh
|
||||
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
|
||||
Export JSON
|
||||
@@ -46,15 +55,37 @@
|
||||
<div class="md:hidden mb-6">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
|
||||
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="monitoring-stat-card monitoring-stat-card-compact mb-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap">
|
||||
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
|
||||
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-block h-1.5 w-1.5 rounded-full"
|
||||
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
|
||||
></span>
|
||||
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.refreshAll">Refresh</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.toggleAutoRefresh">
|
||||
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
|
||||
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
|
||||
<div class="text-white/50 text-sm">Loading fleet data...</div>
|
||||
<div class="glass-card p-8 max-w-md text-center">
|
||||
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
|
||||
<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>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Loading fleet data</h3>
|
||||
<p class="text-white/60 text-sm">Checking beta telemetry reports from connected nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
@@ -76,6 +107,14 @@
|
||||
:avg-disk="fleet.avgDisk.value"
|
||||
/>
|
||||
|
||||
<div v-if="fleet.refreshing.value && fleet.nodes.value.length > 0" class="glass-card p-3 mb-4 text-sm text-white/60 flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 text-white/50" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing fleet telemetry...
|
||||
</div>
|
||||
|
||||
<FleetNodeGrid
|
||||
:nodes="fleet.nodes.value"
|
||||
:sorted-nodes="fleet.sortedNodes.value"
|
||||
|
||||
@@ -111,6 +111,13 @@
|
||||
>
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@click="completeVerifyStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.checkAndContinue') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'info'"
|
||||
@click="completeInfoStep(step)"
|
||||
@@ -167,6 +174,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
@@ -287,10 +295,7 @@ async function installApp(step: GoalStep) {
|
||||
if (!step.appId) return
|
||||
isInstalling.value = true
|
||||
|
||||
// Start goal tracking if not already started
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
ensureGoalStarted()
|
||||
|
||||
try {
|
||||
await appStore.installPackage(step.appId, '', 'latest')
|
||||
@@ -303,19 +308,28 @@ async function installApp(step: GoalStep) {
|
||||
}
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
// Mark step as complete when user acknowledges they've configured
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
// If there's an app to open, navigate to it
|
||||
if (step.appId) {
|
||||
router.push(`/dashboard/apps/${step.appId}`)
|
||||
const targetPath = goalStepTargetPath(step)
|
||||
if (targetPath) {
|
||||
router.push(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
function completeVerifyStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function completeInfoStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function ensureGoalStarted() {
|
||||
if (!goalStore.progress[goalId.value]) {
|
||||
goalStore.startGoal(goalId.value)
|
||||
}
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
|
||||
+120
-35
@@ -86,8 +86,8 @@
|
||||
</div>
|
||||
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
|
||||
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
|
||||
<div class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center" :style="app.bg ? { background: app.bg } : {}" :class="{ 'bg-white/5': !app.bg }">
|
||||
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
|
||||
<div class="w-14 h-14 rounded-xl overflow-hidden transition-all group-hover:-translate-y-1 group-hover:shadow-lg flex items-center justify-center">
|
||||
<img :src="app.icon" :alt="app.name" class="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,8 +125,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Store Recommendations -->
|
||||
<div
|
||||
v-if="homeRecommendedApps.length > 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animateCards }"
|
||||
style="--card-stagger: 2"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
<div class="home-card-text">
|
||||
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
|
||||
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<button
|
||||
v-for="app in homeRecommendedApps"
|
||||
:key="app.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
|
||||
@click="viewRecommendedApp(app)"
|
||||
>
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title || app.id"
|
||||
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
|
||||
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 2">
|
||||
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
|
||||
@@ -179,6 +229,33 @@
|
||||
@open-in-mempool="openInMempool"
|
||||
/>
|
||||
|
||||
<!-- Quick Start Goals -->
|
||||
<div
|
||||
v-if="showQuickStart"
|
||||
class="home-card transition-opacity duration-300"
|
||||
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
style="--card-stagger: 5"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner px-6 py-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-2 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||
</div>
|
||||
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 mt-auto">
|
||||
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
|
||||
<span>{{ goal.title }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Stats -->
|
||||
<HomeSystemCard
|
||||
:animate="animateCards"
|
||||
@@ -187,33 +264,6 @@
|
||||
:uptime-display="systemUptimeDisplay"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Start Goals -->
|
||||
<div
|
||||
v-if="homeTab === 'dashboard' && showQuickStart"
|
||||
class="home-card transition-opacity duration-300"
|
||||
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
style="--card-stagger: 5"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner px-6 py-6">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
|
||||
</div>
|
||||
<button @click="dismissQuickStart" aria-label="Dismiss Quick Start" class="text-white/40 hover:text-white/80 transition-colors p-1 -mt-1 -mr-1" title="Dismiss">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
|
||||
<span>{{ goal.title }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Chat Mode -->
|
||||
@@ -246,6 +296,11 @@ import { GOALS } from '@/data/goals'
|
||||
import EasyHome from '@/components/EasyHome.vue'
|
||||
import { fileBrowserClient } from '@/api/filebrowser-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { getAppUsage } from '@/utils/appUsage'
|
||||
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||
import { getHomeRecommendedApps } from './home/homeRecommendations'
|
||||
import HomeWalletCard from './home/HomeWalletCard.vue'
|
||||
import HomeSystemCard from './home/HomeSystemCard.vue'
|
||||
import type { WalletTransaction } from './home/HomeWalletCard.vue'
|
||||
@@ -264,6 +319,7 @@ const QUICK_START_RESHOW_LOGINS = 5
|
||||
const store = useAppStore()
|
||||
const homeStatus = useHomeStatusStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const { setCurrentApp } = useMarketplaceApp()
|
||||
|
||||
const LINE1 = t('home.title')
|
||||
const LINE2 = t('home.subtitle')
|
||||
@@ -306,11 +362,40 @@ const packages = computed(() => store.packages)
|
||||
const appCount = computed(() => Object.keys(packages.value || {}).length)
|
||||
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
|
||||
|
||||
const quickLaunchApps = [
|
||||
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
|
||||
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
|
||||
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
|
||||
]
|
||||
const quickLaunchApps = computed(() => {
|
||||
const usage = getAppUsage()
|
||||
return Object.entries(packages.value || {})
|
||||
.filter(([id, pkg]) => !isServicePackage(id, pkg) && !isWebsitePackage(id, pkg))
|
||||
.map(([id, pkg]) => ({
|
||||
id,
|
||||
name: pkg.manifest?.title || id,
|
||||
icon: resolveAppIcon(id, pkg),
|
||||
state: pkg.state,
|
||||
usage: usage[id]?.count || 0,
|
||||
lastLaunchedAt: usage[id]?.lastLaunchedAt || 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.usage !== a.usage) return b.usage - a.usage
|
||||
if (b.lastLaunchedAt !== a.lastLaunchedAt) return b.lastLaunchedAt - a.lastLaunchedAt
|
||||
if ((b.state === PackageState.Running ? 1 : 0) !== (a.state === PackageState.Running ? 1 : 0)) {
|
||||
return (b.state === PackageState.Running ? 1 : 0) - (a.state === PackageState.Running ? 1 : 0)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppList(), packages.value, 3))
|
||||
|
||||
function viewRecommendedApp(app: MarketplaceApp) {
|
||||
setCurrentApp(app)
|
||||
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
|
||||
}
|
||||
|
||||
function marketplaceDescription(app: MarketplaceApp) {
|
||||
if (typeof app.description === 'string') return app.description
|
||||
return app.description?.short || app.description?.long || ''
|
||||
}
|
||||
|
||||
// Network card data
|
||||
const torConnected = computed(() => {
|
||||
|
||||
@@ -3,27 +3,51 @@
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<!-- Desktop: tabs + categories + search -->
|
||||
<div class="hidden md:flex mb-4 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
<div ref="marketplaceHeaderRef" class="hidden md:flex mb-4 items-center gap-4 relative">
|
||||
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
|
||||
<div class="mode-switcher hidden md:inline-flex">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/discover"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
|
||||
>Discover</RouterLink>
|
||||
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mode-switcher-btn"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
|
||||
>
|
||||
{{ category.name }}
|
||||
<span v-if="category.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
||||
{{ section.name }}
|
||||
<span v-if="section.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
|
||||
<label class="sr-only" for="marketplace-category-select">App Store category</label>
|
||||
<select
|
||||
id="marketplace-category-select"
|
||||
:value="selectedCategory"
|
||||
class="segmented-select-control"
|
||||
@change="selectAppStoreSection(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
:value="section.id"
|
||||
>
|
||||
{{ section.name }}{{ section.id === 'nostr' && nostrApps.length > 0 ? ` +${nostrApps.length}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div ref="marketplaceCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
|
||||
<button
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
class="mode-switcher-btn"
|
||||
type="button"
|
||||
>
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -31,7 +55,7 @@
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,18 +67,13 @@
|
||||
</div>
|
||||
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
|
||||
<button
|
||||
@click="router.push({ name: 'discover' })"
|
||||
v-for="section in appStoreSections"
|
||||
:key="section.id"
|
||||
@click="selectAppStoreSection(section.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === section.id }"
|
||||
type="button"
|
||||
>Discover</button>
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
class="mobile-category-pill"
|
||||
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
|
||||
type="button"
|
||||
>{{ category.name }}</button>
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@@ -76,6 +95,26 @@
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<template v-if="(loadingCommunity || nostrLoading) && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-6 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<MarketplaceAppCard
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
@@ -97,15 +136,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
||||
<div v-if="loadingCommunity || nostrLoading" class="flex flex-col items-center gap-4">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400" 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">{{ nostrLoading ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
|
||||
</div>
|
||||
<div v-else-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
||||
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
|
||||
<div v-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
|
||||
@@ -132,6 +164,8 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
import {
|
||||
type MarketplaceApp,
|
||||
@@ -155,19 +189,8 @@ const toast = useToast()
|
||||
// Category state — read initial value from query param (set by Discover page navigation)
|
||||
const selectedCategory = ref((route.query.category as string) || 'all')
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', name: t('marketplace.all') },
|
||||
{ id: 'community', name: t('marketplace.community') },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: t('marketplace.commerce') },
|
||||
{ id: 'money', name: t('marketplace.money') },
|
||||
{ id: 'data', name: t('marketplace.data') },
|
||||
{ id: 'home', name: t('marketplace.homeCategory') },
|
||||
{ id: 'car', name: t('marketplace.auto') },
|
||||
{ id: 'networking', name: t('marketplace.networking') },
|
||||
{ id: 'l484', name: 'L484' },
|
||||
{ id: 'other', name: t('marketplace.other') }
|
||||
])
|
||||
const categories = computed(() => APP_STORE_CATEGORIES)
|
||||
const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
||||
|
||||
// Installation state — uses global store so it persists across navigation
|
||||
const installingApps = server.installingApps
|
||||
@@ -186,6 +209,14 @@ function selectCategory(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAppStoreSection(id: string) {
|
||||
if (id === 'discover') {
|
||||
router.push('/dashboard/discover')
|
||||
return
|
||||
}
|
||||
selectCategory(id)
|
||||
}
|
||||
|
||||
watch(() => route.query.category, (category) => {
|
||||
const next = typeof category === 'string' && category ? category : 'all'
|
||||
selectedCategory.value = next
|
||||
@@ -200,6 +231,15 @@ const communityError = ref('')
|
||||
const communityApps = ref<MarketplaceApp[]>([])
|
||||
const searchQuery = ref('')
|
||||
const bitcoinPruned = ref(false)
|
||||
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
|
||||
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
|
||||
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
|
||||
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
|
||||
marketplaceHeaderRef,
|
||||
marketplacePrimaryRef,
|
||||
marketplaceCategoryProbeRef,
|
||||
144
|
||||
)
|
||||
|
||||
// Nostr community marketplace state
|
||||
const nostrApps = ref<MarketplaceApp[]>([])
|
||||
@@ -270,14 +310,6 @@ const allApps = computed(() => {
|
||||
return base
|
||||
})
|
||||
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.value.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
|
||||
@@ -500,4 +532,49 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 255px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -201,20 +201,18 @@
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@@ -443,6 +441,26 @@ const longDescription = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
|
||||
|
||||
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
|
||||
// Placeholder features
|
||||
const features = computed(() => {
|
||||
return [
|
||||
|
||||
@@ -15,10 +15,12 @@ const transport = useTransportStore()
|
||||
|
||||
// Responsive layout breakpoints
|
||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
|
||||
const isMobile = ref(window.innerWidth < 1280)
|
||||
|
||||
function handleResize() {
|
||||
isWideDesktop.value = window.innerWidth >= 1536
|
||||
isVeryWideDesktop.value = window.innerWidth >= 2560 && window.innerHeight >= 1200
|
||||
isMobile.value = window.innerWidth < 1280
|
||||
}
|
||||
|
||||
@@ -251,15 +253,21 @@ const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
)
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map'
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||
@@ -1270,7 +1278,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
||||
|
||||
<!-- Responsive column layout -->
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<!-- Device Status -->
|
||||
@@ -1727,8 +1735,7 @@ function isImageMime(mime?: string): boolean {
|
||||
|
||||
<!-- Tools panels (3rd column on wide screens) -->
|
||||
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
|
||||
<!-- Tools tab bar (wide desktop only) -->
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
@@ -1742,10 +1749,9 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-6 px-3 sm:px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-6 px-3 sm:px-4 max-w-[760px] mx-auto w-full">
|
||||
<!-- Fresh Start -->
|
||||
<button
|
||||
@click="selectOption('fresh')"
|
||||
@@ -52,22 +52,6 @@
|
||||
Enter your 24-word recovery phrase
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<!-- Connect Existing (Coming Soon) -->
|
||||
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
|
||||
<div class="mb-3 sm:mb-4">
|
||||
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Connect Existing</h3>
|
||||
<p class="text-white/70 text-xs sm:text-sm">
|
||||
Connect to an existing Archipelago server
|
||||
</p>
|
||||
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="glass-card p-8 text-center">
|
||||
<div v-if="loading && catalogItems.length === 0" class="glass-card p-8 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" 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>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="catalogError" class="glass-card p-6">
|
||||
<div v-else-if="catalogError && catalogItems.length === 0" class="glass-card p-6">
|
||||
<div class="alert-error mb-4">{{ catalogError }}</div>
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
||||
</div>
|
||||
@@ -67,12 +67,23 @@
|
||||
</div>
|
||||
|
||||
<!-- File Grid -->
|
||||
<div v-else-if="catalogItems.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card overflow-hidden"
|
||||
>
|
||||
<div v-if="catalogItems.length > 0" class="space-y-3">
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing peer files...
|
||||
</div>
|
||||
<div v-else-if="catalogError" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
|
||||
{{ catalogError }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card overflow-hidden"
|
||||
>
|
||||
<!-- Media preview (images / videos / audio) -->
|
||||
<div
|
||||
v-if="isMediaMime(item.mime_type)"
|
||||
@@ -192,6 +203,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,9 +325,9 @@ onMounted(async () => {
|
||||
async function loadCatalog() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
const hadItems = catalogItems.value.length > 0
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
catalogItems.value = []
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
@@ -325,6 +337,7 @@ async function loadCatalog() {
|
||||
catalogItems.value = result?.items ?? []
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
if (!hadItems) catalogItems.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -563,6 +576,8 @@ function triggerDownload(base64Data: string, item: CatalogItem) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
defineExpose({ loadCatalog })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="networkRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing network...
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
@@ -135,46 +142,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
||||
Manage Local Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Web3</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Decentralized web hosting and services</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div v-for="item in ['Hosted Websites', 'SSL Certificates', 'IPFS Storage', 'ENS Domains']" :key="item" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
|
||||
<span class="text-white/80 text-sm">{{ item }}</span>
|
||||
</div>
|
||||
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
|
||||
</div>
|
||||
</div>
|
||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
||||
Manage Web3 Services
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FIPS Mesh (full card) -->
|
||||
<div class="mb-8">
|
||||
<FipsNetworkCard />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||
<!-- VPN Card -->
|
||||
<div class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||
<div class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
@@ -185,7 +160,7 @@
|
||||
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-top glass-button px-4 py-2 text-sm">Add Device</button>
|
||||
</div>
|
||||
|
||||
<!-- WireGuard Status -->
|
||||
@@ -221,10 +196,14 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
|
||||
</div>
|
||||
|
||||
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Network Interfaces (second column on desktop) -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
|
||||
@@ -233,7 +212,7 @@
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="responsive-card-actions-top px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
@@ -246,6 +225,13 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfacesRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing interfaces...
|
||||
</div>
|
||||
<div
|
||||
v-for="iface in physicalInterfaces"
|
||||
:key="iface.name"
|
||||
@@ -266,6 +252,14 @@
|
||||
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="wifiAvailable"
|
||||
@click="showWifiModal = true"
|
||||
class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Scan WiFi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div><!-- close VPN+Network 2-col grid -->
|
||||
@@ -413,6 +407,8 @@ const logCount = ref(0)
|
||||
|
||||
// Network data
|
||||
const networkLoading = ref(true)
|
||||
const networkRefreshing = ref(false)
|
||||
const networkHasLoaded = ref(false)
|
||||
const networkData = ref({
|
||||
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
|
||||
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
|
||||
@@ -449,7 +445,9 @@ async function loadFipsSummary() {
|
||||
}
|
||||
|
||||
async function loadNetworkData() {
|
||||
networkLoading.value = true
|
||||
const initialLoad = !networkHasLoaded.value
|
||||
networkLoading.value = initialLoad
|
||||
networkRefreshing.value = !initialLoad
|
||||
try {
|
||||
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
|
||||
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
|
||||
@@ -461,7 +459,11 @@ async function loadNetworkData() {
|
||||
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
|
||||
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
|
||||
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
|
||||
} catch { /* keep defaults */ } finally { networkLoading.value = false }
|
||||
} catch { /* keep existing/default values */ } finally {
|
||||
networkHasLoaded.value = true
|
||||
networkLoading.value = false
|
||||
networkRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// VPN peer management
|
||||
@@ -548,6 +550,8 @@ interface NetworkInterface { name: string; type: string; state: string; mac: str
|
||||
interface WifiNetwork { ssid: string; signal: number; security: string }
|
||||
|
||||
const interfacesLoading = ref(true)
|
||||
const interfacesRefreshing = ref(false)
|
||||
const interfacesHaveLoaded = ref(false)
|
||||
const allInterfaces = ref<NetworkInterface[]>([])
|
||||
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
|
||||
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
|
||||
@@ -599,8 +603,11 @@ async function applyDnsConfig(customServers: string) {
|
||||
}
|
||||
|
||||
async function loadInterfaces() {
|
||||
interfacesLoading.value = true
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { allInterfaces.value = [] } finally { interfacesLoading.value = false }
|
||||
const initialLoad = !interfacesHaveLoaded.value
|
||||
const hadInterfaces = allInterfaces.value.length > 0
|
||||
interfacesLoading.value = initialLoad
|
||||
interfacesRefreshing.value = !initialLoad
|
||||
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
@@ -670,9 +677,10 @@ const availableAppsForTor = computed(() => {
|
||||
})
|
||||
|
||||
async function loadTorServices() {
|
||||
const hadServices = torServices.value.length > 0
|
||||
torServicesLoading.value = true
|
||||
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
|
||||
catch { torServices.value = []; torDaemonRunning.value = false } finally { torServicesLoading.value = false }
|
||||
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
|
||||
}
|
||||
|
||||
async function copyTorAddress(address: string) {
|
||||
@@ -753,4 +761,16 @@ async function checkTorStatus() {
|
||||
|
||||
const logsToast = ref('')
|
||||
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
|
||||
|
||||
defineExpose({
|
||||
allInterfaces,
|
||||
interfacesRefreshing,
|
||||
loadInterfaces,
|
||||
loadNetworkData,
|
||||
loadTorServices,
|
||||
networkData,
|
||||
networkRefreshing,
|
||||
torServices,
|
||||
torServicesLoading,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppSession from '../AppSession.vue'
|
||||
|
||||
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
|
||||
mockReplace: vi.fn(),
|
||||
mockPush: vi.fn(),
|
||||
mockWindowOpen: vi.fn(),
|
||||
mockSuppress: vi.fn(),
|
||||
mockResume: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
params: { appId: 'gitea' },
|
||||
query: { returnTo: '/dashboard/apps' },
|
||||
fullPath: '/dashboard/apps/session/gitea',
|
||||
}),
|
||||
useRouter: () => ({ replace: mockReplace, push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appLauncher', () => ({
|
||||
useAppLauncherStore: () => ({ panelAppId: null }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ data: { 'package-data': {} } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/screensaver', () => ({
|
||||
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useAppIdentity', () => ({
|
||||
useAppIdentity: () => ({
|
||||
onIdentitySelected: vi.fn(),
|
||||
onIframeLoadIdentity: vi.fn(),
|
||||
handleIdentityRequest: vi.fn(),
|
||||
getStoredIdentity: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../appSession/useNostrBridge', () => ({
|
||||
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
describe('AppSession mobile new-tab apps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
|
||||
const wrapper = mount(AppSession, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
AppSessionHeader: true,
|
||||
NostrIdentityPicker: true,
|
||||
MobileGamepad: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('This app opens in a new tab')
|
||||
expect(wrapper.text()).toContain('Open in new tab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Cloud from '../Cloud.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePeer() {
|
||||
return {
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer Alpha',
|
||||
trust_level: 'trusted',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cloud peer list', () => {
|
||||
it('keeps peer nodes visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
|
||||
|
||||
const wrapper = mount(Cloud)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
const pending = deferred<{ nodes: [] }>()
|
||||
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Peer Alpha')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
|
||||
expect(wrapper.text()).not.toContain('No peers yet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Credentials from '../Credentials.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCredential(id: string) {
|
||||
return {
|
||||
id,
|
||||
type: ['VerifiableCredential', 'NodeOperator'],
|
||||
issuer: 'did:key:issuer',
|
||||
credentialSubject: { id: 'did:key:subject' },
|
||||
issuanceDate: '2026-06-10T10:00:00Z',
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
describe('Credentials', () => {
|
||||
it('keeps credentials visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
|
||||
if (request.method === 'identity.list-credentials') {
|
||||
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const wrapper = mount(Credentials, {
|
||||
global: {
|
||||
mocks: {
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
|
||||
const pending = deferred<{ credentials: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.list-credentials') return pending.promise
|
||||
return Promise.resolve({ identities: [] })
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).toContain('Refreshing credentials...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('NodeOperator')
|
||||
expect(wrapper.text()).toContain('cred-one')
|
||||
expect(wrapper.text()).not.toContain('Refreshing credentials...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OnboardingOptions from '../OnboardingOptions.vue'
|
||||
|
||||
const push = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavSounds', () => ({
|
||||
playNavSound: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('OnboardingOptions', () => {
|
||||
it('shows only usable setup paths', () => {
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
expect(wrapper.text()).toContain('Fresh Start')
|
||||
expect(wrapper.text()).toContain('Restore from Seed')
|
||||
expect(wrapper.text()).not.toContain('Connect Existing')
|
||||
expect(wrapper.text()).not.toContain('Coming Soon')
|
||||
})
|
||||
|
||||
it('continues to fresh seed generation by default', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed')
|
||||
})
|
||||
|
||||
it('routes restore choice to seed restore', async () => {
|
||||
push.mockClear()
|
||||
const wrapper = mount(OnboardingOptions)
|
||||
|
||||
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
|
||||
expect(restoreButton).toBeDefined()
|
||||
|
||||
await restoreButton!.trigger('click')
|
||||
await wrapper.get('button.path-action-button').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import PeerFiles from '../PeerFiles.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
federationListNodes: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({
|
||||
useAudioPlayer: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeCatalogItem() {
|
||||
return {
|
||||
id: 'file-1',
|
||||
filename: 'notes.txt',
|
||||
mime_type: 'text/plain',
|
||||
size_bytes: 128,
|
||||
description: '',
|
||||
access: 'free',
|
||||
}
|
||||
}
|
||||
|
||||
describe('PeerFiles', () => {
|
||||
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
|
||||
nodes: [{
|
||||
did: 'did:key:peer',
|
||||
pubkey: 'peer',
|
||||
onion: 'peer.onion',
|
||||
name: 'Peer',
|
||||
trust_level: 'trusted',
|
||||
}],
|
||||
} as never)
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
|
||||
|
||||
const wrapper = mount(PeerFiles, {
|
||||
props: { peerId: 'peer.onion' },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
|
||||
const pending = deferred<{ items: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('Refreshing peer files...')
|
||||
expect(wrapper.text()).not.toContain('Connecting via Tor')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('notes.txt')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing peer files...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Server from '../Server.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ packages: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
vpnStatus: vi.fn(),
|
||||
dnsStatus: vi.fn(),
|
||||
diskStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mountServer(options: { renderTorServices?: boolean } = {}) {
|
||||
return mount(Server, {
|
||||
global: {
|
||||
stubs: {
|
||||
QuickActionsCard: true,
|
||||
TorServicesCard: options.renderTorServices ? false : true,
|
||||
ServerModals: true,
|
||||
FipsNetworkCard: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Server network refresh states', () => {
|
||||
it('keeps network overview visible while refresh is pending', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [{}, {}] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
|
||||
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
|
||||
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
expect(wrapper.text()).toContain('Refreshing network...')
|
||||
|
||||
pendingDiagnostics.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Lab WiFi')
|
||||
expect(wrapper.text()).toContain('2 rules')
|
||||
})
|
||||
|
||||
it('keeps network interfaces visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({
|
||||
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: false })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({ services: [], tor_running: false })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
|
||||
const pendingInterfaces = deferred<{ interfaces: [] }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
expect(wrapper.text()).toContain('Refreshing interfaces...')
|
||||
|
||||
pendingInterfaces.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('eth0')
|
||||
expect(wrapper.text()).toContain('192.168.1.10')
|
||||
})
|
||||
|
||||
it('keeps Tor services visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') {
|
||||
return Promise.resolve({
|
||||
services: [{
|
||||
name: 'filebrowser',
|
||||
local_port: 8080,
|
||||
onion_address: 'filebrowser123456789.onion',
|
||||
enabled: true,
|
||||
unauthenticated: false,
|
||||
protocol: false,
|
||||
}],
|
||||
tor_running: true,
|
||||
})
|
||||
}
|
||||
if (request.method === 'network.diagnostics') {
|
||||
return Promise.resolve({ tor_connected: true })
|
||||
}
|
||||
if (request.method === 'router.list-forwards') {
|
||||
return Promise.resolve({ forwards: [] })
|
||||
}
|
||||
if (request.method === 'network.list-interfaces') {
|
||||
return Promise.resolve({ interfaces: [] })
|
||||
}
|
||||
if (request.method === 'vpn.list-peers') {
|
||||
return Promise.resolve({ peers: [] })
|
||||
}
|
||||
if (request.method === 'fips.status') {
|
||||
return Promise.resolve({ installed: false, service_active: false, key_present: false })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
|
||||
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
|
||||
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
|
||||
|
||||
const wrapper = mountServer({ renderTorServices: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
|
||||
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'tor.list-services') return pendingTor.promise
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
expect(wrapper.text()).toContain('Refreshing Tor services...')
|
||||
|
||||
pendingTor.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('filebrowser')
|
||||
expect(wrapper.text()).toContain('filebrowser123456789.onion')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
|
||||
|
||||
describe('cloudPath helpers', () => {
|
||||
it('normalizes query paths', () => {
|
||||
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
|
||||
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
|
||||
})
|
||||
|
||||
it('walks to the parent folder without leaving root', () => {
|
||||
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
|
||||
expect(parentCloudPath('/Photos')).toBe('/')
|
||||
expect(parentCloudPath('/')).toBe('/')
|
||||
})
|
||||
})
|
||||
@@ -1,31 +1,29 @@
|
||||
<template>
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Screenshots Gallery -->
|
||||
<div class="glass-card p-6">
|
||||
<div v-if="screenshots.length > 0" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<img
|
||||
v-for="screenshot in screenshots"
|
||||
:key="screenshot.src"
|
||||
:src="screenshot.src"
|
||||
:alt="screenshot.alt"
|
||||
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Sync Warning (for dependent apps) -->
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<svg class="w-6 h-6 text-orange-400 flex-shrink-0 mt-1" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
|
||||
<p class="text-white/60 text-xs mt-0.5">
|
||||
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
|
||||
<p class="text-white/70 mt-2 leading-relaxed">
|
||||
Some features may be unavailable until Bitcoin finishes syncing.
|
||||
Wallet connections and block data require a fully synced node.
|
||||
</p>
|
||||
@@ -37,7 +35,7 @@
|
||||
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-1.5">
|
||||
<p class="text-sm text-white/55 mt-2">
|
||||
{{ bitcoinSyncPercent.toFixed(1) }}% synced — Block {{ bitcoinBlockHeight.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -70,11 +68,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppScreenshot } from '@/types/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
features: string[]
|
||||
needsBitcoinSync: boolean
|
||||
@@ -82,4 +82,28 @@ defineProps<{
|
||||
bitcoinSyncPercent: number
|
||||
bitcoinBlockHeight: number
|
||||
}>()
|
||||
|
||||
const screenshots = computed(() => {
|
||||
const manifestScreenshots = props.pkg.manifest?.screenshots
|
||||
const staticScreenshots = props.pkg['static-files']?.screenshots
|
||||
return normalizeScreenshots(Array.isArray(staticScreenshots) ? staticScreenshots : manifestScreenshots)
|
||||
})
|
||||
|
||||
function normalizeScreenshots(items: AppScreenshot[] | undefined) {
|
||||
if (!Array.isArray(items)) return []
|
||||
return items
|
||||
.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
const src = item.trim()
|
||||
return src ? { src, alt: `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}` } : null
|
||||
}
|
||||
const src = item.src?.trim()
|
||||
if (!src) return null
|
||||
return {
|
||||
src,
|
||||
alt: item.alt?.trim() || `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}`,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { src: string; alt: string } => item !== null)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,225 +1,70 @@
|
||||
<template>
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<!-- Desktop: Single Row Layout -->
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<div class="flex items-start md:items-center gap-4 md:gap-6">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
class="app-detail-icon archy-app-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-xs md:text-sm mb-2 line-clamp-2 md:line-clamp-none">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
|
||||
class="inline-flex items-center px-2 py-0.5 md:px-2.5 md:py-1 rounded-lg text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<!-- Update available -->
|
||||
<button
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@click="$emit('update')"
|
||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Update to v{{ pkg['available-update'] }}
|
||||
</button>
|
||||
<!-- Updating in progress -->
|
||||
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center gap-2"
|
||||
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" 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>
|
||||
Updating...
|
||||
</span>
|
||||
<button
|
||||
v-if="packageKey === 'lnd'"
|
||||
@click="$emit('channels')"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
|
||||
v-for="action in actionItems"
|
||||
:key="`top-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
<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>
|
||||
{{ t('appDetails.channels') }}
|
||||
{{ action.label }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@click="$emit('launch')"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<template v-if="!isWebOnly">
|
||||
<button
|
||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||
@click="$emit('start')"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
||||
>
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('restart')"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click="$emit('stop')"
|
||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors 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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('uninstall')"
|
||||
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('common.uninstall') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Two Column Grid Layout -->
|
||||
<div class="md:hidden">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="pkg.manifest.title"
|
||||
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
|
||||
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isWebOnly"
|
||||
@click="$emit('uninstall')"
|
||||
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
|
||||
:title="t('common.uninstall')"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (Auto Grid) -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Update available (mobile) -->
|
||||
<button
|
||||
v-if="pkg['available-update'] && pkg.state !== 'updating'"
|
||||
@click="$emit('update')"
|
||||
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center justify-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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Update to v{{ pkg['available-update'] }}
|
||||
</button>
|
||||
<!-- Updating in progress (mobile) -->
|
||||
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
|
||||
<template v-if="pkg.state === 'updating'">
|
||||
<span
|
||||
v-if="pkg.state === 'updating'"
|
||||
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center justify-center gap-2"
|
||||
class="col-span-2 mobile-card-action bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" 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>
|
||||
Updating...
|
||||
</span>
|
||||
<button
|
||||
v-if="canLaunch"
|
||||
@click="$emit('launch')"
|
||||
:class="isWebOnly ? 'col-span-2' : ''"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
{{ t('common.launch') }}
|
||||
</button>
|
||||
<template v-if="!isWebOnly">
|
||||
<button
|
||||
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
|
||||
@click="$emit('start')"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
|
||||
>
|
||||
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
</svg>
|
||||
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pkg.state === 'running'"
|
||||
@click="$emit('stop')"
|
||||
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
{{ t('common.stop') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('restart')"
|
||||
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
|
||||
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ t('common.restart') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-for="action in actionItems"
|
||||
:key="`bottom-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="[
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
action.class,
|
||||
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||
]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -229,6 +74,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -239,11 +85,13 @@ const props = defineProps<{
|
||||
packageKey: string
|
||||
canLaunch: boolean
|
||||
isWebOnly: boolean
|
||||
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
launch: []
|
||||
start: []
|
||||
stop: []
|
||||
@@ -253,10 +101,110 @@ defineEmits<{
|
||||
channels: []
|
||||
}>()
|
||||
|
||||
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
|
||||
|
||||
const actionItems = computed(() => {
|
||||
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
|
||||
|
||||
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
|
||||
actions.push({
|
||||
key: 'update',
|
||||
emit: 'update',
|
||||
label: props.pendingAction === 'update' ? 'Updating...' : `Update to v${props.pkg['available-update']}`,
|
||||
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
|
||||
full: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.packageKey === 'lnd') {
|
||||
actions.push({
|
||||
key: 'channels',
|
||||
emit: 'channels',
|
||||
label: t('appDetails.channels'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.canLaunch) {
|
||||
actions.push({
|
||||
key: 'launch',
|
||||
emit: 'launch',
|
||||
label: t('common.launch'),
|
||||
class: 'glass-button font-semibold',
|
||||
})
|
||||
}
|
||||
|
||||
if (!props.isWebOnly) {
|
||||
if (props.pkg.state === 'stopped' || props.pkg.state === 'exited') {
|
||||
actions.push({
|
||||
key: 'start',
|
||||
emit: 'start',
|
||||
label: props.pendingAction === 'start' ? 'Starting...' : props.pkg.state === 'exited' ? 'Restart' : t('common.start'),
|
||||
class: props.pkg.state === 'exited' ? 'glass-button glass-button-danger' : 'glass-button glass-button-success',
|
||||
})
|
||||
}
|
||||
|
||||
if (props.pkg.state === 'running') {
|
||||
actions.push({
|
||||
key: 'stop',
|
||||
emit: 'stop',
|
||||
label: props.pendingAction === 'stop' ? 'Stopping...' : t('common.stop'),
|
||||
class: 'glass-button text-yellow-200 border-yellow-500/30 hover:bg-yellow-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'restart',
|
||||
emit: 'restart',
|
||||
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
|
||||
class: 'glass-button',
|
||||
})
|
||||
|
||||
actions.push({
|
||||
key: 'uninstall',
|
||||
emit: 'uninstall',
|
||||
label: props.pendingAction === 'uninstall' ? 'Uninstalling...' : t('common.uninstall'),
|
||||
class: 'glass-button text-red-300 border-red-500/30 hover:bg-red-500/10',
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
function emitAction(action: ActionEmit) {
|
||||
emit(action)
|
||||
}
|
||||
|
||||
function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
if (!target.src.includes('data:image') && !target.src.includes('logo-archipelago')) {
|
||||
target.src = '/assets/img/logo-archipelago.svg'
|
||||
if (!target.src.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-detail-actions-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-detail-action-btn {
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.app-detail-actions-top {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-detail-actions-bottom {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -86,10 +86,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="credentials?.credentials?.length" class="glass-card p-6">
|
||||
<!-- Setup Instructions -->
|
||||
<div v-if="setupInstructions" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-3">{{ t('appDetails.setupInstructions') }}</h3>
|
||||
<p class="text-sm text-white/70 leading-relaxed whitespace-pre-line">
|
||||
{{ setupInstructions }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="credentialsLoading || credentials?.credentials?.length" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
|
||||
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div class="space-y-3">
|
||||
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
|
||||
<div v-if="credentialsLoading" class="space-y-3" aria-live="polite">
|
||||
<div class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="h-3 w-28 rounded bg-white/10 mb-3"></div>
|
||||
<div class="h-4 w-full rounded bg-white/10"></div>
|
||||
</div>
|
||||
<p class="text-xs text-white/50">Loading credentials...</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div class="flex items-center justify-between gap-3 mb-1">
|
||||
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
|
||||
@@ -128,38 +143,44 @@
|
||||
</div>
|
||||
|
||||
<!-- Links Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div v-if="links.length > 0" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
|
||||
<div class="space-y-2">
|
||||
<a
|
||||
v-if="pkg.manifest.website"
|
||||
:href="pkg.manifest.website"
|
||||
v-for="link in links"
|
||||
:key="link.kind"
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-if="link.kind === 'website'"
|
||||
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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
{{ t('appDetails.website') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-else-if="link.kind === 'source'"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{{ t('appDetails.sourceCode') }}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
v-else
|
||||
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{{ t('appDetails.documentation') }}
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +188,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
@@ -191,7 +212,7 @@ async function copyCredential(label: string, value: string) {
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
pkg: Record<string, any>
|
||||
packageKey: string
|
||||
isWebOnly: boolean
|
||||
@@ -201,5 +222,40 @@ defineProps<{
|
||||
torUrl: string
|
||||
showTorAddress: boolean
|
||||
credentials: AppCredentialsResponse | null
|
||||
credentialsLoading: boolean
|
||||
}>()
|
||||
|
||||
type LinkKind = 'website' | 'source' | 'documentation'
|
||||
|
||||
interface SidebarLink {
|
||||
kind: LinkKind
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: unknown): string {
|
||||
const url = typeof value === 'string' ? value.trim() : ''
|
||||
if (!url || url === '#') return ''
|
||||
if (/^https?:\/\//i.test(url)) return url
|
||||
return ''
|
||||
}
|
||||
|
||||
const links = computed<SidebarLink[]>(() => {
|
||||
const manifest = props.pkg.manifest || {}
|
||||
const website = normalizeHttpUrl(manifest.website || manifest['marketing-site'])
|
||||
const source = normalizeHttpUrl(manifest['upstream-repo'] || manifest['wrapper-repo'])
|
||||
const documentation = normalizeHttpUrl(manifest['support-site'])
|
||||
|
||||
return [
|
||||
website ? { kind: 'website' as const, label: t('appDetails.website'), url: website } : null,
|
||||
source ? { kind: 'source' as const, label: t('appDetails.sourceCode'), url: source } : null,
|
||||
documentation ? { kind: 'documentation' as const, label: t('appDetails.documentation'), url: documentation } : null,
|
||||
].filter((link): link is SidebarLink => link !== null)
|
||||
})
|
||||
|
||||
const setupInstructions = computed(() => {
|
||||
const raw = props.pkg['static-files']?.instructions
|
||||
const instructions = typeof raw === 'string' ? raw.trim() : ''
|
||||
return instructions ? instructions : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppContentSection from '../AppContentSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
about: 'About {name}',
|
||||
features: 'Features',
|
||||
screenshots: 'Screenshots',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app details',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountContent(pkg: PackageDataEntry) {
|
||||
return mount(AppContentSection, {
|
||||
props: {
|
||||
pkg,
|
||||
features: [],
|
||||
needsBitcoinSync: false,
|
||||
bitcoinSynced: true,
|
||||
bitcoinSyncPercent: 100,
|
||||
bitcoinBlockHeight: 100,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppContentSection', () => {
|
||||
it('does not show screenshot placeholders when no screenshots exist', () => {
|
||||
const wrapper = mountContent(packageData())
|
||||
|
||||
expect(wrapper.text()).not.toContain('Screenshots')
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders real screenshot metadata when provided', () => {
|
||||
const wrapper = mountContent(packageData({
|
||||
manifest: {
|
||||
...packageData().manifest,
|
||||
screenshots: [
|
||||
'/assets/screenshots/example-dashboard.png',
|
||||
{ src: '/assets/screenshots/example-settings.png', alt: 'Settings screen' },
|
||||
' ',
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
const images = wrapper.findAll('img')
|
||||
|
||||
expect(wrapper.text()).toContain('Screenshots')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('src')).toBe('/assets/screenshots/example-dashboard.png')
|
||||
expect(images[0].attributes('alt')).toBe('Example screenshot 1')
|
||||
expect(images[1].attributes('src')).toBe('/assets/screenshots/example-settings.png')
|
||||
expect(images[1].attributes('alt')).toBe('Settings screen')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppHeroSection from '../AppHeroSection.vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
common: {
|
||||
launch: 'Launch',
|
||||
restart: 'Restart',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
uninstall: 'Uninstall',
|
||||
},
|
||||
appDetails: {
|
||||
channels: 'Channels',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
health: 'healthy',
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
description: {
|
||||
short: 'Example app',
|
||||
long: 'Example app',
|
||||
},
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountHero(props: Partial<InstanceType<typeof AppHeroSection>['$props']> = {}) {
|
||||
return mount(AppHeroSection, {
|
||||
props: {
|
||||
pkg: packageData(),
|
||||
appId: 'example',
|
||||
packageKey: 'example',
|
||||
canLaunch: true,
|
||||
isWebOnly: false,
|
||||
pendingAction: null,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppHeroSection', () => {
|
||||
it('disables app controls while a container action is running', () => {
|
||||
const wrapper = mountHero({ pendingAction: 'restart' })
|
||||
|
||||
expect(wrapper.text()).toContain('Restarting...')
|
||||
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('labels update progress and disables update controls', () => {
|
||||
const wrapper = mountHero({
|
||||
pendingAction: 'update',
|
||||
pkg: packageData({ 'available-update': '1.1.0' }),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Updating...')
|
||||
const updateButtons = wrapper.findAll('button').filter(button => button.text().includes('Updating...'))
|
||||
expect(updateButtons.length).toBeGreaterThan(0)
|
||||
expect(updateButtons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import AppSidebar from '../AppSidebar.vue'
|
||||
import { PackageState } from '@/types/api'
|
||||
|
||||
function mountSidebar(manifestOverrides: Record<string, unknown>, propOverrides: Record<string, unknown> = {}) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
appDetails: {
|
||||
access: 'Access',
|
||||
documentation: 'Documentation',
|
||||
gateway: 'Gateway',
|
||||
information: 'Information',
|
||||
lan: 'LAN',
|
||||
links: 'Links',
|
||||
ram: 'RAM',
|
||||
ramDesc: 'Memory required',
|
||||
requirements: 'Requirements',
|
||||
setupInstructions: 'Setup Instructions',
|
||||
requiresTor: 'Requires Tor',
|
||||
services: 'Services',
|
||||
sourceCode: 'Source Code',
|
||||
storage: 'Storage',
|
||||
storageDesc: 'Storage required',
|
||||
tor: 'Tor',
|
||||
website: 'Website',
|
||||
},
|
||||
common: {
|
||||
category: 'Category',
|
||||
developer: 'Developer',
|
||||
license: 'License',
|
||||
status: 'Status',
|
||||
version: 'Version',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(AppSidebar, {
|
||||
props: {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
...manifestOverrides,
|
||||
},
|
||||
'static-files': { license: '', instructions: 'Follow step 1.\nThen step 2.', icon: '' },
|
||||
},
|
||||
packageKey: 'example',
|
||||
isWebOnly: false,
|
||||
gatewayState: 'stopped',
|
||||
interfaceAddresses: null,
|
||||
lanUrl: '',
|
||||
torUrl: '',
|
||||
showTorAddress: false,
|
||||
credentials: null,
|
||||
credentialsLoading: false,
|
||||
...propOverrides,
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppSidebar', () => {
|
||||
it('renders manifest links with real URLs', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: 'https://example.com',
|
||||
'upstream-repo': 'https://github.com/example/app',
|
||||
'support-site': 'https://docs.example.com',
|
||||
})
|
||||
|
||||
const hrefs = wrapper.findAll('a').map(anchor => anchor.attributes('href'))
|
||||
|
||||
expect(hrefs).toEqual([
|
||||
'https://example.com',
|
||||
'https://github.com/example/app',
|
||||
'https://docs.example.com',
|
||||
])
|
||||
expect(wrapper.text()).toContain('Website')
|
||||
expect(wrapper.text()).toContain('Source Code')
|
||||
expect(wrapper.text()).toContain('Documentation')
|
||||
})
|
||||
|
||||
it('does not render dead placeholder links', () => {
|
||||
const wrapper = mountSidebar({
|
||||
website: '#',
|
||||
'upstream-repo': '',
|
||||
'support-site': undefined,
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('a')).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('Links')
|
||||
})
|
||||
|
||||
it('shows a credentials loading state before credentials arrive', () => {
|
||||
const wrapper = mountSidebar({}, { credentialsLoading: true })
|
||||
|
||||
expect(wrapper.text()).toContain('Credentials')
|
||||
expect(wrapper.text()).toContain('Loading credentials...')
|
||||
})
|
||||
|
||||
it('renders setup instructions when provided', () => {
|
||||
const wrapper = mountSidebar({})
|
||||
|
||||
expect(wrapper.text()).toContain('Setup Instructions')
|
||||
expect(wrapper.text()).toContain('Follow step 1.')
|
||||
expect(wrapper.text()).toContain('Then step 2.')
|
||||
})
|
||||
|
||||
it('hides setup instructions when empty', () => {
|
||||
const wrapper = mountSidebar({}, {
|
||||
pkg: {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
version: '1.0.0',
|
||||
license: '',
|
||||
},
|
||||
'static-files': { license: '', instructions: ' ', icon: '' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('Setup Instructions')
|
||||
})
|
||||
})
|
||||
@@ -37,9 +37,10 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable' }}</h3>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||
<p class="text-white/50 text-sm mb-6">
|
||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
@@ -88,6 +89,8 @@ const props = defineProps<{
|
||||
mustOpenNewTab: boolean
|
||||
autoRetryCount: number
|
||||
refreshKey: number
|
||||
blockedReason?: string
|
||||
blockedTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
|
||||
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
|
||||
|
||||
describe('appSessionConfig', () => {
|
||||
it('keeps new-tab apps marked on every viewport', () => {
|
||||
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
|
||||
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
|
||||
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
|
||||
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
|
||||
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves direct app ports against the current browser host', () => {
|
||||
@@ -17,7 +23,17 @@ describe('appSessionConfig', () => {
|
||||
|
||||
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
|
||||
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
|
||||
expect(resolveAppUrl('saleor')).toBe('http://192.168.1.228:9011')
|
||||
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
|
||||
})
|
||||
|
||||
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('meshtastic')).toBe('http://192.168.1.228:4403')
|
||||
})
|
||||
|
||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||
@@ -29,4 +45,14 @@ describe('appSessionConfig', () => {
|
||||
|
||||
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
|
||||
})
|
||||
|
||||
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { hostname: '192.168.1.228' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||
|
||||
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
|
||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||
export const APP_PORTS: Record<string, number> = {
|
||||
...GENERATED_APP_PORTS,
|
||||
'bitcoin-knots': 8334,
|
||||
'bitcoin-core': 8334,
|
||||
'bitcoin-ui': 8334,
|
||||
@@ -14,7 +17,6 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'archy-electrs-ui': 50002,
|
||||
'mempool-electrs': 50002,
|
||||
'btcpay-server': 23000,
|
||||
'saleor': 9011,
|
||||
'lnd': 18083,
|
||||
'archy-lnd-ui': 18083,
|
||||
'mempool': 4080,
|
||||
@@ -71,8 +73,9 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
}
|
||||
|
||||
export const APP_TITLES: Record<string, string> = {
|
||||
...GENERATED_APP_TITLES,
|
||||
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
|
||||
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
|
||||
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
|
||||
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
|
||||
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
|
||||
'nginx-proxy-manager': 'Nginx Proxy Manager',
|
||||
@@ -82,17 +85,8 @@ export const APP_TITLES: Record<string, string> = {
|
||||
|
||||
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
|
||||
export const NEW_TAB_APPS = new Set([
|
||||
'btcpay-server',
|
||||
'grafana',
|
||||
'photoprism',
|
||||
'homeassistant',
|
||||
'vaultwarden',
|
||||
'nextcloud',
|
||||
'uptime-kuma',
|
||||
'portainer',
|
||||
'onlyoffice',
|
||||
...GENERATED_NEW_TAB_APPS,
|
||||
'nginx-proxy-manager',
|
||||
'gitea',
|
||||
'tailscale',
|
||||
])
|
||||
|
||||
@@ -100,7 +94,7 @@ export const NEW_TAB_APPS = new Set([
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?: string): string {
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
@@ -109,9 +103,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?:
|
||||
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
|
||||
// shell when proxied under a path prefix on some nodes.
|
||||
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
|
||||
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
|
||||
return 'http://' + window.location.hostname + ':8334'
|
||||
}
|
||||
|
||||
if (runtimeUrl && id !== 'netbird') {
|
||||
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
// Local apps launch by host port.
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const APP_STORE_CATEGORIES = [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'community', name: 'Community' },
|
||||
{ id: 'nostr', name: 'Nostr' },
|
||||
{ id: 'commerce', name: 'Commerce' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
{ id: 'home', name: 'Home' },
|
||||
{ id: 'networking', name: 'Networking' },
|
||||
{ id: 'l484', name: 'L484' },
|
||||
{ id: 'other', name: 'Other' },
|
||||
] as const
|
||||
|
||||
export const APP_STORE_SECTIONS = [
|
||||
{ id: 'discover', name: 'Discover' },
|
||||
...APP_STORE_CATEGORIES,
|
||||
] as const
|
||||
@@ -31,7 +31,7 @@
|
||||
<img
|
||||
:src="icon"
|
||||
:alt="title"
|
||||
class="app-card-icon w-14 h-14 object-cover bg-white/10"
|
||||
class="app-card-icon archy-app-icon w-14 h-14"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
@@ -77,6 +77,9 @@
|
||||
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
|
||||
{{ blockedReason }}
|
||||
</p>
|
||||
|
||||
<!-- Quick Actions — icon buttons in uniform dark containers -->
|
||||
<!-- Installing progress — replaces action buttons -->
|
||||
@@ -207,7 +210,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
|
||||
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
@@ -284,6 +287,7 @@ const isTransitioning = computed(() => {
|
||||
const h = props.pkg.health
|
||||
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
|
||||
})
|
||||
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -18,15 +18,21 @@
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="getTitle(id, pkg)"
|
||||
@pointerdown="startLongPress(id)"
|
||||
@pointerup="clearLongPress"
|
||||
@pointercancel="clearLongPress"
|
||||
@pointerleave="clearLongPress"
|
||||
@contextmenu.prevent="openAppOptions(id)"
|
||||
@click="handleTap(id, pkg)"
|
||||
@keydown.enter="handleTap(id, pkg)"
|
||||
@keydown.space.prevent="openAppOptions(id)"
|
||||
>
|
||||
<!-- Icon with status indicator -->
|
||||
<div class="app-icon-frame">
|
||||
<img
|
||||
:src="getIcon(id, pkg)"
|
||||
:alt="getTitle(id, pkg)"
|
||||
class="app-icon-img"
|
||||
class="app-icon-img archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- Status dot -->
|
||||
@@ -44,7 +50,7 @@
|
||||
></span>
|
||||
<!-- Installing overlay -->
|
||||
<div
|
||||
v-if="serverStore.isInstalling(id)"
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-installing"
|
||||
>
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
@@ -55,6 +61,13 @@
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||
<span
|
||||
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
|
||||
class="app-icon-progress-label"
|
||||
:title="progressLabel(id, pkg)"
|
||||
>
|
||||
{{ progressLabel(id, pkg) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
|
||||
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
|
||||
<div class="sideload-modal credential-modal">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
@@ -107,6 +120,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { resolveAppCredentials } from './appCredentials'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
@@ -135,6 +149,8 @@ const emit = defineEmits<{
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
@@ -154,7 +170,22 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||
}
|
||||
|
||||
function progressLabel(id: string, pkg: PackageDataEntry): string {
|
||||
const install = serverStore.installingApps.get(id)
|
||||
if (install) {
|
||||
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
|
||||
}
|
||||
if (serverStore.uninstallingApps.has(id)) {
|
||||
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (longPressTriggered.value) {
|
||||
longPressTriggered.value = false
|
||||
return
|
||||
}
|
||||
if (canLaunch(pkg)) {
|
||||
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
|
||||
if (shown) return
|
||||
@@ -164,6 +195,26 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
function startLongPress(id: string) {
|
||||
clearLongPress()
|
||||
longPressTriggered.value = false
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTriggered.value = true
|
||||
openAppOptions(id)
|
||||
}, 550)
|
||||
}
|
||||
|
||||
function clearLongPress() {
|
||||
if (!longPressTimer) return
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
|
||||
function openAppOptions(id: string) {
|
||||
clearLongPress()
|
||||
emit('goToApp', id)
|
||||
}
|
||||
|
||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
@@ -191,18 +242,29 @@ function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
|
||||
if (!result.credentials?.length) return false
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: result.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: result.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: result.credentials,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${getTitle(id, pkg)} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +332,12 @@ function scrollToPage(index: number) {
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.credential-modal {
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
|
||||
border-radius: 1.25rem;
|
||||
padding-bottom: 1.25rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.credential-modal-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
<p class="text-white/70">
|
||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||
</p>
|
||||
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="deleteAppData"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-white/30 bg-black/30 text-red-500 focus:ring-red-500 focus:ring-offset-0"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
||||
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +50,7 @@
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
@click="$emit('confirm', deleteAppData)"
|
||||
:disabled="uninstalling"
|
||||
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -61,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
@@ -75,11 +88,12 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
confirm: [deleteAppData: boolean]
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
const deleteAppData = ref(false)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
@@ -87,4 +101,13 @@ useModalKeyboard(
|
||||
() => emit('close'),
|
||||
{ restoreFocusRef },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
deleteAppData.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<button @click="router.replace('/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>
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
|
||||
<div v-if="loading && channels.length === 0" 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>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
|
||||
<div v-else-if="error && channels.length === 0" 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>
|
||||
@@ -63,6 +63,16 @@
|
||||
|
||||
<!-- Channel List -->
|
||||
<div v-else key="channels" class="space-y-3">
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing channels...
|
||||
</div>
|
||||
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
:key="ch.chan_id || ch.channel_point"
|
||||
@@ -119,7 +129,7 @@
|
||||
</Transition>
|
||||
|
||||
<!-- Open Channel Modal -->
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @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>
|
||||
|
||||
@@ -165,7 +175,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Close Confirmation Modal -->
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @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>
|
||||
@@ -232,6 +242,7 @@ function capacityPercent(amount: number, capacity: number): number {
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
const hadChannels = channels.value.length > 0
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
@@ -246,6 +257,7 @@ async function loadChannels() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -305,4 +317,6 @@ async function closeChannel() {
|
||||
}
|
||||
|
||||
onMounted(loadChannels)
|
||||
|
||||
defineExpose({ channels, loadChannels })
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import AppIconGrid from '../AppIconGrid.vue'
|
||||
|
||||
const mockWindowOpen = vi.fn()
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ credentials: [] }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.stubGlobal('open', mockWindowOpen)
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
@@ -31,8 +38,12 @@ function makePkg(id: string): PackageDataEntry {
|
||||
}
|
||||
|
||||
describe('AppIconGrid', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useRealTimers()
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -51,14 +62,32 @@ describe('AppIconGrid', () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBe('lnd')
|
||||
})
|
||||
|
||||
it('shows File Browser credentials before launch even when backend returns no credentials', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('File Browser credentials')
|
||||
expect(wrapper.text()).toContain('Username')
|
||||
expect(wrapper.text()).toContain('admin')
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('routes desktop new-tab apps through app session on mobile', async () => {
|
||||
@@ -71,13 +100,78 @@ describe('AppIconGrid', () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['gitea', makePkg('gitea')]] },
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(useAppLauncherStore().panelAppId).toBeNull()
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('shows backend uninstall stage while an app is removing', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
pkg['uninstall-stage'] = 'Stopping containers (2/7)'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Stopping containers (2/7)')
|
||||
})
|
||||
|
||||
it('supports legacy underscore uninstall stage data', () => {
|
||||
const pkg = makePkg('indeedhub')
|
||||
pkg.state = PackageState.Removing
|
||||
;(pkg as PackageDataEntry & { uninstall_stage?: string }).uninstall_stage = 'Removing app data'
|
||||
useServerStore(pinia).uninstallingApps.add('indeedhub')
|
||||
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['indeedhub', pkg]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Removing app data')
|
||||
})
|
||||
|
||||
it('opens app details on long press without launching the app', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
const icon = wrapper.get('.app-icon-item')
|
||||
await icon.trigger('pointerdown')
|
||||
vi.advanceTimersByTime(550)
|
||||
await icon.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
|
||||
})
|
||||
|
||||
it('opens app details from the keyboard options shortcut', async () => {
|
||||
const wrapper = mount(AppIconGrid, {
|
||||
props: { apps: [['lnd', makePkg('lnd')]] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.app-icon-item').trigger('keydown.space')
|
||||
|
||||
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params?.name) return `${key} ${params.name}`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('AppsUninstallModal', () => {
|
||||
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
||||
const wrapper = mount(AppsUninstallModal, {
|
||||
props: {
|
||||
show: true,
|
||||
appTitle: 'File Browser',
|
||||
uninstalling: false,
|
||||
},
|
||||
})
|
||||
|
||||
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox).not.toBeNull()
|
||||
checkbox?.click()
|
||||
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
||||
expect(confirmButton).not.toBeNull()
|
||||
confirmButton?.click()
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LightningChannels from '../LightningChannels.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeChannel() {
|
||||
return {
|
||||
chan_id: '123',
|
||||
remote_pubkey: 'peer-pubkey',
|
||||
capacity: 100_000,
|
||||
local_balance: 60_000,
|
||||
remote_balance: 40_000,
|
||||
active: true,
|
||||
status: 'active',
|
||||
channel_point: 'txid:0',
|
||||
}
|
||||
}
|
||||
|
||||
describe('LightningChannels', () => {
|
||||
it('keeps channels visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
channels: [makeChannel()],
|
||||
total_inbound: 40_000,
|
||||
total_outbound: 60_000,
|
||||
})
|
||||
|
||||
const wrapper = mount(LightningChannels)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('100.0k sats')
|
||||
|
||||
const pending = deferred<{ channels: []; total_inbound: number; total_outbound: number }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadChannels: () => Promise<void> }).loadChannels()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('Refreshing channels...')
|
||||
expect(wrapper.text()).not.toContain('Loading channels...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveAppCredentials } from '../appCredentials'
|
||||
|
||||
describe('resolveAppCredentials', () => {
|
||||
it('uses backend credentials when they are available', () => {
|
||||
expect(resolveAppCredentials('filebrowser', {
|
||||
title: 'Backend credentials',
|
||||
credentials: [{ label: 'Password', value: 'secret' }],
|
||||
})?.credentials[0].value).toBe('secret')
|
||||
})
|
||||
|
||||
it('falls back to File Browser default credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('filebrowser', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('File Browser credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to PhotoPrism manifest credentials when backend data is not available', () => {
|
||||
const result = resolveAppCredentials('photoprism', { credentials: [] })
|
||||
|
||||
expect(result?.title).toBe('PhotoPrism credentials')
|
||||
expect(result?.credentials).toEqual([
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not invent credentials for unknown apps', () => {
|
||||
expect(resolveAppCredentials('unknown', { credentials: [] })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { useLastKnownPackages, type PackageMap } from '../appPackageCache'
|
||||
|
||||
function makePkg(id: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useLastKnownPackages', () => {
|
||||
it('keeps the last package list visible while the scanner reports not ready', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
|
||||
containersScanned.value = false
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an empty list once the scanner has completed', async () => {
|
||||
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
|
||||
const containersScanned = ref(true)
|
||||
const cache = useLastKnownPackages(livePackages, containersScanned)
|
||||
|
||||
livePackages.value = {}
|
||||
await nextTick()
|
||||
|
||||
expect(cache.packages.value).toEqual({})
|
||||
expect(cache.lastKnownPackages.value).toEqual({})
|
||||
expect(cache.isUsingLastKnownPackages.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { filterEntriesForTab, isServiceContainer, isServicePackage, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
||||
import { canLaunch, filterEntriesForTab, isServiceContainer, isServicePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
@@ -81,4 +81,12 @@ describe('appsConfig service filtering', () => {
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
|
||||
})
|
||||
|
||||
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
|
||||
const pkg = makePkg('fedimint', 'Fedimint', 'money')
|
||||
pkg.state = PackageState.Starting
|
||||
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8175' } } } as unknown as PackageDataEntry['installed']
|
||||
expect(launchBlockedReason('fedimint', pkg)).toContain('Bitcoin')
|
||||
expect(canLaunch(pkg)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { parseSideloadPortMapping, validateSideloadRequest } from '../sideloadValidation'
|
||||
|
||||
function makePkg(id: string, title: string, lanAddress?: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
},
|
||||
installed: lanAddress
|
||||
? {
|
||||
'current-dependents': {},
|
||||
'current-dependencies': {},
|
||||
'last-backup': null,
|
||||
status: 'running',
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'lan-address': lanAddress,
|
||||
'tor-address': '',
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sideloadValidation', () => {
|
||||
it('parses host and container port mappings', () => {
|
||||
expect(parseSideloadPortMapping('3009:80')).toEqual({ host: 3009, container: 80 })
|
||||
expect(parseSideloadPortMapping('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed port mappings', () => {
|
||||
expect(() => parseSideloadPortMapping('3009')).toThrow('host:container')
|
||||
expect(() => parseSideloadPortMapping('99999:80')).toThrow('between 1 and 65535')
|
||||
})
|
||||
|
||||
it('rejects duplicate app IDs', () => {
|
||||
const packages = { excalidraw: makePkg('excalidraw', 'Excalidraw') }
|
||||
expect(validateSideloadRequest('excalidraw', '3009:80', packages)).toContain('already installed')
|
||||
})
|
||||
|
||||
it('rejects reserved host ports', () => {
|
||||
expect(validateSideloadRequest('demo', '9000:80', {})).toContain('reserved')
|
||||
})
|
||||
|
||||
it('rejects host ports already used by installed apps', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '8083:80', packages)).toContain('File Browser')
|
||||
})
|
||||
|
||||
it('accepts available host ports', () => {
|
||||
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
|
||||
expect(validateSideloadRequest('demo', '3018:80', packages)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppCredentialsResponse } from '@/types/api'
|
||||
|
||||
const FALLBACK_CREDENTIALS: Record<string, AppCredentialsResponse> = {
|
||||
filebrowser: {
|
||||
title: 'File Browser credentials',
|
||||
description: 'Use these credentials when File Browser asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'admin', sensitive: true },
|
||||
],
|
||||
},
|
||||
photoprism: {
|
||||
title: 'PhotoPrism credentials',
|
||||
description: 'Use these credentials when PhotoPrism asks you to sign in.',
|
||||
credentials: [
|
||||
{ label: 'Username', value: 'admin' },
|
||||
{ label: 'Password', value: 'archipelago', sensitive: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function resolveAppCredentials(appId: string, response?: AppCredentialsResponse | null): AppCredentialsResponse | null {
|
||||
if (response?.credentials?.length) return response
|
||||
return FALLBACK_CREDENTIALS[appId] ?? null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
export type PackageMap = Record<string, PackageDataEntry>
|
||||
|
||||
export function useLastKnownPackages(
|
||||
livePackages: Ref<PackageMap>,
|
||||
containersScanned: Ref<boolean>,
|
||||
) {
|
||||
const lastKnownPackages = ref<PackageMap>({})
|
||||
|
||||
watch(
|
||||
livePackages,
|
||||
(packages) => {
|
||||
const hasPackages = Object.keys(packages).length > 0
|
||||
if (hasPackages || containersScanned.value) {
|
||||
lastKnownPackages.value = { ...packages }
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const isUsingLastKnownPackages = computed(() => (
|
||||
!containersScanned.value &&
|
||||
Object.keys(livePackages.value).length === 0 &&
|
||||
Object.keys(lastKnownPackages.value).length > 0
|
||||
))
|
||||
|
||||
const packages = computed<PackageMap>(() => (
|
||||
isUsingLastKnownPackages.value ? lastKnownPackages.value : livePackages.value
|
||||
))
|
||||
|
||||
return {
|
||||
packages,
|
||||
isUsingLastKnownPackages,
|
||||
lastKnownPackages,
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
@@ -165,6 +165,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||
gitea: '/assets/img/app-icons/gitea.svg',
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const rawIcon = (pkg["static-files"]?.icon || "").trim()
|
||||
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
|
||||
@@ -184,9 +186,23 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
|
||||
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
|
||||
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
|
||||
}
|
||||
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
|
||||
}
|
||||
|
||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||
const appId = pkg?.manifest?.id || id
|
||||
if (
|
||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||
) {
|
||||
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
|
||||
const addr = runtimeLanAddress(pkg)
|
||||
if (!addr || typeof window === 'undefined') return addr
|
||||
@@ -272,14 +288,8 @@ export function handleImageError(e: Event) {
|
||||
return
|
||||
}
|
||||
|
||||
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
|
||||
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
|
||||
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>
|
||||
`)}`
|
||||
if (!currentSrc.includes("data:image")) {
|
||||
target.src = placeholderSvg
|
||||
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
|
||||
target.src = DEFAULT_APP_ICON
|
||||
target.dataset.defaultIcon = "1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
|
||||
const RESERVED_HOST_PORTS = new Set([
|
||||
80, 443, 81,
|
||||
8332, 8333, 8334,
|
||||
9735, 10009, 8080,
|
||||
18083,
|
||||
4080, 8999, 50001,
|
||||
23000,
|
||||
8173, 8174, 8175,
|
||||
8123,
|
||||
3000,
|
||||
11434,
|
||||
9980, 9001,
|
||||
8240,
|
||||
9000,
|
||||
3001, 3002,
|
||||
8888,
|
||||
8096, 2342, 2283,
|
||||
8443,
|
||||
])
|
||||
|
||||
export interface ParsedPortMapping {
|
||||
host: number
|
||||
container: number
|
||||
}
|
||||
|
||||
export function parseSideloadPortMapping(value: string): ParsedPortMapping | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const match = trimmed.match(/^(\d{1,5}):(\d{1,5})$/)
|
||||
if (!match) {
|
||||
throw new Error('Port mapping must use host:container format, for example 3009:80.')
|
||||
}
|
||||
|
||||
const host = Number(match[1])
|
||||
const container = Number(match[2])
|
||||
if (!Number.isInteger(host) || host < 1 || host > 65535 || !Number.isInteger(container) || container < 1 || container > 65535) {
|
||||
throw new Error('Ports must be between 1 and 65535.')
|
||||
}
|
||||
return { host, container }
|
||||
}
|
||||
|
||||
export function packageUsesHostPort(pkg: PackageDataEntry, hostPort: number): boolean {
|
||||
const addresses = pkg.installed?.['interface-addresses'] || {}
|
||||
return Object.values(addresses).some((addr) => {
|
||||
const lan = addr?.['lan-address']
|
||||
if (!lan) return false
|
||||
try {
|
||||
const parsed = new URL(lan)
|
||||
return Number(parsed.port || (parsed.protocol === 'https:' ? '443' : '80')) === hostPort
|
||||
} catch {
|
||||
const match = lan.match(/:(\d+)(?:\/|$)/)
|
||||
return match ? Number(match[1]) === hostPort : false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSideloadRequest(
|
||||
id: string,
|
||||
portMapping: string,
|
||||
packages: Record<string, PackageDataEntry>,
|
||||
): string | null {
|
||||
if (packages[id]) return `An app with ID "${id}" is already installed.`
|
||||
|
||||
let parsed: ParsedPortMapping | null = null
|
||||
try {
|
||||
parsed = parseSideloadPortMapping(portMapping)
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Invalid port mapping.'
|
||||
}
|
||||
if (!parsed) return null
|
||||
|
||||
if (RESERVED_HOST_PORTS.has(parsed.host)) {
|
||||
return `Host port ${parsed.host} is reserved by Archipelago or a packaged app. Choose another host port.`
|
||||
}
|
||||
|
||||
const existing = Object.entries(packages).find(([, pkg]) => packageUsesHostPort(pkg, parsed.host))
|
||||
if (existing) {
|
||||
const title = existing[1].manifest?.title || existing[0]
|
||||
return `Host port ${parsed.host} is already used by ${title}. Choose another host port.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -70,11 +70,11 @@ export function useAppsActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUninstall(appId: string) {
|
||||
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
|
||||
uninstalling.value = true
|
||||
try {
|
||||
uninstallingApps.add(appId)
|
||||
await store.uninstallPackage(appId)
|
||||
await store.uninstallPackage(appId, options)
|
||||
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
|
||||
// when the container actually disappears from backend data
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function normalizeCloudPath(path: unknown, fallback = '/'): string {
|
||||
if (typeof path !== 'string' || !path.trim()) return fallback
|
||||
const trimmed = path.trim()
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
return withSlash.replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
export function parentCloudPath(path: string): string {
|
||||
const normalized = normalizeCloudPath(path)
|
||||
if (normalized === '/') return '/'
|
||||
const parent = normalized.slice(0, normalized.lastIndexOf('/')) || '/'
|
||||
return parent
|
||||
}
|
||||
@@ -41,11 +41,30 @@ import { useAppStore } from '@/stores/app'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
|
||||
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
const dismissedNotifications = ref<Set<string>>(new Set())
|
||||
|
||||
const healthNotifications = computed(() => {
|
||||
const notifs = store.data?.notifications ?? []
|
||||
const visible = notifs.filter(n => !dismissedNotifications.value.has(n.id))
|
||||
const packages = store.data?.['package-data'] ?? {}
|
||||
const visible = notifs.filter((n) => {
|
||||
if (dismissedNotifications.value.has(n.id)) return false
|
||||
|
||||
const appId = n.app_id || appIdFromNotificationTitle(n.title)
|
||||
if (appId) {
|
||||
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
|
||||
const pkg = packages[appId]
|
||||
if (!pkg) return false
|
||||
if (pkg.health !== 'unhealthy') return false
|
||||
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
|
||||
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
// Deduplicate: keep only the latest notification per container/title
|
||||
const seen = new Map<string, typeof visible[0]>()
|
||||
for (const n of visible) {
|
||||
@@ -64,4 +83,14 @@ function dismissNotification(id: string) {
|
||||
}
|
||||
dismissedNotifications.value.add(id)
|
||||
}
|
||||
|
||||
function appIdFromNotificationTitle(title: string): string | undefined {
|
||||
const suffix = ' is unhealthy'
|
||||
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
|
||||
}
|
||||
|
||||
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
|
||||
const ts = Date.parse(timestamp)
|
||||
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
|
||||
import HealthNotifications from '../HealthNotifications.vue'
|
||||
|
||||
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
|
||||
return {
|
||||
state,
|
||||
health,
|
||||
manifest: {
|
||||
id,
|
||||
title: id,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
interfaces: { main: { ui: true } },
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
}
|
||||
}
|
||||
|
||||
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
|
||||
return {
|
||||
'server-info': {
|
||||
id: 'node',
|
||||
version: '1.0.0',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': {
|
||||
restarting: false,
|
||||
'shutting-down': false,
|
||||
updated: false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
},
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
'seed-backed': false,
|
||||
},
|
||||
'package-data': pkg ? { indeedhub: pkg } : {},
|
||||
notifications: [{
|
||||
id: 'health-1',
|
||||
level: 'error',
|
||||
title: 'indeedhub is unhealthy',
|
||||
message: 'indeedhub health check failed',
|
||||
timestamp,
|
||||
app_id: 'indeedhub',
|
||||
}],
|
||||
ui: {
|
||||
name: null,
|
||||
'ack-welcome': '',
|
||||
marketplace: {
|
||||
'selected-hosts': [],
|
||||
'known-hosts': {},
|
||||
},
|
||||
theme: 'dark',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('HealthNotifications', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows active unhealthy package notifications', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides stale package notifications once health recovers', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides package notifications while an app is being removed', () => {
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
|
||||
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
|
||||
const store = useAppStore(pinia)
|
||||
store.data = makeData(
|
||||
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
|
||||
new Date('2026-06-10T11:20:00Z').toISOString(),
|
||||
)
|
||||
|
||||
const wrapper = mount(HealthNotifications, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,26 @@
|
||||
<div>
|
||||
<!-- Apps Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
|
||||
<template v-if="isLoading && filteredApps.length === 0">
|
||||
<div
|
||||
v-for="index in 6"
|
||||
:key="`loading-${index}`"
|
||||
class="glass-card p-5 flex flex-col app-card-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-3">
|
||||
<div class="app-card-skeleton-icon"></div>
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
|
||||
<div class="app-card-skeleton-line w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card-skeleton-line w-full mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
|
||||
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
|
||||
<div class="app-card-skeleton-button mt-auto"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-for="(app, index) in filteredApps"
|
||||
:key="app.id"
|
||||
@@ -124,15 +144,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredApps.length === 0" class="text-center py-12">
|
||||
<div v-if="isLoading" class="flex flex-col items-center gap-4">
|
||||
<svg class="animate-spin h-12 w-12 text-blue-400" 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">{{ loadingMessage }}</p>
|
||||
</div>
|
||||
<div v-else-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
||||
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
|
||||
<div v-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
|
||||
<p class="text-white/70">No community apps found</p>
|
||||
<p class="text-white/40 text-sm">{{ nostrError }}</p>
|
||||
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
|
||||
@@ -196,4 +209,44 @@ function handleImageError(event: Event) {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.app-card-skeleton {
|
||||
min-height: 245px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon,
|
||||
.app-card-skeleton-line,
|
||||
.app-card-skeleton-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon::after,
|
||||
.app-card-skeleton-line::after,
|
||||
.app-card-skeleton-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-card-skeleton-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-card-skeleton-line {
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-card-skeleton-button {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeFilter"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
|
||||
@@ -79,7 +79,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
|
||||
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
|
||||
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
|
||||
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing. Storefront opens on port 9011; admin dashboard remains on 9010.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
|
||||
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
|
||||
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
|
||||
@@ -87,7 +86,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '28', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:28`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
|
||||
@@ -121,7 +120,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -191,7 +189,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
const combined = `${id} ${title} ${description}`
|
||||
|
||||
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
|
||||
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
|
||||
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
|
||||
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'
|
||||
|
||||
@@ -67,6 +67,13 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Searching relays...
|
||||
</div>
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.nostr_pubkey"
|
||||
@@ -189,4 +196,6 @@ function shortNpub(npub: string): string {
|
||||
if (!npub || npub.length < 16) return npub
|
||||
return `${npub.slice(0, 14)}…${npub.slice(-8)}`
|
||||
}
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="handleClose">
|
||||
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
|
||||
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-white">Node Details</h2>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-3 py-8 justify-center">
|
||||
<div v-if="loading && nodes.length === 0" class="flex items-center gap-3 py-8 justify-center">
|
||||
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
|
||||
<span class="text-white/60 text-sm">Loading nodes...</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import DiscoverModal from '../DiscoverModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
handshakeDiscover: vi.fn(),
|
||||
handshakeConnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
return {
|
||||
nostr_pubkey: 'pubkey-one',
|
||||
nostr_npub: 'npub1abcdefghijklmnopqrstuvwxyz',
|
||||
did: 'did:key:node',
|
||||
version: '1.8-alpha',
|
||||
}
|
||||
}
|
||||
|
||||
describe('DiscoverModal', () => {
|
||||
it('keeps discoverable nodes visible while relay refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
|
||||
|
||||
const wrapper = mount(DiscoverModal, {
|
||||
props: { visible: false, outboundSent: [] },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.setProps({ visible: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
expect(wrapper.text()).toContain('version 1.8-alpha')
|
||||
|
||||
const pending = deferred<{ nodes: [] }>()
|
||||
vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { refresh: () => Promise<void> }).refresh()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
expect(wrapper.text()).toContain('Searching relays...')
|
||||
expect(wrapper.text()).not.toContain('No discoverable nodes found')
|
||||
|
||||
pending.reject(new Error('relay offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('did:key:node')
|
||||
expect(wrapper.text()).toContain('relay offline')
|
||||
expect(wrapper.text()).not.toContain('Searching relays...')
|
||||
expect(wrapper.text()).not.toContain('No discoverable nodes found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import NodeList from '../NodeList.vue'
|
||||
import type { FederatedNode } from '../types'
|
||||
|
||||
const trustedNode: FederatedNode = {
|
||||
did: 'did:key:z6MkTrustedNode',
|
||||
pubkey: 'trusted-pubkey',
|
||||
onion: 'trusted.onion',
|
||||
trust_level: 'trusted',
|
||||
added_at: '2026-06-10T00:00:00Z',
|
||||
name: 'Trusted Node',
|
||||
last_seen: '2026-06-10T00:00:00Z',
|
||||
}
|
||||
|
||||
describe('NodeList', () => {
|
||||
it('keeps existing nodes visible during background refresh loading', () => {
|
||||
const wrapper = mount(NodeList, {
|
||||
props: {
|
||||
nodes: [trustedNode],
|
||||
loading: true,
|
||||
error: '',
|
||||
syncResults: [],
|
||||
dwnSyncDotClass: 'bg-green-400',
|
||||
cleaningNodes: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Trusted Node')
|
||||
expect(wrapper.text()).not.toContain('Loading nodes...')
|
||||
})
|
||||
})
|
||||
@@ -28,9 +28,12 @@
|
||||
</div>
|
||||
|
||||
<!-- History Charts -->
|
||||
<div v-if="historyLoading" class="text-white/40 text-sm py-4 text-center mb-4">
|
||||
<div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4">
|
||||
Loading history...
|
||||
</div>
|
||||
<div v-else-if="historyLoading" class="text-white/40 text-xs text-center mb-4">
|
||||
Refreshing history...
|
||||
</div>
|
||||
<div v-else-if="historyLabels.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="glass-card p-4">
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
|
||||
|
||||
function node(id: string, reportedAt: string): FleetNode {
|
||||
return {
|
||||
node_id: id,
|
||||
version: '1.8-alpha',
|
||||
uptime_secs: 60,
|
||||
cpu_cores: 4,
|
||||
cpu_pct: 10,
|
||||
mem_pct: 20,
|
||||
disk_pct: 30,
|
||||
container_count: 2,
|
||||
running_count: 2,
|
||||
federation_peers: 1,
|
||||
recent_alerts: [],
|
||||
containers: [],
|
||||
reported_at: reportedAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fleet data helpers', () => {
|
||||
it('treats nodes reported within 30 minutes as online', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
|
||||
expect(isOnline('2026-06-10T11:45:00Z')).toBe(true)
|
||||
expect(isOnline('2026-06-10T11:20:00Z')).toBe(false)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sorts status with online nodes first, then latest report', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
const nodes = [
|
||||
node('offline', '2026-06-10T10:00:00Z'),
|
||||
node('online-old', '2026-06-10T11:45:00Z'),
|
||||
node('online-new', '2026-06-10T11:59:00Z'),
|
||||
]
|
||||
|
||||
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
|
||||
'online-new',
|
||||
'online-old',
|
||||
'offline',
|
||||
])
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sorts by name alphabetically', () => {
|
||||
expect(sortFleetNodes([
|
||||
node('zulu', '2026-06-10T11:59:00Z'),
|
||||
node('alpha', '2026-06-10T11:59:00Z'),
|
||||
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
|
||||
})
|
||||
|
||||
it('normalizes older telemetry reports with missing metric and container fields', () => {
|
||||
const normalized = normalizeFleetNode({
|
||||
node_id: 'legacy-node',
|
||||
version: '1.8-alpha',
|
||||
reported_at: '2026-06-10T11:59:00Z',
|
||||
})
|
||||
|
||||
expect(normalized.node_id).toBe('legacy-node')
|
||||
expect(normalized.cpu_pct).toBe(0)
|
||||
expect(normalized.mem_pct).toBe(0)
|
||||
expect(normalized.disk_pct).toBe(0)
|
||||
expect(normalized.containers).toEqual([])
|
||||
expect(normalized.recent_alerts).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes node history responses from backend entries or legacy history fields', () => {
|
||||
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
|
||||
|
||||
expect(normalizeNodeHistoryResponse({ entries: [entry] })).toEqual([entry])
|
||||
expect(normalizeNodeHistoryResponse({ history: [entry] })).toEqual([entry])
|
||||
expect(normalizeNodeHistoryResponse({})).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,58 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
||||
{ label: 'Name', value: 'name' },
|
||||
]
|
||||
|
||||
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
|
||||
const sorted = [...nodes]
|
||||
switch (sortBy) {
|
||||
case 'status':
|
||||
sorted.sort((a, b) => {
|
||||
const aOnline = isOnline(a.reported_at)
|
||||
const bOnline = isOnline(b.reported_at)
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1
|
||||
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
||||
})
|
||||
break
|
||||
case 'last-seen':
|
||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||
break
|
||||
case 'name':
|
||||
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function numberOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
||||
return {
|
||||
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
|
||||
version: typeof node.version === 'string' ? node.version : 'unknown',
|
||||
uptime_secs: numberOrZero(node.uptime_secs),
|
||||
cpu_cores: numberOrZero(node.cpu_cores),
|
||||
cpu_pct: numberOrZero(node.cpu_pct),
|
||||
mem_pct: numberOrZero(node.mem_pct),
|
||||
disk_pct: numberOrZero(node.disk_pct),
|
||||
container_count: numberOrZero(node.container_count),
|
||||
running_count: numberOrZero(node.running_count),
|
||||
federation_peers: numberOrZero(node.federation_peers),
|
||||
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
|
||||
containers: Array.isArray(node.containers) ? node.containers : [],
|
||||
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNodeHistoryResponse(data: {
|
||||
history?: NodeHistoryEntry[]
|
||||
entries?: NodeHistoryEntry[]
|
||||
} | null | undefined): NodeHistoryEntry[] {
|
||||
if (Array.isArray(data?.history)) return data.history
|
||||
if (Array.isArray(data?.entries)) return data.entries
|
||||
return []
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useFleetData() {
|
||||
@@ -125,6 +177,7 @@ export function useFleetData() {
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>([])
|
||||
const fleetAlerts = ref<FleetAlert[]>([])
|
||||
const refreshing = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||
@@ -166,26 +219,7 @@ export function useFleetData() {
|
||||
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
||||
})
|
||||
|
||||
const sortedNodes = computed(() => {
|
||||
const sorted = [...nodes.value]
|
||||
switch (sortBy.value) {
|
||||
case 'status':
|
||||
sorted.sort((a, b) => {
|
||||
const aOnline = isOnline(a.reported_at)
|
||||
const bOnline = isOnline(b.reported_at)
|
||||
if (aOnline !== bOnline) return aOnline ? 1 : -1
|
||||
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
||||
})
|
||||
break
|
||||
case 'last-seen':
|
||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||
break
|
||||
case 'name':
|
||||
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
|
||||
|
||||
const allAppIds = computed(() => {
|
||||
const appSet = new Set<string>()
|
||||
@@ -227,11 +261,11 @@ export function useFleetData() {
|
||||
|
||||
async function fetchFleetStatus() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
|
||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||
method: 'telemetry.fleet-status',
|
||||
})
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -259,15 +293,12 @@ export function useFleetData() {
|
||||
|
||||
async function fetchNodeHistory(nodeId: string) {
|
||||
nodeHistoryLoading.value = true
|
||||
nodeHistory.value = []
|
||||
try {
|
||||
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
|
||||
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
|
||||
method: 'telemetry.fleet-node-history',
|
||||
params: { node_id: nodeId },
|
||||
})
|
||||
if (data?.history) {
|
||||
nodeHistory.value = data.history
|
||||
}
|
||||
nodeHistory.value = normalizeNodeHistoryResponse(data)
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
@@ -277,9 +308,14 @@ export function useFleetData() {
|
||||
|
||||
async function refreshAll() {
|
||||
loading.value = !nodes.value.length
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
loading.value = false
|
||||
try {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(nodeId: string) {
|
||||
@@ -288,7 +324,6 @@ export function useFleetData() {
|
||||
nodeHistory.value = []
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
fetchNodeHistory(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +402,7 @@ export function useFleetData() {
|
||||
})
|
||||
|
||||
return {
|
||||
loading, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { goalStepTargetPath } from '../goalStepActions'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
describe('goalStepActions', () => {
|
||||
it('routes app-backed steps to their app details page', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
|
||||
})
|
||||
|
||||
it('routes built-in identity and backup steps to their owning screens', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
|
||||
expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
|
||||
expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
|
||||
})
|
||||
|
||||
it('keeps passive info steps without a target route', () => {
|
||||
expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('gives every configure step in the shipped goals a destination', () => {
|
||||
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
|
||||
|
||||
expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
|
||||
configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function step(overrides: Partial<GoalStep>): GoalStep {
|
||||
return {
|
||||
id: 'step',
|
||||
title: 'Step',
|
||||
description: '',
|
||||
action: 'configure',
|
||||
isAutomatic: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
|
||||
const STEP_ROUTE_OVERRIDES: Record<string, string> = {
|
||||
'setup-nostr': '/dashboard/web5',
|
||||
'export-identity': '/dashboard/web5/credentials',
|
||||
'create-passphrase': '/dashboard/settings',
|
||||
'create-backup': '/dashboard/settings',
|
||||
'save-backup': '/dashboard/settings',
|
||||
}
|
||||
|
||||
export function goalStepTargetPath(step: GoalStep): string | null {
|
||||
if (step.appId) return `/dashboard/apps/${step.appId}`
|
||||
return STEP_ROUTE_OVERRIDES[step.id] ?? null
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable lg:col-span-2"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
style="--card-stagger: 4"
|
||||
style="--card-stagger: 6"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
tabindex="0"
|
||||
class="home-card controller-focusable"
|
||||
:class="{ 'home-card-animate': animate }"
|
||||
style="--card-stagger: 3"
|
||||
style="--card-stagger: 4"
|
||||
>
|
||||
<div class="home-card-shell">
|
||||
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getHomeRecommendedApps, isMarketplaceAppInstalled } from '../homeRecommendations'
|
||||
import type { MarketplaceApp } from '@/views/marketplace/marketplaceData'
|
||||
|
||||
const apps: MarketplaceApp[] = [
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', dockerImage: 'vaultwarden:latest' },
|
||||
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: 'bitcoin:latest' },
|
||||
{ id: 'homeassistant', title: 'Home Assistant', dockerImage: 'homeassistant:latest' },
|
||||
{ id: 'mempool', title: 'Mempool', dockerImage: 'mempool:latest' },
|
||||
{ id: 'website-only', title: 'Website Only', webUrl: 'https://example.com' },
|
||||
]
|
||||
|
||||
describe('homeRecommendations', () => {
|
||||
it('treats installed aliases as installed apps', () => {
|
||||
expect(isMarketplaceAppInstalled('mempool', { 'mempool-web': {} })).toBe(true)
|
||||
expect(isMarketplaceAppInstalled('mempool', { vaultwarden: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns uninstalled installable core and recommended apps for Home', () => {
|
||||
const recommended = getHomeRecommendedApps(apps, { 'mempool-web': {} }, 3)
|
||||
|
||||
expect(recommended.map((app) => app.id)).toEqual([
|
||||
'bitcoin-knots',
|
||||
'vaultwarden',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no recommendations once matching apps are installed', () => {
|
||||
const recommended = getHomeRecommendedApps(apps, {
|
||||
'bitcoin-knots': {},
|
||||
'mempool-web': {},
|
||||
vaultwarden: {},
|
||||
})
|
||||
|
||||
expect(recommended).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getAppTier, INSTALLED_ALIASES, type MarketplaceApp } from '@/views/marketplace/marketplaceData'
|
||||
|
||||
export type InstalledPackageMap = Record<string, unknown>
|
||||
|
||||
export function isMarketplaceAppInstalled(
|
||||
appId: string,
|
||||
installedPackages: InstalledPackageMap,
|
||||
aliases: Record<string, string[]> = INSTALLED_ALIASES,
|
||||
): boolean {
|
||||
if (appId in installedPackages) return true
|
||||
return aliases[appId]?.some((alias) => alias in installedPackages) ?? false
|
||||
}
|
||||
|
||||
export function getHomeRecommendedApps(
|
||||
apps: MarketplaceApp[],
|
||||
installedPackages: InstalledPackageMap,
|
||||
limit = 3,
|
||||
): MarketplaceApp[] {
|
||||
return apps
|
||||
.filter((app) => {
|
||||
if (!app.dockerImage) return false
|
||||
if (isMarketplaceAppInstalled(app.id, installedPackages)) return false
|
||||
const tier = getAppTier(app.id)
|
||||
return tier === 'core' || tier === 'recommended'
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const tierRank = (app: MarketplaceApp) => getAppTier(app.id) === 'core' ? 0 : 1
|
||||
const tierDiff = tierRank(a) - tierRank(b)
|
||||
if (tierDiff !== 0) return tierDiff
|
||||
return (a.title || a.id).localeCompare(b.title || b.id)
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -12,22 +12,16 @@
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:src="app.icon || DEFAULT_APP_ICON"
|
||||
:alt="app.title"
|
||||
class="w-16 h-16 rounded-lg object-cover"
|
||||
class="w-16 h-16 rounded-lg archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-16 h-16 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-semibold text-white mb-1">
|
||||
{{ app.title }}
|
||||
<span
|
||||
v-if="tierLabel !== 'optional'"
|
||||
v-if="!installed && tierLabel !== 'optional'"
|
||||
class="tier-badge"
|
||||
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
|
||||
>{{ tierLabel }}</span>
|
||||
@@ -153,6 +147,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MarketplaceApp, InstallProgress } from './marketplaceData'
|
||||
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -186,7 +181,10 @@ const installProgressMessage = computed(() => {
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
if (!img.src.includes(DEFAULT_APP_ICON)) {
|
||||
img.src = DEFAULT_APP_ICON
|
||||
img.dataset.defaultIcon = '1'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
|
||||
@click.self="close()"
|
||||
>
|
||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import MarketplaceAppCard from '../MarketplaceAppCard.vue'
|
||||
import type { MarketplaceApp } from '../marketplaceData'
|
||||
|
||||
const app: MarketplaceApp = {
|
||||
id: 'btcpay-server',
|
||||
title: 'BTCPay Server',
|
||||
version: '2.0.6',
|
||||
description: 'Self-hosted Bitcoin payments',
|
||||
icon: '',
|
||||
dockerImage: 'btcpayserver/btcpayserver:2.0.6',
|
||||
category: 'commerce',
|
||||
source: 'community',
|
||||
}
|
||||
|
||||
function mountCard(installed: boolean) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
common: { install: 'Install', launch: 'Launch', notAvailable: 'Not available' },
|
||||
marketplace: { alreadyInstalled: 'Already installed' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return mount(MarketplaceAppCard, {
|
||||
props: {
|
||||
app,
|
||||
index: 0,
|
||||
stagger: false,
|
||||
installed,
|
||||
installing: false,
|
||||
installProgress: undefined,
|
||||
installedState: installed ? 'running' : null,
|
||||
startingUp: false,
|
||||
containersScanned: true,
|
||||
tierLabel: 'recommended',
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('MarketplaceAppCard', () => {
|
||||
it('shows tier badges before install', () => {
|
||||
const wrapper = mountCard(false)
|
||||
expect(wrapper.find('.tier-badge').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('recommended')
|
||||
})
|
||||
|
||||
it('hides tier badges after install', () => {
|
||||
const wrapper = mountCard(true)
|
||||
expect(wrapper.find('.tier-badge').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('recommended')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ export interface MarketplaceApp {
|
||||
manifestUrl?: string
|
||||
repoUrl?: string
|
||||
webUrl?: string
|
||||
screenshots?: AppScreenshot[]
|
||||
category?: string
|
||||
source?: string
|
||||
url?: string
|
||||
@@ -30,6 +31,11 @@ export interface MarketplaceApp {
|
||||
relayCount?: number
|
||||
}
|
||||
|
||||
export type AppScreenshot = string | {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export interface InstallProgress {
|
||||
id: string
|
||||
title: string
|
||||
@@ -47,7 +53,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
|
||||
saleor: ['saleor'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
@@ -68,7 +73,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
/** Get app tier classification (matches backend get_app_tier) */
|
||||
export function getAppTier(appId: string): string {
|
||||
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer', 'saleor']
|
||||
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'netbird', 'portainer']
|
||||
if (core.includes(appId)) return 'core'
|
||||
if (recommended.includes(appId)) return 'recommended'
|
||||
return 'optional'
|
||||
@@ -90,7 +95,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
|
||||
return 'money'
|
||||
}
|
||||
|
||||
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') ||
|
||||
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') ||
|
||||
id.includes('store') || id.includes('pos') || id.includes('payment') ||
|
||||
combined.includes('merchant') || combined.includes('invoice')) {
|
||||
return 'commerce'
|
||||
@@ -158,18 +163,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
|
||||
},
|
||||
{
|
||||
id: 'saleor',
|
||||
title: 'Saleor',
|
||||
version: '3.23',
|
||||
category: 'commerce',
|
||||
description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing.',
|
||||
icon: '/assets/img/app-icons/saleor.svg',
|
||||
author: 'Saleor',
|
||||
dockerImage: 'ghcr.io/saleor/saleor:3.23',
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/saleor/saleor'
|
||||
},
|
||||
{
|
||||
id: 'lnd',
|
||||
title: 'LND',
|
||||
@@ -261,11 +254,11 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{
|
||||
id: 'nextcloud',
|
||||
title: 'Nextcloud',
|
||||
version: '28.0',
|
||||
version: '29.0',
|
||||
description: 'Self-hosted cloud storage and collaboration platform. Your own private cloud.',
|
||||
icon: '/assets/img/app-icons/nextcloud.webp',
|
||||
author: 'Nextcloud',
|
||||
dockerImage: `${REGISTRY}/nextcloud:28`,
|
||||
dockerImage: `${REGISTRY}/nextcloud:29`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/nextcloud/server'
|
||||
},
|
||||
|
||||
@@ -25,12 +25,21 @@
|
||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
||||
.mesh-tools-wrapper { display: contents; }
|
||||
.mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide { display: grid; grid-template-columns: 340px 1fr 1fr; gap: 16px; }
|
||||
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
|
||||
.mesh-columns-wide .mesh-left { grid-column: 1; width: auto; }
|
||||
.mesh-columns-wide .mesh-right { display: contents; }
|
||||
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow-y: auto; }
|
||||
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
|
||||
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
|
||||
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
|
||||
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
|
||||
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar { display: none; }
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
@@ -133,8 +142,8 @@
|
||||
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
|
||||
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 400px; }
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.mesh-chat-back { display: block; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</Teleport>
|
||||
|
||||
<!-- WiFi Scan Modal -->
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
||||
@@ -154,7 +154,7 @@
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration Modal -->
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative shrink-0">
|
||||
@@ -11,17 +11,11 @@
|
||||
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<div class="responsive-card-actions-top items-center gap-2">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
<button @click="$emit('showAddService')" class="glass-button px-3 py-2 rounded-lg text-sm 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>
|
||||
<button @click="$emit('showAddService')" class="glass-button px-3 py-2 rounded-lg text-sm">
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
@@ -29,6 +23,13 @@
|
||||
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
|
||||
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="torServicesLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing Tor services...
|
||||
</div>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -66,6 +67,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="responsive-card-actions-bottom-grid mt-4 grid-cols-2 gap-3">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="mobile-card-action glass-button rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
<button @click="$emit('showAddService')" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAppStore()
|
||||
@@ -13,6 +14,7 @@ const serverName = computed(() => store.serverName)
|
||||
const editingServerName = ref(false)
|
||||
const serverNameDraft = ref('')
|
||||
const serverNameInput = ref<HTMLInputElement | null>(null)
|
||||
const serverNameWarning = ref('')
|
||||
|
||||
function startEditServerName() {
|
||||
serverNameDraft.value = serverName.value
|
||||
@@ -22,13 +24,17 @@ function startEditServerName() {
|
||||
|
||||
async function saveServerName() {
|
||||
const name = serverNameDraft.value.trim()
|
||||
serverNameWarning.value = ''
|
||||
if (!name || name === serverName.value) {
|
||||
editingServerName.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rpcClient.call({ method: 'server.set-name', params: { name } })
|
||||
const result = await rpcClient.call<{ hostname_error?: string | null }>({ method: 'server.set-name', params: { name } })
|
||||
store.updateServerName(name)
|
||||
if (result.hostname_error) {
|
||||
serverNameWarning.value = `Display name saved, but hostname update failed: ${result.hostname_error}`
|
||||
}
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
|
||||
}
|
||||
@@ -38,6 +44,7 @@ async function saveServerName() {
|
||||
// Version & release notes
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
const showReleaseNotes = ref(false)
|
||||
useBodyScrollLock(showReleaseNotes)
|
||||
|
||||
// Identity
|
||||
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||
@@ -148,6 +155,7 @@ init()
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p v-if="serverNameWarning" class="mt-2 text-xs text-yellow-300">{{ serverNameWarning }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Version Card -->
|
||||
|
||||
@@ -27,6 +27,7 @@ const verifyingBackupId = ref<string | null>(null)
|
||||
const deletingBackupId = ref<string | null>(null)
|
||||
const backupStatusMsg = ref('')
|
||||
const backupStatusType = ref<'success' | 'error'>('success')
|
||||
const backupLoadError = ref('')
|
||||
|
||||
function formatBackupSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
@@ -42,12 +43,15 @@ function showBackupStatus(msg: string, type: 'success' | 'error') {
|
||||
}
|
||||
|
||||
async function loadBackups() {
|
||||
const hadBackups = backupList.value.length > 0
|
||||
loadingBackups.value = true
|
||||
backupLoadError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
|
||||
backupList.value = res.backups || []
|
||||
} catch {
|
||||
backupList.value = []
|
||||
} catch (e: unknown) {
|
||||
backupLoadError.value = e instanceof Error ? e.message : t('settings.backupListFailed')
|
||||
if (!hadBackups) backupList.value = []
|
||||
} finally {
|
||||
loadingBackups.value = false
|
||||
}
|
||||
@@ -189,6 +193,8 @@ function copyChannelBackup() {
|
||||
}
|
||||
|
||||
loadBackups()
|
||||
|
||||
defineExpose({ loadBackups })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -204,9 +210,19 @@ loadBackups()
|
||||
{{ t('settings.createBackup') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
|
||||
<div v-if="loadingBackups && backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
|
||||
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="loadingBackups" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing backups...
|
||||
</div>
|
||||
<div v-else-if="backupLoadError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ backupLoadError }}
|
||||
</div>
|
||||
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
|
||||
@@ -235,7 +251,7 @@ loadBackups()
|
||||
|
||||
<!-- Create Backup Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showCreateBackupModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
|
||||
<div class="space-y-3">
|
||||
@@ -260,7 +276,7 @@ loadBackups()
|
||||
|
||||
<!-- Restore Backup Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showRestoreModal = false">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showRestoreModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePas
|
||||
const changingPassword = ref(false)
|
||||
const changePasswordError = ref('')
|
||||
const changePasswordSuccess = ref('')
|
||||
const changePasswordWarning = ref('')
|
||||
const changePasswordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
@@ -32,6 +33,7 @@ function validatePasswordStrength(pw: string): string | null {
|
||||
async function handleChangePassword() {
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
changePasswordWarning.value = ''
|
||||
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
changePasswordError.value = t('settings.passwordAllFieldsRequired')
|
||||
@@ -48,16 +50,21 @@ async function handleChangePassword() {
|
||||
}
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await rpcClient.changePassword({
|
||||
const result = await rpcClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
alsoChangeSsh,
|
||||
})
|
||||
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
|
||||
if (alsoChangeSsh && result.ssh_error) {
|
||||
changePasswordWarning.value = `${t('settings.passwordUpdatedSshFailed')} ${result.ssh_error}`
|
||||
}
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
if (!changePasswordWarning.value) {
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
}
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
|
||||
} finally {
|
||||
@@ -70,6 +77,7 @@ function closeChangePasswordModal() {
|
||||
showChangePasswordModal.value = false
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
changePasswordWarning.value = ''
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
}
|
||||
</script>
|
||||
@@ -93,7 +101,7 @@ function closeChangePasswordModal() {
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeChangePasswordModal()"
|
||||
>
|
||||
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
|
||||
@@ -139,6 +147,7 @@ function closeChangePasswordModal() {
|
||||
</label>
|
||||
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||
<p v-if="changePasswordWarning" class="text-sm text-yellow-300">{{ changePasswordWarning }}</p>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -12,9 +12,19 @@
|
||||
<span class="text-white/40">Auto = FIPS first, Tor on failure.</span>
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="text-white/50 text-sm">Loading…</div>
|
||||
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-if="loading && !prefs" class="text-white/50 text-sm">Loading…</div>
|
||||
<div v-else-if="error && !prefs" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing transport preferences...
|
||||
</div>
|
||||
<div v-else-if="error" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-for="svc in services"
|
||||
:key="svc.key"
|
||||
@@ -77,12 +87,14 @@ const saving = ref<Service | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
const hadPrefs = prefs.value !== null
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
prefs.value = await rpcClient.call<Preferences>({ method: 'transport.preferences' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load transport preferences'
|
||||
if (!hadPrefs) prefs.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -118,4 +130,6 @@ async function setPref(service: Service, pref: Pref) {
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
defineExpose({ load })
|
||||
</script>
|
||||
|
||||
@@ -172,7 +172,7 @@ loadTotpStatus()
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpSetupModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeTotpSetup"
|
||||
@keydown.escape="closeTotpSetup"
|
||||
>
|
||||
@@ -279,7 +279,7 @@ loadTotpStatus()
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpDisableModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
|
||||
@click.self="closeTotpDisable"
|
||||
@keydown.escape="closeTotpDisable"
|
||||
>
|
||||
|
||||
@@ -38,6 +38,8 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
onMounted(fetchVpnStatus)
|
||||
|
||||
defineExpose({ fetchVpnStatus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,29 +67,47 @@ onMounted(fetchVpnStatus)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50">Loading VPN status...</div>
|
||||
<div v-if="loading && !vpnStatus" class="text-sm text-white/50">Loading VPN status...</div>
|
||||
|
||||
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Provider</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
|
||||
<div v-else-if="vpnStatus?.connected" class="space-y-3">
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing VPN status...
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Peers</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">VPN Address</div>
|
||||
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Traffic</div>
|
||||
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Provider</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Peers</div>
|
||||
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">VPN Address</div>
|
||||
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
|
||||
</div>
|
||||
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
|
||||
<div class="text-xs text-white/50 mb-1">Traffic</div>
|
||||
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!vpnStatus?.connected" class="text-sm text-white/50">
|
||||
VPN will activate automatically when peers are discovered via Nostr relays.
|
||||
<div v-else-if="vpnStatus && !vpnStatus.connected" class="space-y-2">
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing VPN status...
|
||||
</div>
|
||||
<div class="text-sm text-white/50">
|
||||
VPN will activate automatically when peers are discovered via Nostr relays.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import BackupSection from '../BackupSection.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeBackup() {
|
||||
return {
|
||||
id: 'backup-one',
|
||||
created_at: '2026-06-10T10:00:00Z',
|
||||
size_bytes: 2048,
|
||||
encrypted: true,
|
||||
description: 'Before upgrade',
|
||||
}
|
||||
}
|
||||
|
||||
describe('BackupSection', () => {
|
||||
it('keeps backups visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({ backups: [makeBackup()] })
|
||||
|
||||
const wrapper = mount(BackupSection, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Before upgrade')
|
||||
expect(wrapper.text()).toContain('2.0 KB')
|
||||
|
||||
const pending = deferred<{ backups: [] }>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadBackups: () => Promise<void> }).loadBackups()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Before upgrade')
|
||||
expect(wrapper.text()).toContain('Refreshing backups...')
|
||||
expect(wrapper.text()).not.toContain('settings.loadingBackups')
|
||||
expect(wrapper.text()).not.toContain('settings.noBackups')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Before upgrade')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing backups...')
|
||||
expect(wrapper.text()).not.toContain('settings.noBackups')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ChangePasswordSection from '../ChangePasswordSection.vue'
|
||||
|
||||
const { changePassword } = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
changePassword,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ChangePasswordSection', () => {
|
||||
beforeEach(() => {
|
||||
changePassword.mockReset()
|
||||
})
|
||||
|
||||
it('shows a warning when SSH update fails after web password update succeeds', async () => {
|
||||
changePassword.mockResolvedValueOnce({
|
||||
success: true,
|
||||
ssh_updated: false,
|
||||
ssh_error: 'sudo unavailable',
|
||||
})
|
||||
const wrapper = mount(ChangePasswordSection, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('password123')
|
||||
await inputs[1]!.setValue('MyP@ssw0rd!123')
|
||||
await inputs[2]!.setValue('MyP@ssw0rd!123')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
|
||||
expect(changePassword).toHaveBeenCalledWith({
|
||||
currentPassword: 'password123',
|
||||
newPassword: 'MyP@ssw0rd!123',
|
||||
alsoChangeSsh: true,
|
||||
})
|
||||
expect(wrapper.text()).toContain('settings.passwordUpdatedSuccess')
|
||||
expect(wrapper.text()).toContain('settings.passwordUpdatedSshFailed sudo unavailable')
|
||||
expect(wrapper.text()).not.toContain('settings.passwordChangeFailed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TransportPrefsCard from '../TransportPrefsCard.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makePrefs() {
|
||||
return {
|
||||
federation: 'auto',
|
||||
peers: 'fips',
|
||||
peer_files: 'tor',
|
||||
messaging: 'auto',
|
||||
mesh_file_sharing: 'fips',
|
||||
}
|
||||
}
|
||||
|
||||
describe('TransportPrefsCard', () => {
|
||||
it('keeps transport preferences visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce(makePrefs())
|
||||
|
||||
const wrapper = mount(TransportPrefsCard)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Federation')
|
||||
expect(wrapper.text()).toContain('Peer Files')
|
||||
expect(wrapper.text()).toContain('Auto')
|
||||
expect(wrapper.text()).toContain('FIPS')
|
||||
expect(wrapper.text()).toContain('Tor')
|
||||
|
||||
const pending = deferred<ReturnType<typeof makePrefs>>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { load: () => Promise<void> }).load()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Federation')
|
||||
expect(wrapper.text()).toContain('Peer Files')
|
||||
expect(wrapper.text()).toContain('Refreshing transport preferences...')
|
||||
expect(wrapper.text()).not.toContain('Loading…')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Federation')
|
||||
expect(wrapper.text()).toContain('Peer Files')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing transport preferences...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import VpnStatusSection from '../VpnStatusSection.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function makeVpnStatus() {
|
||||
return {
|
||||
connected: true,
|
||||
provider: 'wireguard',
|
||||
interface: 'wg0',
|
||||
ip_address: '10.0.0.2/32',
|
||||
hostname: 'node',
|
||||
peers_connected: 2,
|
||||
bytes_in: 1024,
|
||||
bytes_out: 2048,
|
||||
}
|
||||
}
|
||||
|
||||
describe('VpnStatusSection', () => {
|
||||
it('keeps VPN status visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce(makeVpnStatus())
|
||||
|
||||
const wrapper = mount(VpnStatusSection)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Connected')
|
||||
expect(wrapper.text()).toContain('wireguard')
|
||||
expect(wrapper.text()).toContain('10.0.0.2/32')
|
||||
|
||||
const pending = deferred<ReturnType<typeof makeVpnStatus>>()
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { fetchVpnStatus: () => Promise<void> }).fetchVpnStatus()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Connected')
|
||||
expect(wrapper.text()).toContain('wireguard')
|
||||
expect(wrapper.text()).toContain('Refreshing VPN status...')
|
||||
expect(wrapper.text()).not.toContain('Loading VPN status...')
|
||||
|
||||
pending.reject(new Error('offline'))
|
||||
await refresh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Connected')
|
||||
expect(wrapper.text()).toContain('wireguard')
|
||||
expect(wrapper.text()).toContain('offline')
|
||||
expect(wrapper.text()).not.toContain('Refreshing VPN status...')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<div class="web5-card-actions-top gap-2 shrink-0">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
@@ -20,9 +20,10 @@
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? '...' : t('common.refresh') }}
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,32 +37,26 @@
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ loadingPeers ? '...' : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Peers | Messages | Requests -->
|
||||
<!-- Tabs: Trusted | Observers | Messages | Requests -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
<button
|
||||
@click="nodesContainerTab = 'peers'"
|
||||
@click="nodesContainerTab = 'trusted'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
:class="nodesContainerTab === 'trusted' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
{{ t('web5.peers') }}
|
||||
{{ t('web5.trusted') }}
|
||||
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="nodesContainerTab = 'observers'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'observers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
{{ t('web5.observers') }}
|
||||
<span v-if="observers.length > 0" class="ml-1.5 text-xs text-white/50">({{ observers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="switchToMessagesTab"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
||||
@@ -82,11 +77,17 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers tab -->
|
||||
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
<!-- Trusted tab -->
|
||||
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noPeers') }}
|
||||
</div>
|
||||
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="p in peers"
|
||||
:key="p.pubkey"
|
||||
@@ -108,14 +109,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observers tab -->
|
||||
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="observers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noObservers') }}
|
||||
</div>
|
||||
<div v-else-if="loadingPeers" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="p in observers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-300 shrink-0">
|
||||
{{ t('web5.observer') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
|
||||
<div v-if="loadingMessages && receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noMessages') }}
|
||||
</div>
|
||||
<div v-else-if="loadingMessages" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, idx) in receivedMessages"
|
||||
:key="idx"
|
||||
@@ -131,12 +164,15 @@
|
||||
|
||||
<!-- Requests tab -->
|
||||
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
|
||||
<div v-if="loadingRequests" class="p-4 text-center text-white/60 text-sm">
|
||||
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
{{ t('web5.noRequests') }}
|
||||
</div>
|
||||
<div v-else-if="loadingRequests" class="p-2 text-center text-white/45 text-xs">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="req in connectionRequests"
|
||||
:key="req.id"
|
||||
@@ -168,15 +204,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4">
|
||||
<div class="mt-auto pt-4 space-y-3">
|
||||
<div class="web5-card-actions-bottom-grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="nodesContainerTab === 'peers'"
|
||||
v-if="nodesContainerTab === 'trusted'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? t('web5.discovering') : t('web5.discoverNodes') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'observers'"
|
||||
@click="loadPeers"
|
||||
:disabled="loadingPeers"
|
||||
class="w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingPeers ? t('common.loading') : t('common.refresh') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="nodesContainerTab === 'messages'"
|
||||
@click="loadReceivedMessages"
|
||||
@@ -266,10 +325,11 @@ const web5Badge = useWeb5BadgeStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const nodesContainerRef = ref<HTMLElement | null>(null)
|
||||
const nodesContainerTab = ref<'peers' | 'messages' | 'requests'>('peers')
|
||||
const nodesContainerTab = ref<'trusted' | 'observers' | 'messages' | 'requests'>('trusted')
|
||||
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||
|
||||
const peers = ref<Peer[]>([])
|
||||
const observers = ref<Peer[]>([])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachableLocal = ref<Record<string, boolean>>({})
|
||||
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
|
||||
@@ -300,11 +360,21 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
function peerNameFromPubkey(pubkey: string): string {
|
||||
const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
|
||||
const peer = [...peers.value, ...observers.value].find(p => p.pubkey === pubkey || p.onion === pubkey)
|
||||
if (peer?.name) return peer.name
|
||||
return (pubkey || '').slice(0, 16) + '...'
|
||||
}
|
||||
|
||||
type FederationNode = Awaited<ReturnType<typeof rpcClient.federationListNodes>>['nodes'][number]
|
||||
|
||||
function federationNodeToPeer(node: FederationNode): Peer {
|
||||
return {
|
||||
onion: node.onion,
|
||||
pubkey: node.pubkey,
|
||||
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
|
||||
}
|
||||
}
|
||||
|
||||
function switchToMessagesTab() {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
@@ -322,13 +392,25 @@ async function loadPeers() {
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
const peerList = res.peers || []
|
||||
const observerList: Peer[] = []
|
||||
|
||||
try {
|
||||
const fedRes = await rpcClient.federationListNodes()
|
||||
const fedNodes = fedRes.nodes || []
|
||||
for (const n of fedNodes) {
|
||||
if (n.onion && !peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
peerList.push({ onion: n.onion, pubkey: n.pubkey, name: n.name || `Federation: ${n.did?.slice(0, 16) || 'node'}` })
|
||||
if (!n.onion || n.trust_level === 'untrusted') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (n.trust_level === 'observer') {
|
||||
if (!observerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
observerList.push(federationNodeToPeer(n))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
|
||||
peerList.push(federationNodeToPeer(n))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -336,7 +418,8 @@ async function loadPeers() {
|
||||
}
|
||||
|
||||
peers.value = peerList
|
||||
for (const p of peers.value) {
|
||||
observers.value = observerList
|
||||
for (const p of [...peers.value, ...observers.value]) {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachableLocal.value[p.onion] = check.reachable
|
||||
@@ -394,13 +477,14 @@ async function discoverAndAddPeers() {
|
||||
}
|
||||
|
||||
async function loadConnectionRequests() {
|
||||
const hadRequests = connectionRequests.value.length > 0
|
||||
loadingRequests.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ requests: ConnectionRequest[] }>({ method: 'network.list-requests' })
|
||||
connectionRequests.value = res.requests || []
|
||||
web5Badge.pendingRequestCount = connectionRequests.value.length
|
||||
} catch {
|
||||
connectionRequests.value = []
|
||||
if (!hadRequests) connectionRequests.value = []
|
||||
} finally {
|
||||
loadingRequests.value = false
|
||||
}
|
||||
@@ -443,5 +527,5 @@ function scrollToMessages() {
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, scrollToMessages })
|
||||
defineExpose({ loadPeers, loadReceivedMessages, loadConnectionRequests, peers, observers, scrollToMessages })
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Credentials List (summary) -->
|
||||
<div v-if="vcCredentials.length" class="space-y-2">
|
||||
<div v-if="credentialsLoading && vcCredentials.length === 0" class="text-center text-white/40 text-sm py-4">
|
||||
Loading credentials...
|
||||
</div>
|
||||
<div v-else-if="vcCredentials.length" class="space-y-2">
|
||||
<div v-if="credentialsLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing credentials...
|
||||
</div>
|
||||
<div v-else-if="credentialsError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ credentialsError }}
|
||||
</div>
|
||||
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
|
||||
@@ -86,13 +99,21 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const vcCredentials = ref<VCData[]>([])
|
||||
const credentialsLoading = ref(false)
|
||||
const credentialsError = ref('')
|
||||
|
||||
async function loadCredentials() {
|
||||
const hadCredentials = vcCredentials.value.length > 0
|
||||
credentialsLoading.value = true
|
||||
credentialsError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ credentials: VCData[] }>({ method: 'identity.list-credentials' })
|
||||
vcCredentials.value = res.credentials || []
|
||||
} catch {
|
||||
vcCredentials.value = []
|
||||
} catch (e: unknown) {
|
||||
credentialsError.value = e instanceof Error ? e.message : 'Failed to load credentials'
|
||||
if (!hadCredentials) vcCredentials.value = []
|
||||
} finally {
|
||||
credentialsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +119,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showDwnMessages">
|
||||
<div v-if="loadingDwnMessages" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
||||
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
||||
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-if="loadingDwnMessages" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing messages...
|
||||
</div>
|
||||
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
|
||||
@@ -261,16 +268,17 @@ async function toggleDwnMessages() {
|
||||
}
|
||||
|
||||
async function loadDwnMessages() {
|
||||
const hadMessages = dwnMessages.value.length > 0
|
||||
loadingDwnMessages.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
|
||||
dwnMessages.value = res.messages || []
|
||||
} catch {
|
||||
dwnMessages.value = []
|
||||
if (!hadMessages) dwnMessages.value = []
|
||||
} finally {
|
||||
loadingDwnMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadDwnStatus, loadDwnProtocols })
|
||||
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div v-if="domainsLoading && registeredNames.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing domains...
|
||||
</div>
|
||||
<div v-else-if="domainsLoadError && registeredNames.length > 0" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ domainsLoadError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -152,6 +163,8 @@ const newDomainDomain = ref('')
|
||||
const newDomainIdentityId = ref('')
|
||||
const domainError = ref('')
|
||||
const domainRegistering = ref(false)
|
||||
const domainsLoading = ref(false)
|
||||
const domainsLoadError = ref('')
|
||||
const verifyNip05Input = ref('')
|
||||
const nip05Verifying = ref(false)
|
||||
const nip05Result = ref<Nip05Result | null>(null)
|
||||
@@ -160,11 +173,17 @@ const activeNamesCount = computed(() => registeredNames.value.filter(n => n.stat
|
||||
const expiringNamesCount = computed(() => registeredNames.value.filter(n => n.status === 'expired' || n.expires_at).length)
|
||||
|
||||
async function loadDomainNames() {
|
||||
const hadNames = registeredNames.value.length > 0
|
||||
domainsLoading.value = true
|
||||
domainsLoadError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ names: RegisteredNameData[] }>({ method: 'identity.list-names' })
|
||||
registeredNames.value = res.names || []
|
||||
} catch {
|
||||
registeredNames.value = []
|
||||
} catch (e: unknown) {
|
||||
domainsLoadError.value = e instanceof Error ? e.message : 'Failed to load domains'
|
||||
if (!hadNames) registeredNames.value = []
|
||||
} finally {
|
||||
domainsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,31 @@
|
||||
<p class="text-xs text-white/60">Federated nodes & peers</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Details
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<div class="web5-card-actions-top items-center gap-2">
|
||||
<RouterLink to="/dashboard/server/federation" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Find Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
Fleet
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="loadingFederation && hasLoadedFederation" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing federation...
|
||||
</div>
|
||||
<div v-else-if="federationError && hasLoadedFederation" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ federationError }}
|
||||
</div>
|
||||
<div v-else-if="loadingFederation" class="p-2 text-center text-white/45 text-xs">
|
||||
Loading federation...
|
||||
</div>
|
||||
|
||||
<!-- Node Count -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -55,6 +71,15 @@
|
||||
<span class="text-sm font-medium text-white/40 truncate max-w-[140px]">{{ selfDid || 'Not set' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="web5-card-actions-bottom-grid mt-6 grid-cols-2 gap-3">
|
||||
<RouterLink to="/dashboard/server/federation" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Find Nodes
|
||||
</RouterLink>
|
||||
<RouterLink to="/dashboard/fleet" class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors no-underline">
|
||||
Fleet
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,8 +92,13 @@ const nodeCount = ref(0)
|
||||
const onlineCount = ref(0)
|
||||
const pendingCount = ref(0)
|
||||
const selfDid = ref('')
|
||||
const loadingFederation = ref(false)
|
||||
const hasLoadedFederation = ref(false)
|
||||
const federationError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadFederationSummary() {
|
||||
loadingFederation.value = true
|
||||
federationError.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
nodes: Array<{ status: string }>
|
||||
@@ -78,11 +108,22 @@ onMounted(async () => {
|
||||
nodeCount.value = nodes.length
|
||||
onlineCount.value = nodes.filter(n => n.status === 'online' || n.status === 'connected').length
|
||||
pendingCount.value = (res.pending_requests || []).length
|
||||
} catch { /* unavailable */ }
|
||||
hasLoadedFederation.value = true
|
||||
} catch (e: unknown) {
|
||||
federationError.value = e instanceof Error ? e.message : 'Federation unavailable'
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await rpcClient.getNodeDid()
|
||||
selfDid.value = res.did || ''
|
||||
} catch { /* unavailable */ }
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
if (!federationError.value) federationError.value = e instanceof Error ? e.message : 'Node identity unavailable'
|
||||
} finally {
|
||||
loadingFederation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFederationSummary)
|
||||
|
||||
defineExpose({ loadFederationSummary })
|
||||
</script>
|
||||
|
||||
@@ -14,10 +14,7 @@
|
||||
<p class="text-xs text-white/60">{{ t('web5.identitiesDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="showCreateIdentityModal = true" class="glass-button glass-button-sm px-3 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>
|
||||
<button @click="showCreateIdentityModal = true" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
@@ -32,16 +29,10 @@
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
|
||||
<button @click="showCreateIdentityModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-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>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="identitiesLoading" class="py-6 text-center">
|
||||
<div v-if="identitiesLoading && managedIdentities.length === 0" class="py-6 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" 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>
|
||||
@@ -60,6 +51,13 @@
|
||||
|
||||
<!-- Identity List -->
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="identitiesLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing identities...
|
||||
</div>
|
||||
<div
|
||||
v-for="(identity, idx) in managedIdentities"
|
||||
:key="identity.id"
|
||||
@@ -136,6 +134,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="showCreateIdentityModal = true" class="web5-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Identity Modal -->
|
||||
@@ -501,12 +503,13 @@ const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
|
||||
async function loadIdentities() {
|
||||
const hadIdentities = managedIdentities.value.length > 0
|
||||
identitiesLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
|
||||
managedIdentities.value = res.identities || []
|
||||
} catch {
|
||||
managedIdentities.value = []
|
||||
if (!hadIdentities) managedIdentities.value = []
|
||||
} finally {
|
||||
identitiesLoading.value = false
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@
|
||||
<p class="text-xs text-white/60">System resources & health</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/dashboard/monitoring" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
||||
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2 no-underline">
|
||||
Details
|
||||
<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="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +64,10 @@
|
||||
<span class="text-sm font-medium text-white/60">{{ uptimeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/dashboard/monitoring" class="web5-card-actions-bottom mt-6 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0 no-underline">
|
||||
Details
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div v-if="relaysLoading && hasRelayData" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing relays...
|
||||
</div>
|
||||
<div v-else-if="relaysLoadError && hasRelayData" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
|
||||
{{ relaysLoadError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -99,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { NostrRelayData, NostrRelayStatsData } from './types'
|
||||
@@ -115,8 +126,14 @@ const nostrRelayStats = ref<NostrRelayStatsData | null>(null)
|
||||
const showRelaysModal = ref(false)
|
||||
const newRelayUrl = ref('')
|
||||
const relayError = ref('')
|
||||
const relaysLoading = ref(false)
|
||||
const relaysLoadError = ref('')
|
||||
const hasRelayData = computed(() => nostrRelays.value.length > 0 || nostrRelayStats.value !== null)
|
||||
|
||||
async function loadNostrRelays() {
|
||||
const hadRelayData = hasRelayData.value
|
||||
relaysLoading.value = true
|
||||
relaysLoadError.value = ''
|
||||
try {
|
||||
const [relayRes, statsRes] = await Promise.all([
|
||||
rpcClient.call<{ relays: NostrRelayData[] }>({ method: 'nostr.list-relays' }),
|
||||
@@ -124,9 +141,14 @@ async function loadNostrRelays() {
|
||||
])
|
||||
nostrRelays.value = relayRes.relays || []
|
||||
nostrRelayStats.value = statsRes
|
||||
} catch {
|
||||
nostrRelays.value = []
|
||||
nostrRelayStats.value = null
|
||||
} catch (e: unknown) {
|
||||
relaysLoadError.value = e instanceof Error ? e.message : 'Failed to load relays'
|
||||
if (!hadRelayData) {
|
||||
nostrRelays.value = []
|
||||
nostrRelayStats.value = null
|
||||
}
|
||||
} finally {
|
||||
relaysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||
<p v-if="unifiedReceiveProcessing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -181,6 +182,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -475,6 +477,9 @@ async function unifiedReceive() {
|
||||
receiveInvoiceResult.value = res.payment_request
|
||||
} else if (receiveMethod.value === 'onchain') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
|
||||
if (!res.address) {
|
||||
throw new Error('LND did not return a Bitcoin address')
|
||||
}
|
||||
receiveOnchainAddress.value = res.address
|
||||
nextTick(() => renderQrCode(res.address, onchainQrCanvas.value))
|
||||
} else {
|
||||
@@ -491,7 +496,9 @@ async function unifiedReceive() {
|
||||
emit('balancesChanged')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
unifiedReceiveError.value = err instanceof Error ? err.message : t('web5.receiveFailed')
|
||||
unifiedReceiveError.value = receiveMethod.value === 'onchain'
|
||||
? explainReceiveAddressFailure(err)
|
||||
: err instanceof Error ? err.message : t('web5.receiveFailed')
|
||||
} finally {
|
||||
unifiedReceiveProcessing.value = false
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@
|
||||
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-if="contentLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing shared content...
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, idx) in contentItems"
|
||||
:key="item.id"
|
||||
@@ -168,7 +175,7 @@
|
||||
|
||||
<!-- Browse Peers tab -->
|
||||
<div v-show="contentTab === 'browse'">
|
||||
<div v-if="browsingPeerContent" class="py-4 text-center">
|
||||
<div v-if="browsingPeerContent && peerContentItems.length === 0" class="py-4 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" 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>
|
||||
@@ -186,6 +193,13 @@
|
||||
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="browsingPeerContent" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Refreshing peer content...
|
||||
</div>
|
||||
<div
|
||||
v-for="(pItem, idx) in peerContentItems"
|
||||
:key="pItem.id"
|
||||
@@ -383,6 +397,7 @@ const accessOptions = [
|
||||
|
||||
// Browse peers
|
||||
const browsePeerOnion = ref('')
|
||||
const activeBrowsePeerOnion = ref('')
|
||||
const browsingPeerContent = ref(false)
|
||||
const browsePeerError = ref('')
|
||||
const peerContentItems = ref<PeerContentItem[]>([])
|
||||
@@ -405,7 +420,7 @@ async function loadContentItems() {
|
||||
const res = await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })
|
||||
contentItems.value = res.items || []
|
||||
} catch {
|
||||
contentItems.value = []
|
||||
if (contentItems.value.length === 0) contentItems.value = []
|
||||
} finally {
|
||||
contentLoading.value = false
|
||||
}
|
||||
@@ -501,14 +516,16 @@ async function updateItemPrice(item: ContentItemData, value: string) {
|
||||
|
||||
async function browsePeerContent() {
|
||||
if (!browsePeerOnion.value || browsingPeerContent.value) return
|
||||
const isRefreshingActivePeer = browsePeerOnion.value === activeBrowsePeerOnion.value
|
||||
browsingPeerContent.value = true
|
||||
browsePeerError.value = ''
|
||||
peerContentItems.value = []
|
||||
if (!isRefreshingActivePeer) peerContentItems.value = []
|
||||
try {
|
||||
const res = await rpcClient.call<{ items: PeerContentItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion: browsePeerOnion.value },
|
||||
})
|
||||
activeBrowsePeerOnion.value = browsePeerOnion.value
|
||||
peerContentItems.value = res.items || []
|
||||
} catch (err: unknown) {
|
||||
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const messageState = vi.hoisted(() => ({
|
||||
receivedMessages: { __v_isRef: true, value: [] as Array<{ from_pubkey: string; timestamp: number; message: string }> },
|
||||
loadingMessages: { __v_isRef: true, value: false },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
listPeers: vi.fn(() => new Promise(() => {})),
|
||||
federationListNodes: vi.fn().mockResolvedValue({ nodes: [] }),
|
||||
checkPeerReachable: vi.fn().mockResolvedValue({ reachable: false }),
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMessageToast', () => ({
|
||||
useMessageToast: () => ({
|
||||
receivedMessages: messageState.receivedMessages,
|
||||
loadingMessages: messageState.loadingMessages,
|
||||
unreadCount: { value: 0 },
|
||||
loadReceivedMessages: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/web5Badge', () => ({
|
||||
useWeb5BadgeStore: () => ({ pendingRequestCount: 0 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({ peerHealth: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Web5ConnectedNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
messageState.receivedMessages.value = []
|
||||
messageState.loadingMessages.value = false
|
||||
})
|
||||
|
||||
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
|
||||
wrapper.vm.loadPeers()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
expect(wrapper.text()).not.toContain('web5.noPeers')
|
||||
})
|
||||
|
||||
it('keeps received messages visible during refresh', async () => {
|
||||
messageState.receivedMessages.value = [{
|
||||
from_pubkey: 'peer-pubkey',
|
||||
timestamp: 1760000000,
|
||||
message: 'Existing message',
|
||||
}]
|
||||
messageState.loadingMessages.value = true
|
||||
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
wrapper.vm.nodesContainerTab = 'messages'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Existing message')
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
})
|
||||
|
||||
it('keeps connection requests visible while refresh is pending or fails', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValueOnce({
|
||||
requests: [{
|
||||
id: 'req-1',
|
||||
from_did: 'did:key:alice',
|
||||
from_pubkey: 'alice-pubkey',
|
||||
message: 'Please connect',
|
||||
created_at: '2026-06-10T10:00:00Z',
|
||||
}],
|
||||
})
|
||||
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
await (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
|
||||
vi.mocked(rpcClient.call).mockReturnValueOnce(new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('offline')), 0)
|
||||
}))
|
||||
|
||||
const refresh = (wrapper.vm as unknown as { loadConnectionRequests: () => Promise<void> }).loadConnectionRequests()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
expect(wrapper.text()).toContain('common.loading')
|
||||
|
||||
await refresh
|
||||
|
||||
expect(wrapper.text()).toContain('Please connect')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user