Container recovery: - Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s - Dependency-aware restarts: won't restart services before their deps - Reset dependent counters when a dependency recovers - Handle "created" state containers (were invisible to health monitor) - Added IndeedHub, mempool-api, mysql to tier system - Crash recovery: podman start timeout 30s→120s with retry - Podman client: socket timeout 5s→30s, added restart policy UI state representation: - Exit code 0 shows "stopped" (gray), not "crashed" (red) - Exit code 137 shows "killed (OOM)" - Non-zero exit shows "crashed" (red) - Added exit_code field to PackageDataEntry Install/uninstall fixes: - Install returns error when container doesn't start (was silent success) - Post-install hooks awaited instead of fire-and-forget tokio::spawn - Uninstall: graceful rm before force, volume prune, network cleanup - Uninstall returns error on partial failure (was 200 OK) Config consistency: - DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded) - Bitcoin: added ZMQ ports 28332/28333 for LND block notifications - IndeedHub port 7777→8190 (was conflicting with strfry) - Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0 Performance: - Metrics collector interval 60s→300s (was duplicating health monitor) - Podman client: proper error propagation instead of unwrap_or_default Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
9.7 KiB
TypeScript
198 lines
9.7 KiB
TypeScript
/** Static configuration for the Apps view */
|
|
|
|
import type { Ref } from 'vue'
|
|
import { computed } from 'vue'
|
|
import { PackageState, type PackageDataEntry } from '@/types/api'
|
|
|
|
// Service container name patterns (backend/infra, not user-facing)
|
|
export const SERVICE_NAMES = new Set([
|
|
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
|
'immich_postgres', 'immich_redis',
|
|
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
|
|
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
|
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
|
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
|
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
|
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
|
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
|
// L484 web-only apps — parked in Services for now
|
|
'botfights', 'nwnn', '484-kitchen', 'call-the-operator',
|
|
'syntropy-institute', 't-zero', 'arch-presentation',
|
|
])
|
|
|
|
export function isServiceContainer(id: string): boolean {
|
|
if (SERVICE_NAMES.has(id)) return true
|
|
if (id.startsWith('indeedhub-build_')) return true
|
|
if (id.startsWith('archy-')) return true
|
|
if (id.endsWith('_db') || id.endsWith('-db')) return true
|
|
return false
|
|
}
|
|
|
|
// 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',
|
|
'fedimint': 'money', 'fedimint-gateway': 'money',
|
|
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
|
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'onlyoffice': 'data',
|
|
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
|
|
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
|
|
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
|
|
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
|
|
'uptime-kuma': 'networking', 'dwn': 'data',
|
|
'botfights': 'l484', 'nwnn': 'l484', '484-kitchen': 'l484',
|
|
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
|
|
}
|
|
|
|
export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
|
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
|
|
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
|
|
return cat || 'other'
|
|
}
|
|
|
|
// Web-only app IDs and their URLs
|
|
export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
|
'botfights': 'https://botfights.net',
|
|
'nwnn': 'https://nwnn.l484.com',
|
|
'484-kitchen': 'https://484.kitchen',
|
|
'call-the-operator': 'https://cta.tx1138.com',
|
|
'syntropy-institute': 'https://syntropy.institute',
|
|
't-zero': 'https://teeminuszero.net',
|
|
}
|
|
|
|
export function isWebOnlyApp(id: string): boolean {
|
|
return id in WEB_ONLY_APP_URLS
|
|
}
|
|
|
|
// Web-only apps (no container) -- always show as installed bookmarks
|
|
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
|
'botfights': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/botfights.svg' },
|
|
},
|
|
'nwnn': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
|
|
},
|
|
'484-kitchen': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
|
|
},
|
|
'call-the-operator': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
|
},
|
|
'syntropy-institute': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
|
|
},
|
|
't-zero': {
|
|
state: 'running' as PackageState,
|
|
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
|
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
|
|
},
|
|
}
|
|
|
|
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
|
|
export const TAB_LAUNCH_APPS = new Set([
|
|
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
|
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer',
|
|
'onlyoffice', 'nginx-proxy-manager', 'tailscale',
|
|
])
|
|
|
|
export function opensInTab(id: string): boolean {
|
|
return TAB_LAUNCH_APPS.has(id)
|
|
}
|
|
|
|
export function canLaunch(pkg: PackageDataEntry): boolean {
|
|
if (isWebOnlyApp(pkg.manifest.id)) return true
|
|
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
|
const canLaunchState = pkg.state === 'running' || pkg.state === 'starting'
|
|
return !!hasUI && canLaunchState
|
|
}
|
|
|
|
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
|
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
|
|
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
|
|
switch (state) {
|
|
case PackageState.Running:
|
|
return 'bg-green-500/20 text-green-200'
|
|
case PackageState.Stopped:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
case PackageState.Exited:
|
|
// Exit code 0 = clean shutdown (gray), non-zero = crash (red)
|
|
return exitCode != null && exitCode !== 0
|
|
? 'bg-red-500/20 text-red-200'
|
|
: 'bg-gray-500/20 text-gray-200'
|
|
case PackageState.Starting:
|
|
case PackageState.Stopping:
|
|
case PackageState.Restarting:
|
|
return 'bg-yellow-500/20 text-yellow-200'
|
|
case PackageState.Installing:
|
|
return 'bg-blue-500/20 text-blue-200'
|
|
default:
|
|
return 'bg-gray-500/20 text-gray-200'
|
|
}
|
|
}
|
|
|
|
export function getStatusLabel(state: PackageState, health?: string | null, exitCode?: number | null): string {
|
|
if (state === PackageState.Running && health === 'starting') return 'starting up'
|
|
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
|
|
if (state === PackageState.Running && health === 'healthy') return 'healthy'
|
|
if (state === PackageState.Exited) {
|
|
if (exitCode === 137) return 'killed (OOM)'
|
|
if (exitCode != null && exitCode !== 0) return 'crashed'
|
|
return 'stopped'
|
|
}
|
|
return state
|
|
}
|
|
|
|
export function buildAllCategories(t: (key: string) => string) {
|
|
return [
|
|
{ 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: 'media', name: 'Media' },
|
|
{ id: 'home', name: t('marketplace.homeCategory') },
|
|
{ id: 'networking', name: t('marketplace.networking') },
|
|
{ id: 'l484', name: 'L484' },
|
|
{ id: 'other', name: t('marketplace.other') },
|
|
]
|
|
}
|
|
|
|
export function useCategoriesWithApps(
|
|
packages: Ref<Record<string, PackageDataEntry>>,
|
|
allCategories: Ref<Array<{ id: string; name: string }>>,
|
|
) {
|
|
return computed(() => {
|
|
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
|
|
return allCategories.value.filter(cat => {
|
|
if (cat.id === 'all') return true
|
|
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
|
})
|
|
})
|
|
}
|
|
|
|
export function handleImageError(e: Event) {
|
|
const target = e.target as HTMLImageElement
|
|
const currentSrc = target.src
|
|
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
|
|
}
|
|
}
|