const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue; createApp({ setup() { // Auth State const authState = reactive({ enabled: true, setupRequired: false, isLoggedIn: false, token: localStorage.getItem("pangolin_token") || "", password: "", setupPassword: "", setupPasswordConfirm: "", error: "" }); // Navigation Tab const currentTab = ref("dashboard"); // Alerts / Toasts const toasts = ref([]); const showToast = (message, type = "info") => { const id = Date.now(); toasts.value.push({ id, message, type }); setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id); }, 4500); }; // System & VPS Stats State const systemStats = ref(null); const updatesData = ref({ total: 0, security_count: 0, packages: [] }); const checkingUpdates = ref(false); // SSH & VPS Connection State const publicKeyData = ref({ public_key: "", command_hint: "" }); const connectionTest = reactive({ testing: false, result: null }); // Settings State const settings = ref({ vps_host: "", vps_port: 22, vps_user: "root", vps_auth_method: "key", vps_ssh_key_path: "/opt/pangolin-manager/data/id_ed25519", vps_ssh_password: "", vps_pangolin_dir: "/opt/pangolin", backup_dir: "/mnt/user/backups/pangolin", retention_count: 10, scheduled_backup_enabled: true, scheduled_backup_cron: "0 3 * * *", scheduled_backup_scope: "pangolin", scheduled_update_check_enabled: true, webhook_url: "", webhook_type: "discord", telegram_bot_token: "", telegram_chat_id: "", notify_on_backup: true, notify_on_update: true, auth_enabled: true }); const settingsLoading = ref(false); const testingWebhook = ref(false); // Pangolin Stack State const pangolinStatus = ref({ vps_configured: false, connected: false, installed: false, path: "/opt/pangolin", containers: [], running_count: 0, total_count: 0, api_online: false, database: { found: false, path: null, size_bytes: 0 } }); const stackFiles = ref({}); const activeFileKey = ref("docker-compose.yml"); const activeFileContent = ref(""); const savingFile = ref(false); const containerLogs = ref({ name: "", logs: "", visible: false }); // Backups State (Stored on Unraid) const backups = ref([]); const backupsLoading = ref(false); const backupModal = reactive({ show: false, scope: "pangolin", description: "" }); const inspectModal = reactive({ show: false, backup: null, members: [], loading: false }); const restoreModal = reactive({ show: false, backup: null, confirmText: "" }); const verifyState = reactive({}); // Password change const changePasswordForm = reactive({ oldPassword: "", newPassword: "", confirmPassword: "" }); // Jobs & Live Console State const jobs = ref([]); const activeJob = ref(null); const selectedJobId = ref(null); const autoScroll = ref(true); const terminalRef = ref(null); let ws = null; let statsInterval = null; // API Helper with Auth const api = async (endpoint, options = {}) => { const headers = options.headers || {}; if (authState.token) { headers["Authorization"] = `Bearer ${authState.token}`; } options.headers = headers; try { const res = await fetch(endpoint, options); if (res.status === 401) { authState.isLoggedIn = false; throw new Error("Session expired. Please log in."); } const data = await res.json(); if (!res.ok) { throw new Error(data.detail || data.error || `HTTP error ${res.status}`); } return data; } catch (err) { throw err; } }; // Check Auth Status const checkAuth = async () => { try { const res = await fetch("/api/auth/status"); const data = await res.json(); authState.enabled = data.auth_enabled; authState.setupRequired = data.setup_required; if (!authState.enabled) { authState.isLoggedIn = true; initDashboard(); } else if (authState.token && !authState.setupRequired) { try { await api("/api/system/stats"); authState.isLoggedIn = true; initDashboard(); } catch (e) { authState.isLoggedIn = false; } } } catch (e) { console.error("Auth check failed:", e); } }; // Login const handleLogin = async () => { authState.error = ""; try { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: authState.password }) }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Login failed"); authState.token = data.token; localStorage.setItem("pangolin_token", data.token); authState.isLoggedIn = true; authState.password = ""; showToast("Logged in successfully", "success"); initDashboard(); } catch (err) { authState.error = err.message; } }; // Setup const handleSetup = async () => { authState.error = ""; if (authState.setupPassword !== authState.setupPasswordConfirm) { authState.error = "Passwords do not match."; return; } if (authState.setupPassword.length < 6) { authState.error = "Password must be at least 6 characters."; return; } try { const res = await fetch("/api/auth/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: authState.setupPassword }) }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Setup failed"); authState.token = data.token; localStorage.setItem("pangolin_token", data.token); authState.setupRequired = false; authState.isLoggedIn = true; showToast("Admin account configured!", "success"); initDashboard(); } catch (err) { authState.error = err.message; } }; // Logout const handleLogout = async () => { try { await fetch("/api/auth/logout", { method: "POST" }); } catch (e) {} localStorage.removeItem("pangolin_token"); authState.token = ""; authState.isLoggedIn = false; showToast("Logged out", "info"); }; // Load Public Key const fetchPublicKey = async () => { try { publicKeyData.value = await api("/api/remote/public-key"); } catch (e) { console.error("Failed to load public key:", e); } }; // Test VPS Connection const testVpsConnection = async () => { connectionTest.testing = true; connectionTest.result = null; try { const res = await api("/api/remote/test-connection", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ settings: settings.value }) }); connectionTest.result = res; if (res.connected) { showToast(`Connected to ${res.hostname} (${res.os})!`, "success"); await fetchSystemStats(); await fetchPangolinStatus(); } else { showToast(`Connection failed: ${res.error}`, "error"); } } catch (e) { connectionTest.result = { connected: false, error: e.message }; showToast(e.message, "error"); } finally { connectionTest.testing = false; } }; // Load Stats const fetchSystemStats = async () => { try { systemStats.value = await api("/api/system/stats"); } catch (e) { console.error("Failed to load system stats:", e); } }; // Check Updates const checkUpdates = async () => { checkingUpdates.value = true; try { updatesData.value = await api("/api/system/updates"); showToast(`Found ${updatesData.value.total} package update(s) on VPS`, "info"); } catch (e) { showToast(`Update check error: ${e.message}`, "error"); } finally { checkingUpdates.value = false; } }; // Upgrade VPS packages const triggerSystemUpgrade = async (dist = false) => { if (!confirm(`Are you sure you want to upgrade packages on remote VPS (${settings.value.vps_host})?`)) { return; } try { const res = await api("/api/system/upgrade", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dist_upgrade: dist }) }); showToast("Remote VPS package upgrade started!", "info"); currentTab.value = "console"; selectedJobId.value = res.job_id; } catch (e) { showToast(e.message, "error"); } }; // Reboot VPS const triggerReboot = async () => { if (!confirm(`⚠️ WARNING: Are you sure you want to REBOOT the remote VPS (${settings.value.vps_host})?`)) { return; } try { await api("/api/system/reboot", { method: "POST" }); showToast("Remote VPS Reboot scheduled in 1 minute.", "warning"); } catch (e) { showToast(e.message, "error"); } }; // Pangolin Stack Calls const fetchPangolinStatus = async () => { try { pangolinStatus.value = await api("/api/pangolin/status"); } catch (e) { console.error("Failed to load Pangolin status:", e); } }; const updatePangolinStack = async () => { if (!confirm(`Update Pangolin stack on remote VPS (${settings.value.vps_host})? This will pull latest Docker images and recreate containers.`)) { return; } try { const res = await api("/api/pangolin/update", { method: "POST" }); showToast("Remote Pangolin stack update started!", "info"); currentTab.value = "console"; selectedJobId.value = res.job_id; } catch (e) { showToast(e.message, "error"); } }; const handleContainerAction = async (name, action) => { try { await api(`/api/pangolin/containers/${name}/action`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }) }); showToast(`Container ${name} ${action} succeeded on VPS`, "success"); await fetchPangolinStatus(); } catch (e) { showToast(e.message, "error"); } }; const viewContainerLogs = async (name) => { try { const data = await api(`/api/pangolin/containers/${name}/logs?lines=200`); containerLogs.value = { name, logs: data.logs || "No logs available.", visible: true }; } catch (e) { showToast(e.message, "error"); } }; const loadStackFiles = async () => { try { const files = await api("/api/pangolin/files"); stackFiles.value = files; const keys = Object.keys(files); if (keys.length > 0 && (!activeFileKey.value || !files[activeFileKey.value])) { activeFileKey.value = keys[0]; } if (activeFileKey.value && files[activeFileKey.value]) { activeFileContent.value = files[activeFileKey.value]; } } catch (e) { console.error("Failed to load stack files:", e); } }; const selectStackFile = (key) => { activeFileKey.value = key; activeFileContent.value = stackFiles.value[key] || ""; }; const saveStackFile = async () => { savingFile.value = true; try { await api("/api/pangolin/files", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: activeFileKey.value, content: activeFileContent.value }) }); stackFiles.value[activeFileKey.value] = activeFileContent.value; showToast(`Saved ${activeFileKey.value} on remote VPS (snapshot created)`, "success"); } catch (e) { showToast(e.message, "error"); } finally { savingFile.value = false; } }; // Backups const fetchBackups = async () => { backupsLoading.value = true; try { backups.value = await api("/api/backups"); } catch (e) { console.error("Failed to load backups:", e); } finally { backupsLoading.value = false; } }; const triggerBackup = async () => { try { const res = await api("/api/backups", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope: backupModal.scope, description: backupModal.description }) }); backupModal.show = false; backupModal.description = ""; showToast(`Pulling backup from VPS to Unraid (${backupModal.scope.toUpperCase()})...`, "info"); currentTab.value = "console"; selectedJobId.value = res.job_id; } catch (e) { showToast(e.message, "error"); } }; const inspectBackupArchive = async (b) => { inspectModal.backup = b; inspectModal.show = true; inspectModal.loading = true; inspectModal.members = []; try { const data = await api(`/api/backups/${b.id}/inspect`); inspectModal.members = data.members; } catch (e) { showToast(`Inspect error: ${e.message}`, "error"); } finally { inspectModal.loading = false; } }; const verifyBackupArchive = async (b) => { verifyState[b.id] = { loading: true }; try { const res = await api(`/api/backups/${b.id}/verify`, { method: "POST" }); verifyState[b.id] = { loading: false, verified: res.verified, details: res.verified ? "Integrity OK on Unraid storage" : "Verification FAILED" }; showToast(res.verified ? `Backup ${b.id} integrity verified on Unraid!` : "Integrity check failed!", res.verified ? "success" : "error"); } catch (e) { verifyState[b.id] = { loading: false, verified: false, details: e.message }; showToast(e.message, "error"); } }; const promptRestore = (b) => { restoreModal.backup = b; restoreModal.confirmText = ""; restoreModal.show = true; }; const executeRestore = async () => { if (restoreModal.confirmText !== "RESTORE") { showToast("Please type RESTORE to confirm.", "warning"); return; } const b = restoreModal.backup; restoreModal.show = false; try { const res = await api(`/api/backups/${b.id}/restore`, { method: "POST" }); showToast(`Pushed restore job to remote VPS!`, "warning"); currentTab.value = "console"; selectedJobId.value = res.job_id; } catch (e) { showToast(e.message, "error"); } }; const deleteBackupArchive = async (b) => { if (!confirm(`Permanently delete backup "${b.filename}" from Unraid?`)) { return; } try { await api(`/api/backups/${b.id}`, { method: "DELETE" }); showToast("Backup deleted from Unraid", "info"); await fetchBackups(); } catch (e) { showToast(e.message, "error"); } }; const handleUploadBackup = async (event) => { const file = event.target.files[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); try { showToast(`Uploading ${file.name} to Unraid...`, "info"); const headers = {}; if (authState.token) headers["Authorization"] = `Bearer ${authState.token}`; const res = await fetch("/api/backups/upload", { method: "POST", headers, body: formData }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Upload failed"); showToast("Backup uploaded to Unraid storage!", "success"); await fetchBackups(); } catch (e) { showToast(e.message, "error"); } finally { event.target.value = ""; } }; const downloadBackupUrl = (b) => { return `/api/backups/${b.id}/download?token=${encodeURIComponent(authState.token)}`; }; // Settings const fetchSettings = async () => { try { settings.value = await api("/api/settings"); } catch (e) { console.error("Failed to load settings:", e); } }; const saveSettings = async () => { settingsLoading.value = true; try { await api("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ settings: settings.value }) }); showToast("Configuration saved successfully!", "success"); await fetchSystemStats(); await fetchPangolinStatus(); } catch (e) { showToast(e.message, "error"); } finally { settingsLoading.value = false; } }; const testWebhook = async () => { testingWebhook.value = true; try { const res = await api("/api/notifications/test", { method: "POST" }); if (res.success) { showToast("Webhook test sent successfully!", "success"); } else { showToast(`Webhook error: ${res.error || res.text}`, "error"); } } catch (e) { showToast(`Webhook test error: ${e.message}`, "error"); } finally { testingWebhook.value = false; } }; // Jobs & WebSocket Console const fetchJobs = async () => { try { jobs.value = await api("/api/jobs"); if (!selectedJobId.value && jobs.value.length > 0) { selectedJobId.value = jobs.value[0].id; } } catch (e) { console.error("Failed to load jobs:", e); } }; const displayedJob = computed(() => { if (activeJob.value && (!selectedJobId.value || selectedJobId.value === activeJob.value.id)) { return activeJob.value; } return jobs.value.find(j => j.id === selectedJobId.value) || activeJob.value || null; }); const connectWebSocket = () => { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${window.location.host}/ws/jobs`; try { ws = new WebSocket(url); ws.onopen = () => { setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send("ping"); }, 20000); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "job_started") { activeJob.value = msg.job; selectedJobId.value = msg.job.id; jobs.value.unshift(msg.job); } else if (msg.type === "log") { if (activeJob.value && activeJob.value.id === msg.job_id) { activeJob.value.logs.push(msg.data); scrollTerminal(); } const target = jobs.value.find(j => j.id === msg.job_id); if (target && target !== activeJob.value) { target.logs.push(msg.data); } } else if (msg.type === "job_finished") { if (activeJob.value && activeJob.value.id === msg.job.id) { activeJob.value = msg.job; } const idx = jobs.value.findIndex(j => j.id === msg.job.id); if (idx !== -1) jobs.value[idx] = msg.job; if (msg.job.type === "backup") fetchBackups(); if (msg.job.type === "update_pangolin" || msg.job.type === "restore") fetchPangolinStatus(); if (msg.job.type === "update_system") fetchSystemStats(); } } catch (e) { console.error("WS error:", e); } }; ws.onclose = () => setTimeout(connectWebSocket, 4000); } catch (e) { console.error("WS connection error:", e); } }; const scrollTerminal = () => { if (!autoScroll.value) return; nextTick(() => { if (terminalRef.value) { terminalRef.value.scrollTop = terminalRef.value.scrollHeight; } }); }; const copyToClipboard = (text) => { navigator.clipboard.writeText(text); showToast("Copied to clipboard!", "info"); }; // Dashboard init const initDashboard = async () => { await fetchSettings(); await fetchPublicKey(); await fetchSystemStats(); await fetchPangolinStatus(); await fetchBackups(); await fetchJobs(); loadStackFiles(); if (settings.value.vps_host) { checkUpdates(); } connectWebSocket(); if (statsInterval) clearInterval(statsInterval); statsInterval = setInterval(() => { if (authState.isLoggedIn && settings.value.vps_host) { fetchSystemStats(); } }, 5000); }; onMounted(() => checkAuth()); onUnmounted(() => { if (statsInterval) clearInterval(statsInterval); if (ws) ws.close(); }); return { authState, currentTab, toasts, showToast, systemStats, updatesData, checkingUpdates, publicKeyData, connectionTest, settings, settingsLoading, testingWebhook, pangolinStatus, stackFiles, activeFileKey, activeFileContent, savingFile, containerLogs, backups, backupsLoading, backupModal, inspectModal, restoreModal, verifyState, changePasswordForm, jobs, activeJob, selectedJobId, displayedJob, autoScroll, terminalRef, handleLogin, handleSetup, handleLogout, testVpsConnection, fetchSystemStats, checkUpdates, triggerSystemUpgrade, triggerReboot, fetchPangolinStatus, updatePangolinStack, handleContainerAction, viewContainerLogs, selectStackFile, saveStackFile, fetchBackups, triggerBackup, inspectBackupArchive, verifyBackupArchive, promptRestore, executeRestore, deleteBackupArchive, handleUploadBackup, downloadBackupUrl, saveSettings, testWebhook, copyToClipboard }; } }).mount("#app");