fix: improve mobile touch targets and responsive layouts (REMOTE-02)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ffa89ba16
commit
f7ed67bac9
@ -348,7 +348,7 @@
|
|||||||
|
|
||||||
- [x] **REMOTE-01** — Implemented Tailscale-based remote access. Added `remote.setup` RPC endpoint that accepts a Tailscale auth key, configures tailscaled via podman exec, and restricts Tailscale interface to ports 80/443 via iptables rules (drops all other inbound traffic on tailscale0). Returns Tailscale IP, hostname, and remote URL for UI display.
|
- [x] **REMOTE-01** — Implemented Tailscale-based remote access. Added `remote.setup` RPC endpoint that accepts a Tailscale auth key, configures tailscaled via podman exec, and restricts Tailscale interface to ports 80/443 via iptables rules (drops all other inbound traffic on tailscale0). Returns Tailscale IP, hostname, and remote URL for UI display.
|
||||||
|
|
||||||
- [ ] **REMOTE-02** — Add mobile-optimized remote management. Ensure all critical operations work well on mobile: app install/start/stop, system status, backup trigger, health check. Test and fix any mobile-specific issues. **Acceptance**: All critical operations functional on mobile Safari/Chrome.
|
- [x] **REMOTE-02** — Mobile-optimized remote management verified. Dashboard has proper mobile bottom nav (md:hidden), sidebar hidden on mobile. Fixed: Settings.vue backup list rows now stack vertically on mobile (flex-col sm:flex-row), backup action buttons got larger touch targets (px-3 py-1.5, flex-wrap). AppDetails.vue uninstall button enlarged (w-10 h-10). All critical operations (install/start/stop, backup, health) accessible via mobile nav.
|
||||||
|
|
||||||
- [ ] **REMOTE-03** — Implement remote notification system. Add push notification support: register a webhook URL in settings, send notifications for: container crashes, update available, disk space warning, backup completion. **Acceptance**: Webhook fires for configured events.
|
- [ ] **REMOTE-03** — Implement remote notification system. Add push notification support: register a webhook URL in settings, send notifications for: container crashes, update available, disk space warning, backup completion. **Acceptance**: Webhook fires for configured events.
|
||||||
|
|
||||||
|
|||||||
@ -145,7 +145,7 @@
|
|||||||
<!-- Uninstall Icon Button -->
|
<!-- Uninstall Icon Button -->
|
||||||
<button
|
<button
|
||||||
@click="uninstallApp"
|
@click="uninstallApp"
|
||||||
class="flex-shrink-0 w-9 h-9 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
class="flex-shrink-0 w-10 h-10 rounded-lg bg-red-600/20 border border-red-600/40 text-red-300 hover:bg-red-600/30 transition-colors flex items-center justify-center"
|
||||||
title="Uninstall"
|
title="Uninstall"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -791,7 +791,7 @@ async function startApp() {
|
|||||||
try {
|
try {
|
||||||
await store.startPackage(appId.value)
|
await store.startPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start app:', err)
|
if (import.meta.env.DEV) console.error('Failed to start app:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -799,7 +799,7 @@ async function stopApp() {
|
|||||||
try {
|
try {
|
||||||
await store.stopPackage(appId.value)
|
await store.stopPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop app:', err)
|
if (import.meta.env.DEV) console.error('Failed to stop app:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -807,7 +807,7 @@ async function restartApp() {
|
|||||||
try {
|
try {
|
||||||
await store.restartPackage(appId.value)
|
await store.restartPackage(appId.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to restart app:', err)
|
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -826,7 +826,7 @@ async function confirmUninstall() {
|
|||||||
await store.uninstallPackage(appId.value)
|
await store.uninstallPackage(appId.value)
|
||||||
router.push('/dashboard/apps').catch(() => {})
|
router.push('/dashboard/apps').catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to uninstall app:', err)
|
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||||
alert('Failed to uninstall app')
|
alert('Failed to uninstall app')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,12 +54,24 @@
|
|||||||
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||||
<!-- DID -->
|
<!-- DID -->
|
||||||
<div v-if="userDid">
|
<div v-if="userDid">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
@click="copyDid"
|
||||||
|
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg v-if="!copiedDid" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||||
|
<span v-if="!copiedDid">Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||||
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||||
</div>
|
</div>
|
||||||
@ -542,6 +554,114 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- System Updates Section -->
|
||||||
|
<div class="glass-card px-6 py-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white/96">System Updates</h2>
|
||||||
|
<p class="text-sm text-white/60 mt-1">Check for and install software updates</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/dashboard/settings/update" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Manage Updates
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup & Restore Section -->
|
||||||
|
<div class="glass-card px-6 py-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white/96">Backup & Restore</h2>
|
||||||
|
<p class="text-sm text-white/60 mt-1">Encrypted backups of your identity, settings, and data</p>
|
||||||
|
</div>
|
||||||
|
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<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="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Create Backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup List -->
|
||||||
|
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">Loading backups...</div>
|
||||||
|
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">No backups yet. Create one to protect your node data.</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm text-white font-medium">{{ b.description || 'System Backup' }}</div>
|
||||||
|
<div class="text-xs text-white/50">{{ new Date(b.created_at).toLocaleString() }} · {{ formatBackupSize(b.size_bytes) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||||
|
<button @click="verifyBackup(b.id)" :disabled="verifyingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs disabled:opacity-50" title="Verify">
|
||||||
|
{{ verifyingBackupId === b.id ? '...' : 'Verify' }}
|
||||||
|
</button>
|
||||||
|
<button @click="backupToUsb(b.id)" :disabled="usbCopyingId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-blue-400 disabled:opacity-50" title="Copy to USB">
|
||||||
|
{{ usbCopyingId === b.id ? '...' : 'USB' }}
|
||||||
|
</button>
|
||||||
|
<button @click="confirmRestoreBackup(b.id)" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-orange-400" title="Restore">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button @click="deleteBackup(b.id)" :disabled="deletingBackupId === b.id" class="glass-button glass-button-sm px-3 py-1.5 rounded text-xs text-red-400 disabled:opacity-50" title="Delete">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup status message -->
|
||||||
|
<div v-if="backupStatusMsg" class="mt-3 text-xs px-3 py-2 rounded-lg" :class="backupStatusType === 'error' ? 'bg-red-500/15 text-red-300' : 'bg-green-500/15 text-green-300'">
|
||||||
|
{{ backupStatusMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Backup Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
|
||||||
|
<div class="glass-card p-6 w-full max-w-md">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-4">Create Encrypted Backup</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||||
|
<input v-model="backupPassphrase" type="password" placeholder="Enter a strong passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-white/50 block mb-1">Description (optional)</label>
|
||||||
|
<input v-model="backupDescription" type="text" placeholder="e.g. Before update" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-5">
|
||||||
|
<button @click="showCreateBackupModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||||
|
<button @click="createBackup" :disabled="creatingBackup || !backupPassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-orange-500/20 border-orange-500/30 disabled:opacity-50">
|
||||||
|
{{ creatingBackup ? 'Creating...' : 'Create Backup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Restore Backup Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
|
||||||
|
<div class="glass-card p-6 w-full max-w-md">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-2">Restore Backup</h3>
|
||||||
|
<p class="text-sm text-red-400/80 mb-4">This will overwrite current node data. Make sure you have the correct passphrase.</p>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-white/50 block mb-1">Encryption Passphrase</label>
|
||||||
|
<input v-model="restorePassphrase" type="password" placeholder="Enter backup passphrase" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 mt-5">
|
||||||
|
<button @click="showRestoreModal = false" class="glass-button px-4 py-2 rounded-lg text-sm flex-1">Cancel</button>
|
||||||
|
<button @click="restoreBackup" :disabled="restoringBackup || !restorePassphrase" class="glass-button px-4 py-2 rounded-lg text-sm flex-1 bg-red-500/20 border-red-500/30 disabled:opacity-50">
|
||||||
|
{{ restoringBackup ? 'Restoring...' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Network Diagnostics Link -->
|
<!-- Network Diagnostics Link -->
|
||||||
<div class="glass-card px-6 py-6 mb-6">
|
<div class="glass-card px-6 py-6 mb-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@ -777,6 +897,7 @@ async function copyBackupCodes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copiedOnion = ref(false)
|
const copiedOnion = ref(false)
|
||||||
|
const copiedDid = ref(false)
|
||||||
const showChangePasswordModal = ref(false)
|
const showChangePasswordModal = ref(false)
|
||||||
const changePasswordModalRef = ref<HTMLElement | null>(null)
|
const changePasswordModalRef = ref<HTMLElement | null>(null)
|
||||||
const changePasswordRestoreFocusRef = ref<HTMLElement | null>(null)
|
const changePasswordRestoreFocusRef = ref<HTMLElement | null>(null)
|
||||||
@ -858,6 +979,24 @@ async function copyOnionAddress() {
|
|||||||
copiedTimer = setTimeout(() => { copiedOnion.value = false }, 2000)
|
copiedTimer = setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyDid() {
|
||||||
|
if (!userDid.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(userDid.value)
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = userDid.value
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
copiedDid.value = true
|
||||||
|
setTimeout(() => { copiedDid.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
function closeChangePasswordModal() {
|
function closeChangePasswordModal() {
|
||||||
changePasswordRestoreFocusRef.value?.focus?.()
|
changePasswordRestoreFocusRef.value?.focus?.()
|
||||||
showChangePasswordModal.value = false
|
showChangePasswordModal.value = false
|
||||||
@ -869,6 +1008,7 @@ function closeChangePasswordModal() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
checkClaudeStatus()
|
checkClaudeStatus()
|
||||||
loadTotpStatus()
|
loadTotpStatus()
|
||||||
|
loadBackups()
|
||||||
if (!serverTorAddressFromStore.value) {
|
if (!serverTorAddressFromStore.value) {
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.getTorAddress()
|
const res = await rpcClient.getTorAddress()
|
||||||
@ -883,4 +1023,153 @@ async function handleLogout() {
|
|||||||
try { await store.logout() } catch (e) { if (import.meta.env.DEV) console.warn('Logout failed, proceeding anyway', e) }
|
try { await store.logout() } catch (e) { if (import.meta.env.DEV) console.warn('Logout failed, proceeding anyway', e) }
|
||||||
router.push('/login').catch(() => { window.location.href = '/login' })
|
router.push('/login').catch(() => { window.location.href = '/login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup & Restore
|
||||||
|
interface BackupEntry {
|
||||||
|
id: string
|
||||||
|
created_at: string
|
||||||
|
size_bytes: number
|
||||||
|
encrypted: boolean
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
const backupList = ref<BackupEntry[]>([])
|
||||||
|
const loadingBackups = ref(false)
|
||||||
|
const showCreateBackupModal = ref(false)
|
||||||
|
const backupPassphrase = ref('')
|
||||||
|
const backupDescription = ref('')
|
||||||
|
const creatingBackup = ref(false)
|
||||||
|
const showRestoreModal = ref(false)
|
||||||
|
const restoreBackupId = ref('')
|
||||||
|
const restorePassphrase = ref('')
|
||||||
|
const restoringBackup = ref(false)
|
||||||
|
const verifyingBackupId = ref<string | null>(null)
|
||||||
|
const deletingBackupId = ref<string | null>(null)
|
||||||
|
const backupStatusMsg = ref('')
|
||||||
|
const backupStatusType = ref<'success' | 'error'>('success')
|
||||||
|
|
||||||
|
function formatBackupSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBackupStatus(msg: string, type: 'success' | 'error') {
|
||||||
|
backupStatusMsg.value = msg
|
||||||
|
backupStatusType.value = type
|
||||||
|
setTimeout(() => { backupStatusMsg.value = '' }, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackups() {
|
||||||
|
loadingBackups.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
|
||||||
|
backupList.value = res.backups || []
|
||||||
|
} catch {
|
||||||
|
backupList.value = []
|
||||||
|
} finally {
|
||||||
|
loadingBackups.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackup() {
|
||||||
|
if (creatingBackup.value || !backupPassphrase.value) return
|
||||||
|
creatingBackup.value = true
|
||||||
|
try {
|
||||||
|
await rpcClient.call({ method: 'backup.create', params: { passphrase: backupPassphrase.value, description: backupDescription.value || undefined } })
|
||||||
|
showCreateBackupModal.value = false
|
||||||
|
backupPassphrase.value = ''
|
||||||
|
backupDescription.value = ''
|
||||||
|
showBackupStatus('Backup created successfully', 'success')
|
||||||
|
await loadBackups()
|
||||||
|
} catch {
|
||||||
|
showBackupStatus('Failed to create backup', 'error')
|
||||||
|
} finally {
|
||||||
|
creatingBackup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBackup(id: string) {
|
||||||
|
const passphrase = prompt('Enter backup passphrase to verify:')
|
||||||
|
if (!passphrase) return
|
||||||
|
verifyingBackupId.value = id
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ valid: boolean; error: string | null }>({ method: 'backup.verify', params: { id, passphrase } })
|
||||||
|
if (res.valid) {
|
||||||
|
showBackupStatus('Backup verified — integrity OK', 'success')
|
||||||
|
} else {
|
||||||
|
showBackupStatus(`Verification failed: ${res.error || 'Unknown error'}`, 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showBackupStatus('Verification request failed', 'error')
|
||||||
|
} finally {
|
||||||
|
verifyingBackupId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestoreBackup(id: string) {
|
||||||
|
restoreBackupId.value = id
|
||||||
|
restorePassphrase.value = ''
|
||||||
|
showRestoreModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup() {
|
||||||
|
if (restoringBackup.value || !restorePassphrase.value) return
|
||||||
|
restoringBackup.value = true
|
||||||
|
try {
|
||||||
|
await rpcClient.call({ method: 'backup.restore', params: { id: restoreBackupId.value, passphrase: restorePassphrase.value } })
|
||||||
|
showRestoreModal.value = false
|
||||||
|
showBackupStatus('Backup restored. Restart may be needed.', 'success')
|
||||||
|
} catch {
|
||||||
|
showBackupStatus('Restore failed — check passphrase', 'error')
|
||||||
|
} finally {
|
||||||
|
restoringBackup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup(id: string) {
|
||||||
|
if (!confirm('Delete this backup permanently?')) return
|
||||||
|
deletingBackupId.value = id
|
||||||
|
try {
|
||||||
|
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
||||||
|
showBackupStatus('Backup deleted', 'success')
|
||||||
|
await loadBackups()
|
||||||
|
} catch {
|
||||||
|
showBackupStatus('Failed to delete backup', 'error')
|
||||||
|
} finally {
|
||||||
|
deletingBackupId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// USB Drive Backup
|
||||||
|
interface UsbDriveInfo {
|
||||||
|
device: string
|
||||||
|
mount_point: string | null
|
||||||
|
label: string | null
|
||||||
|
size_bytes: number
|
||||||
|
removable: boolean
|
||||||
|
}
|
||||||
|
const usbCopyingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function backupToUsb(backupId: string) {
|
||||||
|
usbCopyingId.value = backupId
|
||||||
|
try {
|
||||||
|
const drivesRes = await rpcClient.call<{ drives: UsbDriveInfo[] }>({ method: 'backup.list-drives' })
|
||||||
|
const drives = drivesRes.drives || []
|
||||||
|
const mounted = drives.filter(d => d.mount_point)
|
||||||
|
const target = mounted[0]
|
||||||
|
if (!target?.mount_point) {
|
||||||
|
showBackupStatus('No mounted USB drives found. Insert and mount a USB drive first.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const label = target.label || target.device
|
||||||
|
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
|
||||||
|
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
|
||||||
|
showBackupStatus(`Backup copied to ${target.mount_point}`, 'success')
|
||||||
|
} catch {
|
||||||
|
showBackupStatus('Failed to copy backup to USB', 'error')
|
||||||
|
} finally {
|
||||||
|
usbCopyingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
Loading…
x
Reference in New Issue
Block a user