archy/neode-ui/src/views/apps/AppCard.vue
Dorian 987158ef5f
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Has been cancelled
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

291 lines
14 KiB
Vue

<template>
<div
data-controller-container
:data-controller-launch="canLaunch(pkg) ? '' : undefined"
tabindex="0"
role="link"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="$emit('goToApp', id)"
@keydown.enter="handleEnter"
>
<!-- Installing indicator no overlay, just replaces action buttons at bottom -->
<!-- Uninstalling handled in button area below, no overlay -->
<!-- Uninstall Icon (not for web-only apps) -->
<button
v-if="!isWebOnly && !isUninstalling && !isInstalling && pkg.state !== 'installing'"
@click.stop="$emit('showUninstall', id, pkg)"
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
:title="t('common.uninstall')"
>
<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="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 class="flex items-start gap-4">
<img
:src="icon"
:alt="title"
class="w-14 h-14 rounded-lg object-cover bg-white/10"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
<div class="flex items-center gap-2 mb-0.5">
<h3 class="text-lg font-semibold text-white truncate" :title="title">
{{ title }}
</h3>
<span
v-if="tier && tier !== 'optional'"
class="tier-badge"
:class="tier === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tier }}</span>
<span
v-if="pkg['available-update']"
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
>Update</span>
</div>
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
</div>
</div>
<p class="text-white/70 text-sm mt-3 mb-3 line-clamp-2 min-h-[2.5rem]">
{{ description }}
</p>
<div v-if="!isInstalling && !isUninstalling && pkg.state !== 'installing'" class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
>
<svg
v-if="isTransitioning"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
</span>
</div>
<!-- Quick Actions icon buttons in uniform dark containers -->
<!-- Installing progress replaces action buttons -->
<div v-if="isInstalling || pkg.state === 'installing'" class="mt-4">
<div class="flex items-center justify-between mb-1.5">
<span class="text-xs text-white/70 flex items-center gap-1.5">
<svg class="animate-spin h-3 w-3" 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>
{{ installProgress?.message || 'Installing...' }}
</span>
<span class="text-xs text-white/50">{{ Math.round(installProgress?.progress || 0) }}%</span>
</div>
<div class="w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
class="h-full bg-white/60 rounded-full transition-all duration-500"
:style="{ width: `${Math.max(installProgress?.progress || 2, 2)}%` }"
></div>
</div>
</div>
<!-- Uninstalling progress live stage label from backend -->
<div v-else-if="isUninstalling" class="mt-4">
<div class="flex items-center gap-1.5">
<svg class="animate-spin h-3 w-3 text-red-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-xs text-red-300 truncate">{{ uninstallStageLabel }}</span>
</div>
<div class="mt-1.5 w-full h-1.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-red-400/60 rounded-full animate-pulse w-full"></div>
</div>
</div>
<div v-else class="mt-4 flex gap-2">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click.stop="$emit('update', id)"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
:title="`Update to v${pkg['available-update']}`"
>
<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.update') }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200"
>
<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>
{{ t('common.updating') }}
</span>
<!-- Launch -->
<button
v-if="canLaunch(pkg)"
data-controller-launch-btn
@click.stop="$emit('launch', id)"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium flex items-center justify-center gap-1.5"
>
{{ t('common.launch') }}
<svg v-if="opensInTab(id)" class="w-3.5 h-3.5 opacity-60" 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>
</button>
<!-- Start (play icon) -->
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="$emit('start', id)"
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="pkg.state === 'exited' ? 'Restart' : t('common.start')"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
</button>
<!-- Starting (spinner) -->
<button
v-if="!isWebOnly && isLoading && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
>
<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>
</button>
<!-- Stop (square icon) -->
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="$emit('stop', id)"
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.stop')"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1" /></svg>
</button>
<!-- Restart -->
<button
v-if="!isWebOnly && !isLoading && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="$emit('restart', id)"
class="px-3 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.restart')"
>
<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>
</button>
<!-- Stopping (spinner) -->
<button
v-if="!isWebOnly && isLoading && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled
class="px-3 py-2 glass-button glass-button-sm rounded-lg opacity-50 cursor-not-allowed flex items-center justify-center"
>
<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>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PackageDataEntry } from '@/types/api'
import {
isWebOnlyApp, opensInTab, canLaunch,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const { t } = useI18n()
// Build a lookup map for enriching sparse backend data during install
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
const props = defineProps<{
id: string
pkg: PackageDataEntry
index: number
showStagger: boolean
isLoading: boolean
isInstalling?: boolean
installProgress?: { status: string; progress: number; message: string }
isUninstalling: boolean
}>()
const emit = defineEmits<{
goToApp: [id: string]
launch: [id: string]
start: [id: string]
stop: [id: string]
restart: [id: string]
update: [id: string]
showUninstall: [id: string, pkg: PackageDataEntry]
}>()
function handleEnter(e: KeyboardEvent) {
// Controller nav already handled this Enter (preventDefault was called) — skip to avoid double navigation
if (e.defaultPrevented) return
emit('goToApp', props.id)
}
const isWebOnly = computed(() => isWebOnlyApp(props.id))
// Enrich from marketplace when backend data is sparse (e.g. during install)
const curated = computed(() => curatedMap.get(props.id))
const title = computed(() => {
const t = props.pkg.manifest?.title
return (t && t !== props.id) ? t : (curated.value?.title || t || props.id)
})
const description = computed(() => {
const d = props.pkg.manifest?.description?.short
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
})
const icon = computed(() => {
const i = props.pkg['static-files']?.icon
return i || curated.value?.icon || `/assets/img/app-icons/${props.id}.png`
})
const version = computed(() => {
const v = props.pkg.manifest?.version
return v || curated.value?.version || ''
})
const author = computed(() => props.pkg.manifest?.author || curated.value?.author || '')
const tier = computed(() => {
const t = props.pkg.manifest?.tier
if (t && t !== '') return t
const core = ['bitcoin-knots', 'bitcoin', 'lnd', 'mempool', 'btcpay-server', 'dwn', 'filebrowser']
const recommended = ['fedimint', 'thunderhub', 'vaultwarden', 'uptime-kuma', 'grafana', 'searxng', 'tailscale', 'portainer']
if (core.includes(props.id)) return 'core'
if (recommended.includes(props.id)) return 'recommended'
return 'optional'
})
// Live uninstall stage from backend, with a sensible fallback so the
// label is never blank between WS pushes.
const uninstallStageLabel = computed(() => {
const raw = props.pkg['uninstall-stage']
return raw ? raw : `${t('common.uninstalling')}`
})
const isTransitioning = computed(() => {
const s = props.pkg.state
const h = props.pkg.health
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
})
</script>