archy/neode-ui/src/views/Apps.vue

299 lines
12 KiB
Vue
Raw Normal View History

2026-01-24 22:59:20 +00:00
<template>
<div class="pb-6">
<!-- Nav header -- tabs + categories + search -->
<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">
<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 === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
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"
/>
</div>
2026-01-24 22:59:20 +00:00
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
</div>
<!-- Loading Skeleton -->
2026-05-06 09:23:57 -04:00
<div v-if="isLoadingApps" 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-lg font-semibold text-white mb-2">Loading apps</h3>
<p class="text-white/60 text-sm">Checking the latest app status before showing launch controls.</p>
</div>
</div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
2026-01-24 22:59:20 +00:00
<div class="glass-card p-12 max-w-md mx-auto">
<svg class="w-16 h-16 mx-auto text-white/40 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">{{ t('apps.noAppsTitle') }}</h3>
<p class="text-white/70 mb-6">{{ t('apps.noAppsMessage') }}</p>
2026-01-24 22:59:20 +00:00
<RouterLink
to="/dashboard/marketplace"
class="inline-block glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 hover:border-white/30"
>
{{ t('apps.browseAppStore') }}
2026-01-24 22:59:20 +00:00
</RouterLink>
</div>
</div>
<!-- No Results -->
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
</div>
<!-- Mobile: iPhone-style icon grid -->
<div class="md:hidden">
<AppIconGrid
:apps="filteredPackageEntries as [string, PackageDataEntry][]"
@go-to-app="goToApp"
/>
</div>
<!-- Desktop: Card grid -->
<div class="hidden md:grid grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<AppCard
v-for="([id, pkg], index) in filteredPackageEntries"
2026-01-24 22:59:20 +00:00
:key="id"
:id="id as string"
:pkg="pkg"
:index="index"
:show-stagger="showStagger"
:is-loading="!!actions.loadingActions.value[id as string]"
:is-installing="serverStore.isInstalling(id as string)"
:install-progress="serverStore.installingApps.get(id as string)"
:is-uninstalling="actions.uninstallingApps.has(id as string)"
@go-to-app="goToApp"
@launch="launchApp"
@start="actions.startApp"
@stop="actions.stopApp"
@restart="actions.restartApp"
release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-<pubkey16>`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
@update="updateApp"
@show-uninstall="showUninstallModal"
/>
2026-01-24 22:59:20 +00:00
</div>
<AppsUninstallModal
:show="uninstallModal.show"
:app-title="uninstallModal.appTitle"
:uninstalling="actions.uninstalling.value"
@close="closeUninstallModal"
@confirm="onConfirmUninstall"
/>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actions.actionError.value }}</span>
<button @click="actions.actionError.value = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
2026-01-24 22:59:20 +00:00
</div>
</template>
<script lang="ts">
// Module-level -- persists across component unmount/remount within same session
let appsAnimationDone = false
</script>
2026-01-24 22:59:20 +00:00
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { type PackageDataEntry, type PackageState } from '@/types/api'
import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import {
filterEntriesForTab, isWebOnlyApp,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
2026-01-24 22:59:20 +00:00
const { t } = useI18n()
2026-01-24 22:59:20 +00:00
const router = useRouter()
const route = useRoute()
2026-01-24 22:59:20 +00:00
const store = useAppStore()
const serverStore = useServerStore()
const actions = useAppsActions()
2026-01-24 22:59:20 +00:00
// Only stagger-animate on first mount
const showStagger = !appsAnimationDone
// Tabs
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
)
// Search (debounced)
const searchQuery = ref('')
const debouncedSearchQuery = ref('')
let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined
watch(searchQuery, (val) => {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { debouncedSearchQuery.value = val }, 150)
})
onBeforeUnmount(() => { clearTimeout(searchDebounceTimer) })
// Category filter
const selectedCategory = ref('all')
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
// Merge real packages from store with web-only app bookmarks + installing placeholders
const packages = computed(() => {
const realPackages = store.packages || {}
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
// Inject placeholder entries for apps being installed that aren't in backend data yet
for (const [appId, progress] of serverStore.installingApps) {
if (!merged[appId]) {
merged[appId] = {
state: 'installing' as PackageState,
manifest: {
id: appId,
title: progress.title,
version: '',
description: { short: '', long: '' },
'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '',
'support-site': '', 'marketing-site': '', 'donation-url': null,
},
'static-files': { license: '', instructions: '', icon: '' },
}
}
}
return merged
})
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
2026-05-06 09:23:57 -04:00
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
// Connection error state
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
appsAnimationDone = true
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
2026-05-06 09:23:57 -04:00
if (!store.hasLoadedInitialData && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
onBeforeUnmount(() => {
if (connectionTimer) clearTimeout(connectionTimer)
2026-01-24 22:59:20 +00:00
})
// Sorted entries: web-only first, then alphabetical by title
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
const filtered = filterEntriesForTab(entries, activeTab.value, selectedCategory.value)
return filtered.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1
const bWeb = isWebOnlyApp(idB) ? 0 : 1
if (aWeb !== bWeb) return aWeb - bWeb
return (a.manifest?.title ?? '').localeCompare(b.manifest?.title ?? '', undefined, { sensitivity: 'base' })
})
})
const filteredPackageEntries = computed(() => {
if (!debouncedSearchQuery.value) return sortedPackageEntries.value
const q = debouncedSearchQuery.value.toLowerCase()
return sortedPackageEntries.value.filter(([id, pkg]) =>
(pkg.manifest?.title ?? '').toLowerCase().includes(q) ||
(pkg.manifest?.description?.short ?? '').toLowerCase().includes(q) ||
id.toLowerCase().includes(q)
)
})
// Uninstall modal
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
function showUninstallModal(id: string, pkg: PackageDataEntry) {
uninstallModal.value = { show: true, appId: id, appTitle: pkg.manifest.title }
2026-01-24 22:59:20 +00:00
}
function closeUninstallModal() {
uninstallModal.value.show = false
2026-01-24 22:59:20 +00:00
}
async function onConfirmUninstall() {
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)
}
2026-01-24 22:59:20 +00:00
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
2026-01-24 22:59:20 +00:00
}
function launchApp(id: string) {
useAppLauncherStore().openSession(id)
2026-01-24 22:59:20 +00:00
}
release(v1.7.35-alpha): rootless-netns self-heal + app update button + bitcoin-core 28.4 + Node DID unification - core/archipelago/src/bootstrap.rs (NEW): embed scripts/container-doctor.sh and image-recipe/configs/archipelago-doctor.{service,timer} via include_str! and sync to disk + enable the timer on every archipelago startup. Idempotent (content-hash compare), dev-box symlink guard keeps the git checkout untouched, best-effort (warn-only on failure) so bootstrap never blocks server readiness. Wired in main.rs as a background tokio task. - scripts/container-doctor.sh: add fix_rootless_netns_egress(). Detects when the rootless-netns has lost its pasta tap (container-to-container still works but outbound DNS/TCP fails) via an nsenter probe into aardvark-dns; with a two-probe 10s debounce to rule out transients and a host-precheck that bails out if the host itself is offline. When the rootless-netns is truly broken, does a graceful podman stop --all / start --all so pasta + aardvark-dns rebuild the netns from scratch. Bitcoin-knots and every other outbound container recover in one cycle. - core/archipelago/src/update.rs: host_sudo → pub(crate) so bootstrap.rs can reuse the existing systemd-run escape hatch. - apps/bitcoin-core/manifest.yml: bump app version 24.0.0 → 28.4.0 and image bitcoin/bitcoin:24.0 → bitcoin/bitcoin:28.4. Resources aligned with the real container-specs.sh large-disk tune (4 GiB memory cap, cpu_limit: 0 so bitcoind can run -par=auto across every core). - neode-ui/src/views/apps/AppCard.vue + Apps.vue: add an Update button + Updating spinner to every app card that has available-update set. Wires through serverStore.updatePackage(id) — the same RPC the detail view already calls. common.update / common.updating i18n keys added in en.json and es.json. - core/archipelago/src/identity_manager.rs: add create_from_signing_key() that mirrors an existing Ed25519 key as a manager-level identity with a deterministic id (`node-<pubkey16>`). Idempotent across restarts, gets the hex-SVG master avatar. - core/archipelago/src/server.rs: the auto-create path on first boot now mirrors the node's own signing_key (seed-derived on onboarded installs) as a "Node" identity instead of generating a random "Default" keypair. Once this ships, the DID on the Web5 DID Status card (via node.did RPC), the Node entry on the Identities page (via identity.list), and the DID used for peer-to-peer connects (via server_info.pubkey) all resolve to the same seed-derived pubkey. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 08:29:56 -04:00
async function updateApp(id: string) {
try {
await serverStore.updatePackage(id)
} catch (err) {
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
}
}
2026-01-24 22:59:20 +00:00
</script>