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
This commit is contained in:
Muhammad Ibrahim
2025-10-11 20:35:47 +01:00
parent 0ad1a96871
commit 8c538bd99c
13 changed files with 1111 additions and 283 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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;

View File

@@ -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",

View File

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

View File

@@ -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) {

View File

@@ -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"
}
# 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
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
# 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

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.2.7",
"version": "1.2.9",
"license": "AGPL-3.0",
"type": "module",
"scripts": {

View File

@@ -128,12 +128,14 @@ const VersionUpdateTab = () => {
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.github.latestRelease.tagName}
</span>
{versionInfo.github.latestRelease.publishedAt && (
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Published:{" "}
{new Date(
versionInfo.github.latestRelease.publishedAt,
).toLocaleDateString()}
</div>
)}
</div>
</div>
)}

View File

@@ -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);
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
</button>
<button
type="button"
onClick={() => handleTabChange("gethomepage")}
className={`px-6 py-3 text-sm font-medium ${
activeTab === "gethomepage"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
}`}
>
GetHomepage
</button>
{/* Future tabs can be added here */}
</div>
@@ -367,9 +424,20 @@ const Integrations = () => {
{/* Documentation Section */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200">
How to Use Auto-Enrollment
</h3>
<a
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/proxmox-lxc-auto-enrollment-guide"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white rounded-lg flex items-center gap-2 transition-colors"
>
<BookOpen className="h-4 w-4" />
Documentation
</a>
</div>
<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
<li>
Create a new auto-enrollment token using the button above
@@ -395,6 +463,266 @@ const Integrations = () => {
</div>
</div>
)}
{/* GetHomepage Tab */}
{activeTab === "gethomepage" && (
<div className="space-y-6">
{/* Header with New API Key Button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
GetHomepage Widget Integration
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Create API keys to display PatchMon statistics in your
GetHomepage dashboard
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
New API Key
</button>
</div>
{/* API Keys List */}
{loading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
) : tokens.filter(
(token) =>
token.metadata?.integration_type === "gethomepage",
).length === 0 ? (
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
<p>No GetHomepage API keys created yet.</p>
<p className="text-sm mt-2">
Create an API key to enable GetHomepage widget
integration.
</p>
</div>
) : (
<div className="space-y-3">
{tokens
.filter(
(token) =>
token.metadata?.integration_type === "gethomepage",
)
.map((token) => (
<div
key={token.id}
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-secondary-900 dark:text-white">
{token.token_name}
</h4>
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
GetHomepage
</span>
{token.is_active ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
Inactive
</span>
)}
</div>
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
{token.token_key}
</span>
<button
type="button"
onClick={() =>
copy_to_clipboard(
token.token_key,
`key-${token.id}`,
)
}
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
{copy_success[`key-${token.id}`] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
<p>Created: {format_date(token.created_at)}</p>
{token.last_used_at && (
<p>
Last Used: {format_date(token.last_used_at)}
</p>
)}
{token.expires_at && (
<p>
Expires: {format_date(token.expires_at)}
{new Date(token.expires_at) <
new Date() && (
<span className="ml-2 text-red-600 dark:text-red-400">
(Expired)
</span>
)}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
toggle_token_active(token.id, token.is_active)
}
className={`px-3 py-1 text-sm rounded ${
token.is_active
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
}`}
>
{token.is_active ? "Disable" : "Enable"}
</button>
<button
type="button"
onClick={() =>
delete_token(token.id, token.token_name)
}
className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Documentation Section */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200">
How to Use GetHomepage Integration
</h3>
<a
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/gethomepagedev-dashboard-card"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 text-white rounded-lg flex items-center gap-2 transition-colors"
>
<BookOpen className="h-4 w-4" />
Documentation
</a>
</div>
<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300">
<li>Create a new API key using the button above</li>
<li>Copy the API key and secret from the success dialog</li>
<li>
Add the following widget configuration to your GetHomepage{" "}
<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
services.yml
</code>{" "}
file:
</li>
</ol>
<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700">
<pre className="text-xs text-primary-800 dark:text-primary-300 whitespace-pre-wrap overflow-x-auto font-mono">
{`- 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`}
</pre>
</div>
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<p className="text-xs text-blue-800 dark:text-blue-300 mb-2">
<strong>
How to generate BASE64_ENCODED_CREDENTIALS:
</strong>
</p>
<pre className="text-xs text-blue-800 dark:text-blue-300 font-mono bg-blue-100 dark:bg-blue-900/40 p-2 rounded overflow-x-auto">
{`echo -n "YOUR_API_KEY:YOUR_API_SECRET" | base64`}
</pre>
<p className="text-xs text-blue-800 dark:text-blue-300 mt-2">
Replace YOUR_API_KEY and YOUR_API_SECRET with your actual
credentials, then run this command to get the base64
string.
</p>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded">
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">
Additional Widget Examples
</h4>
<p className="text-xs text-blue-800 dark:text-blue-300 mb-2">
You can create multiple widgets to display different
statistics:
</p>
<div className="space-y-2 text-xs text-blue-800 dark:text-blue-300 font-mono">
<div className="bg-blue-100 dark:bg-blue-900/40 p-2 rounded">
<strong>Security Updates Widget:</strong>
<br />
type: customapi
<br />
key: security_updates
<br />
value: hosts_with_security_updates
<br />
label: Security Updates
</div>
<div className="bg-blue-100 dark:bg-blue-900/40 p-2 rounded">
<strong>Up-to-Date Hosts Widget:</strong>
<br />
type: customapi
<br />
key: up_to_date_hosts
<br />
value: total_hosts
<br />
label: Up-to-Date Hosts
</div>
<div className="bg-blue-100 dark:bg-blue-900/40 p-2 rounded">
<strong>Recent Activity Widget:</strong>
<br />
type: customapi
<br />
key: recent_updates_24h
<br />
value: total_hosts
<br />
label: Updates (24h)
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -406,7 +734,9 @@ const Integrations = () => {
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
Create Auto-Enrollment Token
{activeTab === "gethomepage"
? "Create GetHomepage API Key"
: "Create Auto-Enrollment Token"}
</h2>
<button
type="button"
@@ -429,11 +759,17 @@ const Integrations = () => {
onChange={(e) =>
setFormData({ ...form_data, token_name: e.target.value })
}
placeholder="e.g., Proxmox Production"
placeholder={
activeTab === "gethomepage"
? "e.g., GetHomepage Widget"
: "e.g., Proxmox Production"
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</label>
{activeTab === "proxmox" && (
<>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Max Hosts Per Day
@@ -452,8 +788,8 @@ const Integrations = () => {
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Maximum number of hosts that can be enrolled per day using
this token
Maximum number of hosts that can be enrolled per day
using this token
</p>
</label>
@@ -482,6 +818,8 @@ const Integrations = () => {
Auto-enrolled hosts will be assigned to this group
</p>
</label>
</>
)}
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
@@ -543,126 +881,138 @@ const Integrations = () => {
{/* New Token Display Modal */}
{new_token && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full">
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-start gap-3 mb-6">
<div className="flex-shrink-0">
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
Token Created Successfully
</h2>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Save these credentials now - the secret will not be shown
again!
</p>
</div>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
<strong>Important:</strong> Store the token secret securely.
You will not be able to view it again after closing this
dialog.
</p>
</div>
</div>
<div className="space-y-4">
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Token Name
</div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
{activeTab === "gethomepage"
? "API Key Created Successfully"
: "Token Created Successfully"}
</h2>
</div>
<button
type="button"
onClick={() => {
setNewToken(null);
setShowSecret(false);
}}
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0" />
<p className="text-xs text-yellow-800 dark:text-yellow-200">
<strong>Important:</strong> Save these credentials - the
secret won't be shown again.
</p>
</div>
</div>
<div className="space-y-3">
<div>
<label
htmlFor={token_name_id}
className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Token Name
</label>
<input
id={token_name_id}
type="text"
value={new_token.token_name}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
className="w-full px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor={token_key_id}
className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Token Key
</div>
</label>
<div className="flex items-center gap-2">
<input
id={token_key_id}
type="text"
value={new_token.token_key}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(new_token.token_key, "new-key")
}
className="btn-primary flex items-center gap-1 px-3 py-2"
className="btn-primary p-2"
title="Copy Key"
>
{copy_success["new-key"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
</div>
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<label
htmlFor={token_secret_id}
className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Token Secret
</div>
</label>
<div className="flex items-center gap-2">
<input
id={token_secret_id}
type={show_secret ? "text" : "password"}
value={new_token.token_secret}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm"
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
/>
<button
type="button"
onClick={() => setShowSecret(!show_secret)}
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200"
className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400"
title="Toggle visibility"
>
{show_secret ? (
<EyeOff className="h-5 w-5" />
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-5 w-5" />
<Eye className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={() =>
copy_to_clipboard(new_token.token_secret, "new-secret")
copy_to_clipboard(
new_token.token_secret,
"new-secret",
)
}
className="btn-primary flex items-center gap-1 px-3 py-2"
className="btn-primary p-2"
title="Copy Secret"
>
{copy_success["new-secret"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
</div>
</div>
{activeTab === "proxmox" && (
<div className="mt-6">
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
One-Line Installation Command
@@ -728,16 +1078,142 @@ const Integrations = () => {
running LXC containers.
</p>
</div>
)}
{activeTab === "gethomepage" && (
<div className="mt-3 space-y-3">
<div>
<label
htmlFor={token_base64_id}
className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Base64 Encoded Credentials
</label>
<div className="flex items-center gap-2">
<input
id={token_base64_id}
type="text"
value={btoa(
`${new_token.token_key}:${new_token.token_secret}`,
)}
readOnly
className="flex-1 px-3 py-2 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
btoa(
`${new_token.token_key}:${new_token.token_secret}`,
),
"base64-creds",
)
}
className="btn-primary p-2"
title="Copy Base64"
>
{copy_success["base64-creds"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="flex gap-3 pt-6">
<div>
<div className="flex items-center justify-between mb-1">
<label
htmlFor={gethomepage_config_id}
className="text-xs font-medium text-secondary-700 dark:text-secondary-300"
>
GetHomepage Configuration
</label>
<button
type="button"
onClick={() => {
const base64Creds = btoa(
`${new_token.token_key}:${new_token.token_secret}`,
);
const config = `- 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 ${base64Creds}
mappings:
- field: total_hosts
label: Total Hosts
- field: hosts_needing_updates
label: Needs Updates
- field: security_updates
label: Security Updates`;
copy_to_clipboard(config, "gethomepage-config");
}}
className="btn-primary flex items-center gap-1 px-2 py-1 text-xs"
>
{copy_success["gethomepage-config"] ? (
<>
<CheckCircle className="h-3 w-3" />
Copied
</>
) : (
<>
<Copy className="h-3 w-3" />
Copy Config
</>
)}
</button>
</div>
<textarea
id={gethomepage_config_id}
value={(() => {
const base64Creds = btoa(
`${new_token.token_key}:${new_token.token_secret}`,
);
return `- 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 ${base64Creds}
mappings:
- field: total_hosts
label: Total Hosts
- field: hosts_needing_updates
label: Needs Updates
- field: security_updates
label: Security Updates`;
})()}
readOnly
rows={12}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs resize-none"
/>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
💡 Paste into your GetHomepage{" "}
<code className="bg-secondary-100 dark:bg-secondary-700 px-1 rounded">
services.yml
</code>
</p>
</div>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
<button
type="button"
onClick={() => {
setNewToken(null);
setShowSecret(false);
}}
className="flex-1 btn-primary py-2 px-4 rounded-md"
className="w-full btn-primary py-2 px-4 rounded-md"
>
I've Saved the Credentials
</button>

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.2.7",
"version": "1.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.2.7",
"version": "1.2.9",
"license": "AGPL-3.0",
"workspaces": [
"backend",
@@ -23,7 +23,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.2.7",
"version": "1.2.9",
"license": "AGPL-3.0",
"dependencies": {
"@bull-board/api": "^6.13.0",
@@ -56,7 +56,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.2.7",
"version": "1.2.9",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.2.7",
"version": "1.2.9",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,

View File

@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
SCRIPT_VERSION="self-hosting-install.sh v1.2.9-selfhost-2025-10-11-1"
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
FQDN=""
CUSTOM_FQDN=""
@@ -834,7 +834,7 @@ EOF
cat > frontend/.env << EOF
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.2.7
VITE_APP_VERSION=1.2.9
EOF
print_status "Environment files created"
@@ -1206,7 +1206,7 @@ create_agent_version() {
# Priority 2: Use fallback version if not found
if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then
current_version="1.2.7"
current_version="1.2.9"
print_warning "Could not determine version, using fallback: $current_version"
fi