feat(orchestrator): complete container migration and release hardening

This commit is contained in:
archipelago
2026-04-28 15:00:58 -04:00
parent 4d05705315
commit 8f83b37d51
94 changed files with 5034 additions and 1003 deletions

View File

@@ -59,6 +59,72 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe('btcpay-server')
})
it('opens Nginx Proxy Manager in new tab even when URL resolves', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'_blank',
'noopener,noreferrer',
)
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://192.168.1.228/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.168.1.228/app/nginx-proxy-manager/',
'_blank',
'noopener,noreferrer',
)
expect(store.panelAppId).toBe(null)
})
it('normalizes legacy Nginx Proxy Manager port 8181 to 81', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'_blank',
'noopener,noreferrer',
)
})
it('normalizes legacy Uptime Kuma port 3001 to 3002', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3001', title: 'Uptime Kuma' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:3002',
'_blank',
'noopener,noreferrer',
)
expect(store.panelAppId).toBe(null)
expect(store.isOpen).toBe(false)
})
it('opens Uptime Kuma in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://192.168.1.228/app/uptime-kuma/', title: 'Uptime Kuma' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.168.1.228/app/uptime-kuma/',
'_blank',
'noopener,noreferrer',
)
expect(store.panelAppId).toBe(null)
})
it('routes Home Assistant (port 8123) to full-page session', () => {
const store = useAppLauncherStore()
@@ -77,6 +143,29 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBeTruthy()
})
it('opens Gitea path URL in new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228/app/gitea/', title: 'Gitea' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228/app/gitea/',
'_blank',
'noopener,noreferrer',
)
})
it('does not map raw port 3001 to gitea session', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3001', title: 'Unknown 3001' })
expect(store.panelAppId).toBe(null)
expect(store.isOpen).toBe(true)
})
it('opens in new tab when openInNewTab flag is set for unknown URL', () => {
const store = useAppLauncherStore()

View File

@@ -11,11 +11,17 @@ const NEW_TAB_PORTS = new Set([
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
'9001', // Penpot — not reachable
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
])
const NEW_TAB_APP_IDS = new Set([
'nginx-proxy-manager',
'uptime-kuma',
'gitea',
])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -25,11 +31,42 @@ function mustOpenInNewTab(url: string): boolean {
}
}
function inferAppIdFromTitle(title?: string): string | null {
const t = (title || '').toLowerCase()
if (!t) return null
if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma'
if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager'
if (t.includes('gitea')) return 'gitea'
return null
}
function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
try {
const u = new URL(urlStr)
const sameHost = u.hostname === window.location.hostname
const normalizedPath = u.pathname === '/' ? '' : u.pathname
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') {
return rebuilt('3002')
}
if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') {
return rebuilt('81')
}
return urlStr
} catch {
return urlStr
}
}
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'8181': 'nginx-proxy-manager',
'3000': 'grafana',
'3001': 'uptime-kuma',
'3002': 'uptime-kuma',
'8080': 'endurain',
'8081': 'lnd',
'8082': 'vaultwarden',
@@ -115,19 +152,30 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Legacy: open app in iframe overlay (kept for backward compat) */
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const titleHintId = inferAppIdFromTitle(payload.title)
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Force selected apps to open directly in new tab
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
// Route to full-page session if we can resolve an app ID from the URL
const resolvedId = resolveAppIdFromUrl(payload.url)
if (resolvedId) {
openSession(resolvedId)
return
}
// Apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
window.open(payload.url, '_blank', 'noopener,noreferrer')
// Unknown apps that block iframes — open directly in new tab
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
previousActiveElement = (document.activeElement as HTMLElement) || null
url.value = payload.url
url.value = launchUrl
title.value = payload.title
isOpen.value = true
}
@@ -136,6 +184,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function resolveAppIdFromUrl(urlStr: string): string | null {
try {
const u = new URL(urlStr)
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
if (m?.[1]) return m[1].toLowerCase()
// Check port-based apps
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId

View File

@@ -160,7 +160,7 @@ import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import {
isServiceContainer, isWebOnlyApp, getAppCategory,
filterEntriesForTab, isWebOnlyApp,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
@@ -244,14 +244,7 @@ onBeforeUnmount(() => {
// Sorted entries: web-only first, then alphabetical by title
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
const filtered = entries.filter(([id, pkg]) => {
const isSvc = isServiceContainer(id)
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
return getAppCategory(id, pkg) === selectedCategory.value
}
return true
})
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

View File

@@ -41,6 +41,7 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
immich: 'immich',
filebrowser: 'filebrowser',
'nginx-proxy-manager': 'nginx-proxy-manager',
'gitea': 'gitea',
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
@@ -85,8 +86,9 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },

View File

@@ -31,15 +31,15 @@ export const APP_PORTS: Record<string, number> = {
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 8181,
'nginx-proxy-manager': 81,
'gitea': 3001,
'portainer': 9000,
'uptime-kuma': 3001,
'uptime-kuma': 3002,
'fedimint': 8175,
'fedimintd': 8175,
'fedimint-gateway': 8176,
'indeedhub': 7778,
'botfights': 9100,
'gitea': 3000,
'dwn': 3100,
'endurain': 8080,
}
@@ -47,7 +47,11 @@ export const APP_PORTS: Record<string, number> = {
/** Apps that need nginx proxy for iframe embedding.
* IndeedHub loads via /app/indeedhub/ proxy for nostr-provider.js injection
* from the container's internal nginx so iframe works on all servers. */
export const PROXY_APPS: Record<string, string> = {}
export const PROXY_APPS: Record<string, string> = {
'gitea': '/app/gitea/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
}
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
@@ -121,42 +125,24 @@ export const NEW_TAB_APPS = new Set([
'portainer',
'onlyoffice',
'nginx-proxy-manager',
'gitea',
'tailscale',
])
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve the app URL given its ID and current route query */
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string): string {
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
const proxyPath = PROXY_APPS[id]
if (proxyPath) return `${window.location.origin}${proxyPath}`
// IndeedHub: direct port access (nostr-provider.js baked into container image)
if (id === 'indeedhub') {
const port = APP_PORTS[id]
if (port) {
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
if (routeQueryPath) base += routeQueryPath
return base
}
}
// HTTPS: use nginx proxy to avoid mixed content
if (window.location.protocol === 'https:') {
const httpsProxy = HTTPS_PROXY_PATHS[id]
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
}
// HTTP: direct port access
// Local apps: always launch by host port
const port = APP_PORTS[id]
if (!port) return ''
let base = `http://${window.location.hostname}:${port}`
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
if (routeQueryPath) base += routeQueryPath
return base
}

View File

@@ -207,7 +207,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PackageDataEntry } from '@/types/api'
import {
isWebOnlyApp, opensInTab, canLaunch,
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
@@ -256,10 +256,7 @@ 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 icon = computed(() => resolveAppIcon(props.id, props.pkg, curated.value?.icon))
const version = computed(() => {
const v = props.pkg.manifest?.version
return v || curated.value?.version || ''

View File

@@ -78,7 +78,7 @@ import { computed, ref } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import { canLaunch, handleImageError } from './appsConfig'
import { canLaunch, handleImageError, resolveAppIcon } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
@@ -114,8 +114,7 @@ function getTitle(id: string, pkg: PackageDataEntry): string {
}
function getIcon(id: string, pkg: PackageDataEntry): string {
const i = pkg['static-files']?.icon
return i || curatedMap.get(id)?.icon || `/assets/img/app-icons/${id}.png`
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
}
function handleTap(id: string, pkg: PackageDataEntry) {

View File

@@ -0,0 +1,84 @@
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'
function makePkg(id: string, title: string, category: 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,
category,
} as unknown as PackageDataEntry['manifest'],
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('appsConfig service filtering', () => {
it('treats bitcoin stack UI sidecars as services', () => {
expect(isServiceContainer('bitcoin-ui')).toBe(true)
expect(isServiceContainer('lnd-ui')).toBe(true)
expect(isServiceContainer('electrs-ui')).toBe(true)
})
it('treats container aliases as services even with non-service keys', () => {
const aliasPkg = makePkg('bitcoin-ui', 'Bitcoin UI', 'money')
expect(isServicePackage('core-lnd-ui', aliasPkg)).toBe(true)
})
it('removes service-only categories from app category tabs', () => {
const packages = ref<Record<string, PackageDataEntry>>({
'core-bitcoin-ui': makePkg('bitcoin-ui', 'Bitcoin UI', 'money'),
'filebrowser': makePkg('filebrowser', 'File Browser', 'data'),
})
const allCategories = ref([
{ id: 'all', name: 'All' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
])
const visible = useCategoriesWithApps(packages, allCategories)
expect(visible.value.map(c => c.id)).toEqual(['all', 'data'])
})
it('filters apps tab by category using manifest-aware service checks', () => {
const entries: Array<[string, PackageDataEntry]> = [
['core-bitcoin-ui', makePkg('bitcoin-ui', 'Bitcoin UI', 'money')],
['filebrowser', makePkg('filebrowser', 'File Browser', 'data')],
['btcpay-server', makePkg('btcpay-server', 'BTCPay', 'commerce')],
]
const appsAll = filterEntriesForTab(entries, 'apps', 'all')
expect(appsAll.map(([id]) => id)).toEqual(['filebrowser', 'btcpay-server'])
const appsData = filterEntriesForTab(entries, 'apps', 'data')
expect(appsData.map(([id]) => id)).toEqual(['filebrowser'])
})
it('routes service aliases into services tab and excludes user apps', () => {
const entries: Array<[string, PackageDataEntry]> = [
['core-lnd-ui', makePkg('lnd-ui', 'LND UI', 'money')],
['grafana', makePkg('grafana', 'Grafana', 'data')],
]
const services = filterEntriesForTab(entries, 'services', 'all')
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
})
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.png')
})
})

View File

@@ -10,6 +10,7 @@ export const SERVICE_NAMES = new Set([
'immich_postgres', 'immich_redis',
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
@@ -28,6 +29,12 @@ export function isServiceContainer(id: string): boolean {
return false
}
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isServiceContainer(id)) return true
const manifestId = pkg?.manifest?.id
return !!manifestId && isServiceContainer(manifestId)
}
// 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',
@@ -50,6 +57,21 @@ export function getAppCategory(id: string, pkg: PackageDataEntry): string {
return cat || 'other'
}
export function filterEntriesForTab(
entries: Array<[string, PackageDataEntry]>,
activeTab: 'apps' | 'services',
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
return entries.filter(([id, pkg]) => {
const isSvc = isServicePackage(id, pkg)
if (activeTab === 'services' ? !isSvc : isSvc) return false
if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory
}
return true
})
}
// Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
@@ -95,7 +117,7 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
/** 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',
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
'cryptpad', 'nginx-proxy-manager', 'tailscale',
])
@@ -103,6 +125,21 @@ export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id)
}
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const icon = (pkg["static-files"]?.icon || "").trim()
if (
icon.startsWith("/") ||
icon.startsWith("http://") ||
icon.startsWith("https://") ||
icon.startsWith("data:image")
) {
return icon
}
return curatedIcon || `/assets/img/app-icons/${id}.png`
}
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
@@ -171,7 +208,7 @@ export function useCategoriesWithApps(
allCategories: Ref<Array<{ id: string; name: string }>>,
) {
return computed(() => {
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg))
return allCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
@@ -182,6 +219,13 @@ export function useCategoriesWithApps(
export function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
const currentSrc = target.src
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
target.dataset.fallbackTried = "1"
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
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)"/>
@@ -189,7 +233,7 @@ export function handleImageError(e: Event) {
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
</svg>
`)}`
if (!currentSrc.includes('data:image')) {
if (!currentSrc.includes("data:image")) {
target.src = placeholderSvg
}
}