Files
PangolinSentinel/frontend/assets/app.js
T

758 lines
22 KiB
JavaScript

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");