security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d1b48388fb
commit
1a74a930f7
+78
-33
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
|
||||
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
|
||||
@@ -56,6 +56,19 @@
|
||||
</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">
|
||||
<div class="glass-card p-12 max-w-md mx-auto">
|
||||
@@ -136,10 +149,10 @@
|
||||
<div 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)"
|
||||
:class="getStatusClass(pkg.state, pkg.health)"
|
||||
>
|
||||
<svg
|
||||
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
|
||||
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting' || (pkg.state === 'running' && pkg.health === 'starting')"
|
||||
class="animate-spin h-3 w-3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -148,7 +161,8 @@
|
||||
<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>
|
||||
{{ pkg.state }}
|
||||
<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) }}
|
||||
</span>
|
||||
<span class="text-xs text-white/50">
|
||||
v{{ pkg.manifest.version }}
|
||||
@@ -171,14 +185,14 @@
|
||||
<button
|
||||
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||
@click.stop="startApp(id as string)"
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>{{ t('common.start') }}</span>
|
||||
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||||
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-4 w-4" aria-hidden="true" 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>
|
||||
@@ -193,6 +207,16 @@
|
||||
>
|
||||
<span>{{ t('common.stop') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||
@click.stop="restartApp(id as string)"
|
||||
class="px-2.5 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>
|
||||
<button
|
||||
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||||
disabled
|
||||
@@ -249,7 +273,7 @@
|
||||
<button
|
||||
@click="confirmUninstall"
|
||||
:disabled="uninstalling"
|
||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
v-if="uninstalling"
|
||||
@@ -274,7 +298,7 @@
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" 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="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
|
||||
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
|
||||
<span>{{ actionError }}</span>
|
||||
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">×</button>
|
||||
</div>
|
||||
@@ -284,7 +308,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@@ -377,6 +401,20 @@ const categoriesWithApps = computed(() => {
|
||||
})
|
||||
|
||||
|
||||
// Connection error state — show after timeout if backend never connects
|
||||
const connectionError = ref('')
|
||||
let connectionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (!store.isConnected) {
|
||||
connectionTimer = setTimeout(() => {
|
||||
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
|
||||
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
})
|
||||
|
||||
// Track loading states for each app action
|
||||
const loadingActions = ref<Record<string, boolean>>({})
|
||||
|
||||
@@ -523,12 +561,16 @@ function launchApp(id: string) {
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
function getStatusClass(state: PackageState): string {
|
||||
function getStatusClass(state: PackageState, health?: string | 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:
|
||||
return 'bg-red-500/20 text-red-200'
|
||||
case PackageState.Starting:
|
||||
case PackageState.Stopping:
|
||||
case PackageState.Restarting:
|
||||
@@ -540,6 +582,14 @@ function getStatusClass(state: PackageState): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(state: PackageState, health?: string | 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) return 'crashed'
|
||||
return state
|
||||
}
|
||||
|
||||
function goToApp(id: string) {
|
||||
router.push(`/dashboard/apps/${id}`).catch(() => {})
|
||||
}
|
||||
@@ -578,9 +628,26 @@ async function stopApp(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp(id: string) {
|
||||
loadingActions.value[id] = true
|
||||
try {
|
||||
await store.restartPackage(id)
|
||||
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
|
||||
actionTimers.set(id, setTimeout(() => {
|
||||
loadingActions.value[id] = false
|
||||
actionTimers.delete(id)
|
||||
}, 8000))
|
||||
} catch (err) {
|
||||
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
|
||||
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
loadingActions.value[id] = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const t of actionTimers.values()) clearTimeout(t)
|
||||
actionTimers.clear()
|
||||
if (connectionTimer) clearTimeout(connectionTimer)
|
||||
})
|
||||
|
||||
|
||||
@@ -638,25 +705,3 @@ function handleImageError(e: Event) {
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card,
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user