frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
+44 -62
View File
@@ -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
}
}
+1 -1
View File
@@ -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">
+10 -3
View File
@@ -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
View File
@@ -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;
+15 -1
View File
@@ -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>
+28 -7
View File
@@ -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>
+10 -1
View File
@@ -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>
+2 -2
View File
@@ -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'
+67 -41
View File
@@ -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) {
+14 -5
View File
@@ -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)
})
+46 -7
View File
@@ -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"
+23 -9
View File
@@ -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
View File
@@ -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(() => {
+135 -58
View File
@@ -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>
+29 -11
View File
@@ -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 [
+14 -8
View File
@@ -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>
+1 -17
View File
@@ -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>
+24 -9
View File
@@ -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>
+61 -41
View File
@@ -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>
+140 -192
View File
@@ -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>
+80 -24
View File
@@ -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 ''
+17
View File
@@ -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
+6 -2
View File
@@ -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>
+76 -8
View File
@@ -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 -3
View File
@@ -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>
+19 -5
View File
@@ -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()
})
})
+25
View File
@@ -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,
}
}
+20 -10
View File
@@ -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
}
+2 -2
View File
@@ -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) {
+13
View File
@@ -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')
})
})
+62 -9
View File
@@ -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>
+1 -1
View File
@@ -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">
+2 -4
View File
@@ -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>
+1 -1
View File
@@ -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...')
})
})
+4 -1
View File
@@ -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([])
})
})
+66 -31
View File
@@ -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
}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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'
},
+13 -4
View File
@@ -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; }
+2 -2
View File
@@ -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>
+19 -10
View File
@@ -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 -->
+21 -5
View File
@@ -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...')
})
})
+118 -34
View File
@@ -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
}
}
+11 -3
View File
@@ -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>
+21 -2
View File
@@ -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
}
}
+51 -10
View File
@@ -13,15 +13,31 @@
<p class="text-xs text-white/60">Federated nodes &amp; 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>
+15 -12
View File
@@ -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
}
+5 -4
View File
@@ -13,11 +13,8 @@
<p class="text-xs text-white/60">System resources &amp; 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>
+26 -4
View File
@@ -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
}
+20 -3
View File
@@ -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