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
@@ -12,7 +12,7 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/router', () => ({
default: { push: mockPush },
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -66,7 +66,7 @@ describe('useAppLauncherStore', () => {
store.openSession('indeedhub')
expect(store.panelAppId).toBe(null)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
})
it('normalizes localhost launch URLs to current host before resolving', () => {
@@ -95,7 +95,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('btcpay-server')
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:23000',
'_blank',
'noopener,noreferrer',
)
})
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
@@ -125,7 +130,7 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
@@ -186,7 +191,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBeTruthy()
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8123',
'_blank',
'noopener,noreferrer',
)
})
it('routes Grafana (port 3000) to full-page session', () => {
@@ -195,7 +205,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBeTruthy()
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:3000',
'_blank',
'noopener,noreferrer',
)
})
it('opens Gitea path URL in new tab', () => {
@@ -261,7 +276,7 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } })
})
it('routes HTTPS same-host apps via session view', () => {
+9 -5
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
@@ -102,8 +103,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9010': 'saleor',
'9011': 'saleor',
'8087': 'netbird',
'8086': 'netbird',
'9980': 'onlyoffice',
@@ -186,21 +185,24 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function dashboardReturnPath(): string {
const current = router.currentRoute.value
const current = router.currentRoute?.value
if (!current) return '/dashboard/apps'
const fullPath = current.fullPath || '/dashboard/apps'
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
return fullPath
}
function openSession(appId: string) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl) {
if (launchUrl && !mobile) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel' && !isMobileViewport()) {
if (mode === 'panel' && !mobile) {
panelAppId.value = appId
} else {
panelAppId.value = null
@@ -219,6 +221,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
@@ -227,6 +230,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phones, route through the app session/webview so app icons behave like
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
+12 -3
View File
@@ -27,7 +27,7 @@ const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; stat
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Waiting for container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
'done': { progress: 100, message: 'Installed', status: 'complete' },
}
@@ -126,6 +126,12 @@ export const useServerStore = defineStore('server', () => {
installingApps.value.delete(appId)
}
}
if ((pkg.state as string) === 'removing') {
uninstallingApps.value.add(appId)
} else if (uninstallingApps.value.has(appId)) {
uninstallingApps.value.delete(appId)
}
}
// Clear installingApps entries for apps that vanished from backend data
// Only clean up entries that have errored — active installs may take minutes to pull images
@@ -183,8 +189,11 @@ export const useServerStore = defineStore('server', () => {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
return rpcClient.uninstallPackage(id)
async function uninstallPackage(
id: string,
options: { preserveData?: boolean } = {},
): Promise<{ status: string; package_id: string }> {
return rpcClient.uninstallPackage(id, options)
}
async function startPackage(id: string): Promise<void> {