diff --git a/loop/plan.md b/loop/plan.md index 7f529ed1..25458e98 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -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. -- [ ] **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. diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue index d94e54b3..c9789b2b 100644 --- a/neode-ui/src/views/AppDetails.vue +++ b/neode-ui/src/views/AppDetails.vue @@ -145,7 +145,7 @@

{{ userDid }}

Decentralized identifier for passwordless auth

@@ -542,6 +554,114 @@ + +
+
+
+

System Updates

+

Check for and install software updates

+
+ + + + + Manage Updates + +
+
+ + +
+
+
+

Backup & Restore

+

Encrypted backups of your identity, settings, and data

+
+ +
+ + +
Loading backups...
+
No backups yet. Create one to protect your node data.
+
+
+
+
{{ b.description || 'System Backup' }}
+
{{ new Date(b.created_at).toLocaleString() }} · {{ formatBackupSize(b.size_bytes) }}
+
+
+ + + + +
+
+
+ + +
+ {{ backupStatusMsg }} +
+
+ + + +
+
+

Create Encrypted Backup

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + + +
+
+

Restore Backup

+

This will overwrite current node data. Make sure you have the correct passphrase.

+
+ + +
+
+ + +
+
+
+
+
@@ -777,6 +897,7 @@ async function copyBackupCodes() { } const copiedOnion = ref(false) +const copiedDid = ref(false) const showChangePasswordModal = ref(false) const changePasswordModalRef = ref(null) const changePasswordRestoreFocusRef = ref(null) @@ -858,6 +979,24 @@ async function copyOnionAddress() { 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() { changePasswordRestoreFocusRef.value?.focus?.() showChangePasswordModal.value = false @@ -869,6 +1008,7 @@ function closeChangePasswordModal() { onMounted(async () => { checkClaudeStatus() loadTotpStatus() + loadBackups() if (!serverTorAddressFromStore.value) { try { 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) } 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([]) +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(null) +const deletingBackupId = ref(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(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 + } +} \ No newline at end of file