From 8c538bd99c1c57cf2ac28564af72f74b3632fc88 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Sat, 11 Oct 2025 20:35:47 +0100 Subject: [PATCH] Merge changes from main: Add GetHomepage integration and update to v1.2.9 - Added gethomepageRoutes.js for GetHomepage integration - Updated all package.json files to version 1.2.9 - Updated agent script to version 1.2.9 - Updated version fallbacks in versionRoutes.js and updateScheduler.js - Updated setup.sh with version 1.2.9 - Merged GetHomepage integration UI (Integrations.jsx) - Updated docker-entrypoint.sh from main - Updated VersionUpdateTab component - Combined automation and gethomepage routes in server.js - Maintains both BullMQ automation and GetHomepage functionality --- agents/patchmon-agent.sh | 4 +- backend/package.json | 2 +- backend/src/routes/gethomepageRoutes.js | 236 +++++ backend/src/routes/versionRoutes.js | 10 +- backend/src/server.js | 43 +- backend/src/services/updateScheduler.js | 4 +- docker/backend.docker-entrypoint.sh | 95 +- frontend/package.json | 2 +- .../components/settings/VersionUpdateTab.jsx | 14 +- frontend/src/pages/settings/Integrations.jsx | 968 +++++++++++++----- package-lock.json | 8 +- package.json | 2 +- setup.sh | 6 +- 13 files changed, 1111 insertions(+), 283 deletions(-) create mode 100644 backend/src/routes/gethomepageRoutes.js diff --git a/agents/patchmon-agent.sh b/agents/patchmon-agent.sh index 750ce51..43ab174 100755 --- a/agents/patchmon-agent.sh +++ b/agents/patchmon-agent.sh @@ -1,12 +1,12 @@ #!/bin/bash -# PatchMon Agent Script v1.2.8 +# PatchMon Agent Script v1.2.9 # This script sends package update information to the PatchMon server using API credentials # Configuration PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}" API_VERSION="v1" -AGENT_VERSION="1.2.8" +AGENT_VERSION="1.2.9" CONFIG_FILE="/etc/patchmon/agent.conf" CREDENTIALS_FILE="/etc/patchmon/credentials" LOG_FILE="/var/log/patchmon-agent.log" diff --git a/backend/package.json b/backend/package.json index 87de98b..eae2672 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "patchmon-backend", - "version": "1.2.7", + "version": "1.2.9", "description": "Backend API for Linux Patch Monitoring System", "license": "AGPL-3.0", "main": "src/server.js", diff --git a/backend/src/routes/gethomepageRoutes.js b/backend/src/routes/gethomepageRoutes.js new file mode 100644 index 0000000..cc44163 --- /dev/null +++ b/backend/src/routes/gethomepageRoutes.js @@ -0,0 +1,236 @@ +const express = require("express"); +const { createPrismaClient } = require("../config/database"); +const bcrypt = require("bcryptjs"); + +const router = express.Router(); +const prisma = createPrismaClient(); + +// Middleware to authenticate API key +const authenticateApiKey = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Basic ")) { + return res + .status(401) + .json({ error: "Missing or invalid authorization header" }); + } + + // Decode base64 credentials + const base64Credentials = authHeader.split(" ")[1]; + const credentials = Buffer.from(base64Credentials, "base64").toString( + "ascii", + ); + const [apiKey, apiSecret] = credentials.split(":"); + + if (!apiKey || !apiSecret) { + return res.status(401).json({ error: "Invalid credentials format" }); + } + + // Find the token in database + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: apiKey }, + include: { + users: { + select: { + id: true, + username: true, + role: true, + }, + }, + }, + }); + + if (!token) { + console.log(`API key not found: ${apiKey}`); + return res.status(401).json({ error: "Invalid API key" }); + } + + // Check if token is active + if (!token.is_active) { + return res.status(401).json({ error: "API key is disabled" }); + } + + // Check if token has expired + if (token.expires_at && new Date(token.expires_at) < new Date()) { + return res.status(401).json({ error: "API key has expired" }); + } + + // Check if token is for gethomepage integration + if (token.metadata?.integration_type !== "gethomepage") { + return res.status(401).json({ error: "Invalid API key type" }); + } + + // Verify the secret + const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret); + if (!isValidSecret) { + return res.status(401).json({ error: "Invalid API secret" }); + } + + // Check IP restrictions if any + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const clientIp = req.ip || req.connection.remoteAddress; + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + // Get the actual client IP (considering proxies) + const actualClientIp = forwardedFor + ? forwardedFor.split(",")[0].trim() + : realIp || clientIp; + + const isAllowedIp = token.allowed_ip_ranges.some((range) => { + // Simple IP range check (can be enhanced for CIDR support) + return actualClientIp.startsWith(range) || actualClientIp === range; + }); + + if (!isAllowedIp) { + console.log( + `IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`, + ); + return res.status(403).json({ error: "IP address not allowed" }); + } + } + + // Update last used timestamp + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { last_used_at: new Date() }, + }); + + // Attach token info to request + req.apiToken = token; + next(); + } catch (error) { + console.error("API key authentication error:", error); + res.status(500).json({ error: "Authentication failed" }); + } +}; + +// Get homepage widget statistics +router.get("/stats", authenticateApiKey, async (_req, res) => { + try { + // Get total hosts count + const totalHosts = await prisma.hosts.count({ + where: { status: "active" }, + }); + + // Get total outdated packages count + const totalOutdatedPackages = await prisma.host_packages.count({ + where: { needs_update: true }, + }); + + // Get total repositories count + const totalRepos = await prisma.repositories.count({ + where: { is_active: true }, + }); + + // Get hosts that need updates (have outdated packages) + const hostsNeedingUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + }, + }, + }, + }); + + // Get security updates count + const securityUpdates = await prisma.host_packages.count({ + where: { + needs_update: true, + is_security_update: true, + }, + }); + + // Get hosts with security updates + const hostsWithSecurityUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + is_security_update: true, + }, + }, + }, + }); + + // Get up-to-date hosts count + const upToDateHosts = totalHosts - hostsNeedingUpdates; + + // Get recent update activity (last 24 hours) + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentUpdates = await prisma.update_history.count({ + where: { + timestamp: { + gte: oneDayAgo, + }, + status: "success", + }, + }); + + // Get OS distribution + const osDistribution = await prisma.hosts.groupBy({ + by: ["os_type"], + where: { status: "active" }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + }); + + // Format OS distribution data + const osDistributionFormatted = osDistribution.map((os) => ({ + name: os.os_type, + count: os._count.id, + })); + + // Extract top 3 OS types for flat display in widgets + const top_os_1 = osDistributionFormatted[0] || { name: "None", count: 0 }; + const top_os_2 = osDistributionFormatted[1] || { name: "None", count: 0 }; + const top_os_3 = osDistributionFormatted[2] || { name: "None", count: 0 }; + + // Prepare response data + const stats = { + total_hosts: totalHosts, + total_outdated_packages: totalOutdatedPackages, + total_repos: totalRepos, + hosts_needing_updates: hostsNeedingUpdates, + up_to_date_hosts: upToDateHosts, + security_updates: securityUpdates, + hosts_with_security_updates: hostsWithSecurityUpdates, + recent_updates_24h: recentUpdates, + os_distribution: osDistributionFormatted, + // Flattened OS data for easy widget display + top_os_1_name: top_os_1.name, + top_os_1_count: top_os_1.count, + top_os_2_name: top_os_2.name, + top_os_2_count: top_os_2.count, + top_os_3_name: top_os_3.name, + top_os_3_count: top_os_3.count, + last_updated: new Date().toISOString(), + }; + + res.json(stats); + } catch (error) { + console.error("Error fetching homepage stats:", error); + res.status(500).json({ error: "Failed to fetch statistics" }); + } +}); + +// Health check endpoint for the API +router.get("/health", authenticateApiKey, async (req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + api_key: req.apiToken.token_name, + }); +}); + +module.exports = router; diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 81b82c9..1bb79be 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -14,13 +14,13 @@ const router = express.Router(); function getCurrentVersion() { try { const packageJson = require("../../package.json"); - return packageJson?.version || "1.2.7"; + return packageJson?.version || "1.2.9"; } catch (packageError) { console.warn( "Could not read version from package.json, using fallback:", packageError.message, ); - return "1.2.7"; + return "1.2.9"; } } @@ -274,11 +274,11 @@ router.get( ) { console.log("GitHub API rate limited, providing fallback data"); latestRelease = { - tagName: "v1.2.7", - version: "1.2.7", + tagName: "v1.2.8", + version: "1.2.8", publishedAt: "2025-10-02T17:12:53Z", htmlUrl: - "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7", + "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.8", }; latestCommit = { sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", diff --git a/backend/src/server.js b/backend/src/server.js index c290da2..adf465c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -61,9 +61,13 @@ const repositoryRoutes = require("./routes/repositoryRoutes"); const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); +const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); +const gethomepageRoutes = require("./routes/gethomepageRoutes"); const automationRoutes = require("./routes/automationRoutes"); -const { queueManager } = require("./services/automation"); +const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); +const { cleanup_expired_sessions } = require("./utils/session_manager"); +const { queueManager } = require("./services/automation"); // Initialize Prisma client with optimized connection pooling for multiple instances const prisma = createPrismaClient(); @@ -416,6 +420,12 @@ app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); app.use(`/api/${apiVersion}/version`, versionRoutes); app.use(`/api/${apiVersion}/tfa`, tfaRoutes); app.use(`/api/${apiVersion}/search`, searchRoutes); +app.use( + `/api/${apiVersion}/auto-enrollment`, + authLimiter, + autoEnrollmentRoutes, +); +app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); app.use(`/api/${apiVersion}/automation`, automationRoutes); // Error handling middleware @@ -439,6 +449,10 @@ process.on("SIGINT", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGINT received, shutting down gracefully"); } + if (app.locals.session_cleanup_interval) { + clearInterval(app.locals.session_cleanup_interval); + } + updateScheduler.stop(); await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); @@ -448,6 +462,10 @@ process.on("SIGTERM", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGTERM received, shutting down gracefully"); } + if (app.locals.session_cleanup_interval) { + clearInterval(app.locals.session_cleanup_interval); + } + updateScheduler.stop(); await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); @@ -723,13 +741,34 @@ async function startServer() { // Schedule recurring jobs await queueManager.scheduleAllJobs(); + // Initial session cleanup + await cleanup_expired_sessions(); + + // Schedule session cleanup every hour + const session_cleanup_interval = setInterval( + async () => { + try { + await cleanup_expired_sessions(); + } catch (error) { + console.error("Session cleanup error:", error); + } + }, + 60 * 60 * 1000, + ); // Every hour + app.listen(PORT, () => { if (process.env.ENABLE_LOGGING === "true") { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV}`); - logger.info("✅ BullMQ queue manager started"); + logger.info("✅ Session cleanup scheduled (every hour)"); } + + // Start update scheduler + updateScheduler.start(); }); + + // Store interval for cleanup on shutdown + app.locals.session_cleanup_interval = session_cleanup_interval; } catch (error) { console.error("❌ Failed to start server:", error.message); process.exit(1); diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js index 34f1979..0e717ea 100644 --- a/backend/src/services/updateScheduler.js +++ b/backend/src/services/updateScheduler.js @@ -104,7 +104,7 @@ class UpdateScheduler { } // Read version from package.json dynamically - let currentVersion = "1.2.7"; // fallback + let currentVersion = "1.2.9"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { @@ -214,7 +214,7 @@ class UpdateScheduler { const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; // Get current version for User-Agent - let currentVersion = "1.2.7"; // fallback + let currentVersion = "1.2.9"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { diff --git a/docker/backend.docker-entrypoint.sh b/docker/backend.docker-entrypoint.sh index 486f05d..9f1a59d 100755 --- a/docker/backend.docker-entrypoint.sh +++ b/docker/backend.docker-entrypoint.sh @@ -8,19 +8,94 @@ log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2 } -# Copy files from agents_backup to agents if agents directory is empty and no .sh files are present -if [ -d "/app/agents" ] && [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' | head -n 1)" ]; then - if [ -d "/app/agents_backup" ]; then - log "Agents directory is empty, copying from backup..." - cp -r /app/agents_backup/* /app/agents/ +# Function to extract version from agent script +get_agent_version() { + local file="$1" + if [ -f "$file" ]; then + grep -m 1 '^AGENT_VERSION=' "$file" | cut -d'"' -f2 2>/dev/null || echo "0.0.0" else - log "Warning: agents_backup directory not found" + echo "0.0.0" fi -else - log "Agents directory already contains files, skipping copy" -fi +} -log "Starting PatchMon Backend (${NODE_ENV:-production})..." +# Function to compare versions (returns 0 if $1 > $2) +version_greater() { + # Use sort -V for version comparison + test "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" && test "$1" != "$2" +} + +# Check and update agent files if necessary +update_agents() { + local backup_agent="/app/agents_backup/patchmon-agent.sh" + local current_agent="/app/agents/patchmon-agent.sh" + + # Check if agents directory exists + if [ ! -d "/app/agents" ]; then + log "ERROR: /app/agents directory not found" + return 1 + fi + + # Check if backup exists + if [ ! -d "/app/agents_backup" ]; then + log "WARNING: agents_backup directory not found, skipping agent update" + return 0 + fi + + # Get versions + local backup_version=$(get_agent_version "$backup_agent") + local current_version=$(get_agent_version "$current_agent") + + log "Agent version check:" + log " Image version: ${backup_version}" + log " Volume version: ${current_version}" + + # Determine if update is needed + local needs_update=0 + + # Case 1: No agents in volume (first time setup) + if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then + log "Agents directory is empty - performing initial copy" + needs_update=1 + # Case 2: Backup version is newer + elif version_greater "$backup_version" "$current_version"; then + log "Newer agent version available (${backup_version} > ${current_version})" + needs_update=1 + else + log "Agents are up to date" + needs_update=0 + fi + + # Perform update if needed + if [ $needs_update -eq 1 ]; then + log "Updating agents to version ${backup_version}..." + + # Create backup of existing agents if they exist + if [ -f "$current_agent" ]; then + local backup_timestamp=$(date +%Y%m%d_%H%M%S) + local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}" + cp "$current_agent" "$backup_name" 2>/dev/null || true + log "Previous agent backed up to: $(basename $backup_name)" + fi + + # Copy new agents + cp -r /app/agents_backup/* /app/agents/ + + # Verify update + local new_version=$(get_agent_version "$current_agent") + if [ "$new_version" = "$backup_version" ]; then + log "✅ Agents successfully updated to version ${new_version}" + else + log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})" + fi + fi +} + +# Main execution +log "PatchMon Backend Container Starting..." +log "Environment: ${NODE_ENV:-production}" + +# Update agents (version-aware) +update_agents log "Running database migrations..." npx prisma migrate deploy diff --git a/frontend/package.json b/frontend/package.json index 3a7bd29..3685593 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patchmon-frontend", "private": true, - "version": "1.2.7", + "version": "1.2.9", "license": "AGPL-3.0", "type": "module", "scripts": { diff --git a/frontend/src/components/settings/VersionUpdateTab.jsx b/frontend/src/components/settings/VersionUpdateTab.jsx index ed99728..6ed0ffb 100644 --- a/frontend/src/components/settings/VersionUpdateTab.jsx +++ b/frontend/src/components/settings/VersionUpdateTab.jsx @@ -128,12 +128,14 @@ const VersionUpdateTab = () => { {versionInfo.github.latestRelease.tagName} -
- Published:{" "} - {new Date( - versionInfo.github.latestRelease.publishedAt, - ).toLocaleDateString()} -
+ {versionInfo.github.latestRelease.publishedAt && ( +
+ Published:{" "} + {new Date( + versionInfo.github.latestRelease.publishedAt, + ).toLocaleDateString()} +
+ )} )} diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index 8732c12..75853ea 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -1,5 +1,6 @@ import { AlertCircle, + BookOpen, CheckCircle, Copy, Eye, @@ -9,11 +10,18 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import SettingsLayout from "../../components/SettingsLayout"; import api from "../../utils/api"; const Integrations = () => { + // Generate unique IDs for form elements + const token_name_id = useId(); + const token_key_id = useId(); + const token_secret_id = useId(); + const token_base64_id = useId(); + const gethomepage_config_id = useId(); + const [activeTab, setActiveTab] = useState("proxmox"); const [tokens, setTokens] = useState([]); const [host_groups, setHostGroups] = useState([]); @@ -94,7 +102,8 @@ const Integrations = () => { ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) : [], metadata: { - integration_type: "proxmox-lxc", + integration_type: + activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc", }, }; @@ -158,12 +167,49 @@ const Integrations = () => { } }; - const copy_to_clipboard = (text, key) => { - navigator.clipboard.writeText(text); - setCopySuccess({ ...copy_success, [key]: true }); - setTimeout(() => { - setCopySuccess({ ...copy_success, [key]: false }); - }, 2000); + const copy_to_clipboard = async (text, key) => { + // Check if Clipboard API is available + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + return; + } catch (error) { + console.error("Clipboard API failed:", error); + // Fall through to fallback method + } + } + + // Fallback method for older browsers or non-secure contexts + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand("copy"); + document.body.removeChild(textArea); + + if (successful) { + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + } else { + console.error("Fallback copy failed"); + alert("Failed to copy to clipboard. Please copy manually."); + } + } catch (fallbackError) { + console.error("Fallback copy failed:", fallbackError); + alert("Failed to copy to clipboard. Please copy manually."); + } }; const format_date = (date_string) => { @@ -198,6 +244,17 @@ const Integrations = () => { > Proxmox LXC + {/* Future tabs can be added here */} @@ -367,9 +424,20 @@ const Integrations = () => { {/* Documentation Section */}
-

- How to Use Auto-Enrollment -

+
+

+ How to Use Auto-Enrollment +

+ + + Documentation + +
  1. Create a new auto-enrollment token using the button above @@ -395,6 +463,266 @@ const Integrations = () => {
)} + + {/* GetHomepage Tab */} + {activeTab === "gethomepage" && ( +
+ {/* Header with New API Key Button */} +
+
+
+ +
+
+

+ GetHomepage Widget Integration +

+

+ Create API keys to display PatchMon statistics in your + GetHomepage dashboard +

+
+
+ +
+ + {/* API Keys List */} + {loading ? ( +
+
+
+ ) : tokens.filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ).length === 0 ? ( +
+

No GetHomepage API keys created yet.

+

+ Create an API key to enable GetHomepage widget + integration. +

+
+ ) : ( +
+ {tokens + .filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ) + .map((token) => ( +
+
+
+
+

+ {token.token_name} +

+ + GetHomepage + + {token.is_active ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + {token.token_key} + + +
+

Created: {format_date(token.created_at)}

+ {token.last_used_at && ( +

+ Last Used: {format_date(token.last_used_at)} +

+ )} + {token.expires_at && ( +

+ Expires: {format_date(token.expires_at)} + {new Date(token.expires_at) < + new Date() && ( + + (Expired) + + )} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Documentation Section */} +
+
+

+ How to Use GetHomepage Integration +

+ + + Documentation + +
+
    +
  1. Create a new API key using the button above
  2. +
  3. Copy the API key and secret from the success dialog
  4. +
  5. + Add the following widget configuration to your GetHomepage{" "} + + services.yml + {" "} + file: +
  6. +
+ +
+
+											{`- PatchMon:
+    href: ${server_url}
+    description: PatchMon Statistics
+    icon: ${server_url}/assets/favicon.svg
+    widget:
+      type: customapi
+      url: ${server_url}/api/v1/gethomepage/stats
+      headers:
+        Authorization: Basic BASE64_ENCODED_CREDENTIALS
+      mappings:
+        - field: total_hosts
+          label: Total Hosts
+        - field: hosts_needing_updates
+          label: Needs Updates
+        - field: security_updates
+          label: Security Updates`}
+										
+
+ +
+

+ + How to generate BASE64_ENCODED_CREDENTIALS: + +

+
+											{`echo -n "YOUR_API_KEY:YOUR_API_SECRET" | base64`}
+										
+

+ Replace YOUR_API_KEY and YOUR_API_SECRET with your actual + credentials, then run this command to get the base64 + string. +

+
+ +
+

+ Additional Widget Examples +

+

+ You can create multiple widgets to display different + statistics: +

+
+
+ Security Updates Widget: +
+ type: customapi +
+ key: security_updates +
+ value: hosts_with_security_updates +
+ label: Security Updates +
+
+ Up-to-Date Hosts Widget: +
+ type: customapi +
+ key: up_to_date_hosts +
+ value: total_hosts +
+ label: Up-to-Date Hosts +
+
+ Recent Activity Widget: +
+ type: customapi +
+ key: recent_updates_24h +
+ value: total_hosts +
+ label: Updates (24h) +
+
+
+
+
+ )}
@@ -406,7 +734,9 @@ const Integrations = () => {

- Create Auto-Enrollment Token + {activeTab === "gethomepage" + ? "Create GetHomepage API Key" + : "Create Auto-Enrollment Token"}

-
-
- -
-
- Token Secret -
-
- - - -
-
- -
-
- One-Line Installation Command -
-

- Run this command on your Proxmox host to download and - execute the enrollment script: -

- - {/* Force Install Toggle */} -
- -

- Enable this if your LXC containers have broken packages - (CloudPanel, WHM, etc.) that block apt-get operations -

-
- -
- - -
-

- 💡 This command will automatically discover and enroll all - running LXC containers. -

-
- - -
+
+ +
+
+ +

+ Important: Save these credentials - the + secret won't be shown again. +

+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+
+ + {activeTab === "proxmox" && ( +
+
+ One-Line Installation Command +
+

+ Run this command on your Proxmox host to download and + execute the enrollment script: +

+ + {/* Force Install Toggle */} +
+ +

+ Enable this if your LXC containers have broken packages + (CloudPanel, WHM, etc.) that block apt-get operations +

+
+ +
+ + +
+

+ 💡 This command will automatically discover and enroll all + running LXC containers. +

+
+ )} + + {activeTab === "gethomepage" && ( +
+
+ +
+ + +
+
+ +
+
+ + +
+