Merge pull request #414 from PatchMon/1-3-8

1 3 8 #1
This commit is contained in:
9 Technology Group LTD
2026-01-04 18:13:11 +00:00
committed by GitHub
33 changed files with 1822 additions and 449 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -160,7 +160,12 @@ router.get(
}
// Calculate statistics for this specific host
const [totalInstalledPackages, outdatedPackagesCount, securityUpdatesCount, totalRepos] = await Promise.all([
const [
totalInstalledPackages,
outdatedPackagesCount,
securityUpdatesCount,
totalRepos,
] = await Promise.all([
// Total packages installed on this host
prisma.host_packages.count({
where: {
@@ -295,7 +300,9 @@ router.get(
});
} catch (error) {
console.error("Error fetching host network info:", error);
res.status(500).json({ error: "Failed to fetch host network information" });
res
.status(500)
.json({ error: "Failed to fetch host network information" });
}
},
);
@@ -351,7 +358,9 @@ router.get(
});
} catch (error) {
console.error("Error fetching host system info:", error);
res.status(500).json({ error: "Failed to fetch host system information" });
res
.status(500)
.json({ error: "Failed to fetch host system information" });
}
},
);
@@ -465,17 +474,17 @@ router.get(
try {
// Try to get live queue stats from Bull/BullMQ if available
const { queueManager } = require("../services/automation");
if (queueManager && queueManager.getQueue) {
const agentQueue = queueManager.getQueue(host.api_id);
if (agentQueue) {
const counts = await agentQueue.getJobCounts();
queueStats = {
waiting: counts.waiting || 0,
active: counts.active || 0,
delayed: counts.delayed || 0,
failed: counts.failed || 0,
};
}
if (queueManager && queueManager.getHostJobs) {
const hostQueueData = await queueManager.getHostJobs(
host.api_id,
Number.parseInt(limit, 10),
);
queueStats = {
waiting: hostQueueData.waiting || 0,
active: hostQueueData.active || 0,
delayed: hostQueueData.delayed || 0,
failed: hostQueueData.failed || 0,
};
}
} catch (queueError) {
console.warn("Could not fetch live queue stats:", queueError.message);
@@ -577,7 +586,8 @@ router.get(
containers_count: containers,
volumes_count: volumes,
networks_count: networks,
description: "Monitor Docker containers, images, volumes, and networks. Collects real-time container status events.",
description:
"Monitor Docker containers, images, volumes, and networks. Collects real-time container status events.",
};
}
@@ -586,7 +596,8 @@ router.get(
integrations: {
docker: dockerDetails || {
enabled: false,
description: "Monitor Docker containers, images, volumes, and networks. Collects real-time container status events.",
description:
"Monitor Docker containers, images, volumes, and networks. Collects real-time container status events.",
},
},
});

View File

@@ -414,8 +414,9 @@ router.put(
req.body;
const updateData = {};
if (username) updateData.username = username;
if (email) updateData.email = email;
// Handle all fields consistently - trim and update if provided
if (username) updateData.username = username.trim();
if (email) updateData.email = email.trim().toLowerCase();
if (first_name !== undefined) updateData.first_name = first_name || null;
if (last_name !== undefined) updateData.last_name = last_name || null;
if (role) updateData.role = role;
@@ -438,8 +439,17 @@ router.put(
{ id: { not: userId } },
{
OR: [
...(username ? [{ username }] : []),
...(email ? [{ email }] : []),
...(username
? [
{
username: {
equals: username.trim(),
mode: "insensitive",
},
},
]
: []),
...(email ? [{ email: email.trim().toLowerCase() }] : []),
],
},
],
@@ -859,12 +869,10 @@ router.post(
// Get accepted release notes versions
let acceptedVersions = [];
try {
if (prisma.release_notes_acceptances) {
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
}
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
} catch (error) {
// If table doesn't exist yet or Prisma client not regenerated, use empty array
console.warn(
@@ -933,7 +941,10 @@ router.post(
// Find user
const user = await prisma.users.findFirst({
where: {
OR: [{ username }, { email: username }],
OR: [
{ username: { equals: username, mode: "insensitive" } },
{ email: username.toLowerCase() },
],
is_active: true,
tfa_enabled: true,
},
@@ -1022,12 +1033,10 @@ router.post(
// Get accepted release notes versions
let acceptedVersions = [];
try {
if (prisma.release_notes_acceptances) {
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
}
acceptedVersions = await prisma.release_notes_acceptances.findMany({
where: { user_id: user.id },
select: { version: true },
});
} catch (error) {
// If table doesn't exist yet or Prisma client not regenerated, use empty array
console.warn(

View File

@@ -344,9 +344,16 @@ router.patch(
authenticateToken,
requireManageSettings,
[
body("token_name")
.optional()
.isLength({ min: 1, max: 255 })
.withMessage("Token name must be between 1 and 255 characters"),
body("is_active").optional().isBoolean(),
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(),
body("default_host_group_id")
.optional({ nullable: true, checkFalsy: true })
.isString(),
body("expires_at").optional().isISO8601(),
body("scopes")
.optional()
@@ -373,6 +380,9 @@ router.patch(
const update_data = { updated_at: new Date() };
// Allow updating token name
if (req.body.token_name !== undefined)
update_data.token_name = req.body.token_name;
if (req.body.is_active !== undefined)
update_data.is_active = req.body.is_active;
if (req.body.max_hosts_per_day !== undefined)
@@ -382,6 +392,25 @@ router.patch(
if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at);
// Handle default host group update
if (req.body.default_host_group_id !== undefined) {
if (req.body.default_host_group_id) {
// Validate host group exists
const host_group = await prisma.host_groups.findUnique({
where: { id: req.body.default_host_group_id },
});
if (!host_group) {
return res.status(400).json({ error: "Host group not found" });
}
update_data.default_host_group_id = req.body.default_host_group_id;
} else {
// Allow clearing the default host group
update_data.default_host_group_id = null;
}
}
// Handle scopes updates for API tokens only
if (req.body.scopes !== undefined) {
if (existing_token.metadata?.integration_type === "api") {
@@ -421,9 +450,16 @@ router.patch(
where: { id: tokenId },
data: update_data,
include: {
host_groups: true,
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,

View File

@@ -532,7 +532,7 @@ router.post(
.withMessage("Disk details must be an array"),
// Network Information
body("gatewayIp")
.optional()
.optional({ checkFalsy: true })
.isIP()
.withMessage("Gateway IP must be a valid IP address"),
body("dnsServers")
@@ -625,7 +625,14 @@ router.post(
if (req.body.diskDetails) updateData.disk_details = req.body.diskDetails;
// Network Information
if (req.body.gatewayIp) updateData.gateway_ip = req.body.gatewayIp;
if (req.body.gatewayIp) {
updateData.gateway_ip = req.body.gatewayIp;
} else if (Object.hasOwn(req.body, "gatewayIp")) {
// Log warning if gateway field was sent but empty (isolated network)
console.warn(
`Host ${host.hostname} reported with no default gateway configured`,
);
}
if (req.body.dnsServers) updateData.dns_servers = req.body.dnsServers;
if (req.body.networkInterfaces)
updateData.network_interfaces = req.body.networkInterfaces;

View File

@@ -354,14 +354,29 @@ router.post(
const _crypto = require("node:crypto");
// Create assets directory if it doesn't exist
// In Docker: use ASSETS_DIR environment variable (mounted volume)
// In development: save to public/assets (served by Vite)
// In production: save to dist/assets (served by built app)
const isDevelopment = process.env.NODE_ENV !== "production";
const assetsDir = isDevelopment
? path.join(__dirname, "../../../frontend/public/assets")
: path.join(__dirname, "../../../frontend/dist/assets");
// In production (non-Docker): save to public/assets (build copies to dist/)
const assetsDir = process.env.ASSETS_DIR
? path.resolve(process.env.ASSETS_DIR)
: path.resolve(__dirname, "../../../frontend/public/assets");
console.log(`📁 Assets directory: ${assetsDir}`);
await fs.mkdir(assetsDir, { recursive: true });
// Verify directory exists
const dirExists = await fs
.access(assetsDir)
.then(() => true)
.catch(() => false);
if (!dirExists) {
throw new Error(
`Failed to create or access assets directory: ${assetsDir}`,
);
}
// Determine file extension and path
let fileExtension;
let fileName_final;
@@ -386,7 +401,8 @@ router.post(
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
}
const filePath = path.join(assetsDir, fileName_final);
const filePath = path.resolve(assetsDir, fileName_final);
console.log(`📄 Full file path: ${filePath}`);
// Handle base64 data URLs
let fileBuffer;
@@ -412,6 +428,17 @@ router.post(
// Write new logo file
await fs.writeFile(filePath, fileBuffer);
console.log(`✅ Logo file written to: ${filePath}`);
// Verify file was written
const fileExists = await fs
.access(filePath)
.then(() => true)
.catch(() => false);
if (!fileExists) {
throw new Error(`Failed to verify file was written: ${filePath}`);
}
// Update settings with new logo path
const settings = await getSettings();
@@ -426,7 +453,10 @@ router.post(
updateData.favicon = logoPath;
}
await updateSettings(settings.id, updateData);
const updatedSettings = await updateSettings(settings.id, updateData);
console.log(
`✅ Settings updated with new ${logoType} logo path: ${logoPath}`,
);
// Get file stats
const stats = await fs.stat(filePath);
@@ -437,10 +467,15 @@ router.post(
path: logoPath,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
timestamp: updatedSettings.updated_at.getTime(), // Include timestamp for cache busting
});
} catch (error) {
console.error("Upload logo error:", error);
res.status(500).json({ error: "Failed to upload logo" });
res.status(500).json({
error: "Failed to upload logo",
details: error.message || "Unknown error",
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
},
);

View File

@@ -0,0 +1,27 @@
const express = require("express");
const {
socialMediaStatsCache,
} = require("../services/automation/socialMediaStats");
const router = express.Router();
// Get social media statistics from cache
router.get("/", async (_req, res) => {
try {
res.json({
github_stars: socialMediaStatsCache.github_stars,
discord_members: socialMediaStatsCache.discord_members,
buymeacoffee_supporters: socialMediaStatsCache.buymeacoffee_supporters,
youtube_subscribers: socialMediaStatsCache.youtube_subscribers,
linkedin_followers: socialMediaStatsCache.linkedin_followers,
last_updated: socialMediaStatsCache.last_updated,
});
} catch (error) {
console.error("Error fetching social media stats:", error);
res.status(500).json({
error: "Failed to fetch social media statistics",
});
}
});
module.exports = router;

View File

@@ -75,6 +75,7 @@ const apiHostsRoutes = require("./routes/apiHostsRoutes");
const releaseNotesRoutes = require("./routes/releaseNotesRoutes");
const releaseNotesAcceptanceRoutes = require("./routes/releaseNotesAcceptanceRoutes");
const buyMeACoffeeRoutes = require("./routes/buyMeACoffeeRoutes");
const socialMediaStatsRoutes = require("./routes/socialMediaStatsRoutes");
const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -385,7 +386,7 @@ app.use(
credentials: true,
// Additional CORS options for better cookie handling
optionsSuccessStatus: 200,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
@@ -491,6 +492,7 @@ app.use(
releaseNotesAcceptanceRoutes,
);
app.use(`/api/${apiVersion}/buy-me-a-coffee`, buyMeACoffeeRoutes);
app.use(`/api/${apiVersion}/social-media-stats`, socialMediaStatsRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;
@@ -900,6 +902,9 @@ async function startServer() {
// Schedule recurring jobs
await queueManager.scheduleAllJobs();
// Trigger social media stats collection on boot
await queueManager.triggerSocialMediaStats();
// Set up Bull Board for queue monitoring
const serverAdapter = new ExpressAdapter();
// Set basePath to match where we mount the router

View File

@@ -1,6 +1,5 @@
const { prisma } = require("./shared/prisma");
const https = require("node:https");
const http = require("node:http");
const { v4: uuidv4 } = require("uuid");
/**
@@ -58,7 +57,9 @@ class DockerImageUpdateCheck {
const params = {};
const regex = /(\w+)="([^"]+)"/g;
let match;
while ((match = regex.exec(header)) !== null) {
while (true) {
match = regex.exec(header);
if (match === null) break;
params[match[1]] = match[2];
}
@@ -105,7 +106,9 @@ class DockerImageUpdateCheck {
const response = await this.httpsRequest(options);
if (response.statusCode !== 200) {
throw new Error(`Token request failed with status ${response.statusCode}`);
throw new Error(
`Token request failed with status ${response.statusCode}`,
);
}
const tokenData = JSON.parse(response.body);
@@ -188,14 +191,15 @@ class DockerImageUpdateCheck {
// Get digest from Docker-Content-Digest header
const digest = response.headers["docker-content-digest"];
if (!digest) {
throw new Error(`No Docker-Content-Digest header for ${imageName}:${tag}`);
throw new Error(
`No Docker-Content-Digest header for ${imageName}:${tag}`,
);
}
// Clean up digest (remove sha256: prefix if present)
return digest.startsWith("sha256:") ? digest.substring(7) : digest;
}
/**
* Parse image name to extract registry and repository
*/
@@ -214,7 +218,11 @@ class DockerImageUpdateCheck {
const firstPart = parts[0];
// Check if first part looks like a registry (contains . or : or is localhost)
if (firstPart.includes(".") || firstPart.includes(":") || firstPart === "localhost") {
if (
firstPart.includes(".") ||
firstPart.includes(":") ||
firstPart === "localhost"
) {
registry = firstPart;
repository = parts.slice(1).join("/");
}

View File

@@ -14,6 +14,7 @@ const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
const MetricsReporting = require("./metricsReporting");
const SystemStatistics = require("./systemStatistics");
const SocialMediaStats = require("./socialMediaStats");
// Queue names
const QUEUE_NAMES = {
@@ -25,6 +26,7 @@ const QUEUE_NAMES = {
DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
METRICS_REPORTING: "metrics-reporting",
SYSTEM_STATISTICS: "system-statistics",
SOCIAL_MEDIA_STATS: "social-media-stats",
AGENT_COMMANDS: "agent-commands",
};
@@ -111,6 +113,9 @@ class QueueManager {
this.automations[QUEUE_NAMES.SYSTEM_STATISTICS] = new SystemStatistics(
this,
);
this.automations[QUEUE_NAMES.SOCIAL_MEDIA_STATS] = new SocialMediaStats(
this,
);
console.log("✅ All automation classes initialized");
}
@@ -205,6 +210,15 @@ class QueueManager {
workerOptions,
);
// Social Media Stats Worker
this.workers[QUEUE_NAMES.SOCIAL_MEDIA_STATS] = new Worker(
QUEUE_NAMES.SOCIAL_MEDIA_STATS,
this.automations[QUEUE_NAMES.SOCIAL_MEDIA_STATS].process.bind(
this.automations[QUEUE_NAMES.SOCIAL_MEDIA_STATS],
),
workerOptions,
);
// Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS,
@@ -372,6 +386,7 @@ class QueueManager {
await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
await this.automations[QUEUE_NAMES.SYSTEM_STATISTICS].schedule();
await this.automations[QUEUE_NAMES.SOCIAL_MEDIA_STATS].schedule();
}
/**
@@ -415,6 +430,10 @@ class QueueManager {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
}
async triggerSocialMediaStats() {
return this.automations[QUEUE_NAMES.SOCIAL_MEDIA_STATS].triggerManual();
}
/**
* Get queue statistics
*/

View File

@@ -0,0 +1,710 @@
const axios = require("axios");
// In-memory cache for social media statistics
const socialMediaStatsCache = {
github_stars: null,
discord_members: null,
buymeacoffee_supporters: null,
youtube_subscribers: null,
linkedin_followers: null,
last_updated: null,
};
/**
* Helper function to parse subscriber/follower count from text with K/M/B suffixes
*/
function parseCount(text) {
if (!text) return null;
// Remove commas and extract numbers
const cleanText = text.replace(/,/g, "").trim();
// Match patterns like "1.2K", "1.2M", "123K", etc.
const match = cleanText.match(/([\d.]+)\s*([KMB])?/i);
if (match) {
let count = parseFloat(match[1]);
const suffix = match[2]?.toUpperCase();
if (suffix === "K") {
count *= 1000;
} else if (suffix === "M") {
count *= 1000000;
} else if (suffix === "B") {
count *= 1000000000;
}
return Math.floor(count);
}
// Try to find just numbers
const numbers = cleanText.match(/\d+/);
if (numbers) {
return parseInt(numbers[0], 10);
}
return null;
}
/**
* Scrape Buy Me a Coffee supporter count
* Extracted from buyMeACoffeeRoutes.js
*/
async function scrapeBuyMeACoffee() {
try {
const response = await axios.get("https://buymeacoffee.com/iby___", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
timeout: 10000,
});
const html = response.data;
let supporterCount = null;
// Pattern 1: Look for "X supporters" text
const textPatterns = [
/(\d+)\s+supporters?/i,
/(\d+)\s+people\s+(have\s+)?(bought|supported)/i,
/supporter[^>]*>.*?(\d+)/i,
/(\d+)[^<]*supporter/i,
/>(\d+)<[^>]*supporter/i,
/supporter[^<]*<[^>]*>(\d+)/i,
];
for (const pattern of textPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
supporterCount = count;
break;
}
}
}
// Pattern 2: Look for data attributes
if (!supporterCount) {
const dataPatterns = [
/data-supporters?=["'](\d+)["']/i,
/data-count=["'](\d+)["']/i,
/supporter[^>]*data-[^=]*=["'](\d+)["']/i,
];
for (const pattern of dataPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
supporterCount = count;
break;
}
}
}
}
// Pattern 3: Look for JSON-LD structured data
if (!supporterCount) {
const jsonLdMatches = html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>(.*?)<\/script>/gis,
);
for (const jsonLdMatch of jsonLdMatches) {
try {
const jsonLd = JSON.parse(jsonLdMatch[1]);
const findCount = (obj) => {
if (typeof obj !== "object" || obj === null) return null;
if (obj.supporterCount || obj.supporter_count || obj.supporters) {
return parseInt(
obj.supporterCount || obj.supporter_count || obj.supporters,
10,
);
}
for (const value of Object.values(obj)) {
if (typeof value === "object") {
const found = findCount(value);
if (found) return found;
}
}
return null;
};
const count = findCount(jsonLd);
if (count && count > 0 && count < 1000000) {
supporterCount = count;
break;
}
} catch (_e) {
// Ignore JSON parse errors
}
}
}
// Pattern 4: Look for class names or IDs
if (!supporterCount) {
const classPatterns = [
/class="[^"]*supporter[^"]*"[^>]*>.*?(\d+)/i,
/id="[^"]*supporter[^"]*"[^>]*>.*?(\d+)/i,
/<span[^>]*class="[^"]*count[^"]*"[^>]*>(\d+)<\/span>/i,
];
for (const pattern of classPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 1000000) {
supporterCount = count;
break;
}
}
}
}
// Pattern 5: Look for numbers near supporter-related text
if (!supporterCount) {
const numberMatches = html.matchAll(/\b(\d{1,6})\b/g);
for (const match of numberMatches) {
const num = parseInt(match[1], 10);
if (num > 0 && num < 1000000) {
const start = Math.max(0, match.index - 200);
const end = Math.min(
html.length,
match.index + match[0].length + 200,
);
const context = html.substring(start, end).toLowerCase();
if (
context.includes("supporter") ||
context.includes("coffee") ||
context.includes("donation")
) {
supporterCount = num;
break;
}
}
}
}
return supporterCount;
} catch (error) {
console.error("Error scraping Buy Me a Coffee:", error.message);
return null;
}
}
/**
* Scrape Discord member count from invitation page
*/
async function scrapeDiscord() {
try {
const response = await axios.get("https://patchmon.net/discord", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
timeout: 10000,
maxRedirects: 5,
});
const html = response.data;
let memberCount = null;
// Pattern 1: Look for "X members" or "X online" text
const textPatterns = [
/(\d+)\s+members?/i,
/(\d+)\s+online/i,
/member[^>]*>.*?(\d+)/i,
/(\d+)[^<]*member/i,
/>(\d+)<[^>]*member/i,
];
for (const pattern of textPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 10000000) {
memberCount = count;
break;
}
}
}
// Pattern 2: Look for data attributes
if (!memberCount) {
const dataPatterns = [
/data-members?=["'](\d+)["']/i,
/data-count=["'](\d+)["']/i,
/member[^>]*data-[^=]*=["'](\d+)["']/i,
];
for (const pattern of dataPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseInt(match[1], 10);
if (count > 0 && count < 10000000) {
memberCount = count;
break;
}
}
}
}
// Pattern 3: Look for JSON-LD or meta tags
if (!memberCount) {
const jsonLdMatches = html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>(.*?)<\/script>/gis,
);
for (const jsonLdMatch of jsonLdMatches) {
try {
const jsonLd = JSON.parse(jsonLdMatch[1]);
const findCount = (obj) => {
if (typeof obj !== "object" || obj === null) return null;
if (obj.memberCount || obj.member_count || obj.members) {
return parseInt(
obj.memberCount || obj.member_count || obj.members,
10,
);
}
for (const value of Object.values(obj)) {
if (typeof value === "object") {
const found = findCount(value);
if (found) return found;
}
}
return null;
};
const count = findCount(jsonLd);
if (count && count > 0 && count < 10000000) {
memberCount = count;
break;
}
} catch (_e) {
// Ignore JSON parse errors
}
}
}
// Pattern 4: Look for numbers near member-related text
if (!memberCount) {
const numberMatches = html.matchAll(/\b(\d{1,7})\b/g);
for (const match of numberMatches) {
const num = parseInt(match[1], 10);
if (num > 0 && num < 10000000) {
const start = Math.max(0, match.index - 200);
const end = Math.min(
html.length,
match.index + match[0].length + 200,
);
const context = html.substring(start, end).toLowerCase();
if (
context.includes("member") ||
context.includes("discord") ||
context.includes("online")
) {
memberCount = num;
break;
}
}
}
}
return memberCount;
} catch (error) {
console.error("Error scraping Discord:", error.message);
return null;
}
}
/**
* Scrape YouTube subscriber count
*/
async function scrapeYouTube() {
try {
const response = await axios.get("https://www.youtube.com/@patchmonTV", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
timeout: 10000,
});
const html = response.data;
let subscriberCount = null;
// Pattern 1: Look for ytInitialData JavaScript variable
const ytInitialDataMatch = html.match(/var ytInitialData = ({.+?});/);
if (ytInitialDataMatch) {
try {
const ytData = JSON.parse(ytInitialDataMatch[1]);
// Navigate through the nested structure to find subscriber count
const findSubscriberCount = (obj, depth = 0) => {
if (depth > 10) return null;
if (typeof obj !== "object" || obj === null) return null;
// Check for subscriber count in various possible locations
if (obj.subscriberCount || obj.subscriber_count || obj.subscribers) {
const count = parseCount(
String(
obj.subscriberCount || obj.subscriber_count || obj.subscribers,
),
);
if (count && count > 0) return count;
}
// Check for subscriber text
if (typeof obj === "string" && obj.includes("subscriber")) {
const count = parseCount(obj);
if (count && count > 0) return count;
}
// Recursively search
if (Array.isArray(obj)) {
for (const item of obj) {
const found = findSubscriberCount(item, depth + 1);
if (found) return found;
}
} else {
for (const value of Object.values(obj)) {
const found = findSubscriberCount(value, depth + 1);
if (found) return found;
}
}
return null;
};
subscriberCount = findSubscriberCount(ytData);
} catch (_e) {
// Ignore JSON parse errors
}
}
// Pattern 2: Look for subscriber count in HTML text
if (!subscriberCount) {
const subscriberPatterns = [
/(\d+(?:\.\d+)?[KMB]?)\s+subscribers?/i,
/subscribers?[^>]*>.*?(\d+(?:\.\d+)?[KMB]?)/i,
/(\d+(?:\.\d+)?[KMB]?)[^<]*subscriber/i,
];
for (const pattern of subscriberPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseCount(match[1]);
if (count && count > 0) {
subscriberCount = count;
break;
}
}
}
}
// Pattern 3: Look for numbers near subscriber-related text
if (!subscriberCount) {
const numberMatches = html.matchAll(/(\d+(?:\.\d+)?[KMB]?)/gi);
for (const match of numberMatches) {
const start = Math.max(0, match.index - 100);
const end = Math.min(html.length, match.index + match[0].length + 100);
const context = html.substring(start, end).toLowerCase();
if (context.includes("subscriber")) {
const count = parseCount(match[1]);
if (count && count > 0) {
subscriberCount = count;
break;
}
}
}
}
return subscriberCount;
} catch (error) {
console.error("Error scraping YouTube:", error.message);
return null;
}
}
/**
* Scrape LinkedIn follower count
*/
async function scrapeLinkedIn() {
try {
const response = await axios.get("https://linkedin.com/company/patchmon", {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
timeout: 10000,
});
const html = response.data;
let followerCount = null;
// Pattern 1: Look for follower count in text
const textPatterns = [
/(\d+(?:\.\d+)?[KMB]?)\s+followers?/i,
/followers?[^>]*>.*?(\d+(?:\.\d+)?[KMB]?)/i,
/(\d+(?:\.\d+)?[KMB]?)[^<]*follower/i,
];
for (const pattern of textPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseCount(match[1]);
if (count && count > 0) {
followerCount = count;
break;
}
}
}
// Pattern 2: Look for data attributes
if (!followerCount) {
const dataPatterns = [
/data-followers?=["'](\d+(?:\.\d+)?[KMB]?)["']/i,
/data-count=["'](\d+(?:\.\d+)?[KMB]?)["']/i,
/follower[^>]*data-[^=]*=["'](\d+(?:\.\d+)?[KMB]?)["']/i,
];
for (const pattern of dataPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
const count = parseCount(match[1]);
if (count && count > 0) {
followerCount = count;
break;
}
}
}
}
// Pattern 3: Look for JSON-LD structured data
if (!followerCount) {
const jsonLdMatches = html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>(.*?)<\/script>/gis,
);
for (const jsonLdMatch of jsonLdMatches) {
try {
const jsonLd = JSON.parse(jsonLdMatch[1]);
const findCount = (obj) => {
if (typeof obj !== "object" || obj === null) return null;
if (obj.followerCount || obj.follower_count || obj.followers) {
return parseCount(
String(
obj.followerCount || obj.follower_count || obj.followers,
),
);
}
for (const value of Object.values(obj)) {
if (typeof value === "object") {
const found = findCount(value);
if (found) return found;
}
}
return null;
};
const count = findCount(jsonLd);
if (count && count > 0) {
followerCount = count;
break;
}
} catch (_e) {
// Ignore JSON parse errors
}
}
}
// Pattern 4: Look for numbers near follower-related text
if (!followerCount) {
const numberMatches = html.matchAll(/(\d+(?:\.\d+)?[KMB]?)/gi);
for (const match of numberMatches) {
const start = Math.max(0, match.index - 100);
const end = Math.min(html.length, match.index + match[0].length + 100);
const context = html.substring(start, end).toLowerCase();
if (context.includes("follower")) {
const count = parseCount(match[1]);
if (count && count > 0) {
followerCount = count;
break;
}
}
}
}
return followerCount;
} catch (error) {
console.error("Error scraping LinkedIn:", error.message);
return null;
}
}
/**
* Social Media Stats Collection Automation
* Fetches social media statistics and stores them in memory cache
*/
class SocialMediaStats {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "social-media-stats";
}
/**
* Process social media stats collection job
*/
async process(_job) {
const startTime = Date.now();
console.log("📊 Starting social media stats collection...");
const results = {
github_stars: null,
discord_members: null,
buymeacoffee_supporters: null,
youtube_subscribers: null,
linkedin_followers: null,
};
try {
// Fetch GitHub stars
try {
const response = await axios.get(
"https://api.github.com/repos/PatchMon/PatchMon",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
timeout: 10000,
},
);
if (response.data?.stargazers_count) {
results.github_stars = response.data.stargazers_count;
console.log(`✅ GitHub stars: ${results.github_stars}`);
}
} catch (error) {
console.error("Error fetching GitHub stars:", error.message);
}
// Scrape Discord members
try {
const discordCount = await scrapeDiscord();
if (discordCount !== null) {
results.discord_members = discordCount;
console.log(`✅ Discord members: ${results.discord_members}`);
}
} catch (error) {
console.error("Error scraping Discord:", error.message);
}
// Scrape Buy Me a Coffee supporters
try {
const bmcCount = await scrapeBuyMeACoffee();
if (bmcCount !== null) {
results.buymeacoffee_supporters = bmcCount;
console.log(
`✅ Buy Me a Coffee supporters: ${results.buymeacoffee_supporters}`,
);
}
} catch (error) {
console.error("Error scraping Buy Me a Coffee:", error.message);
}
// Scrape YouTube subscribers
try {
const youtubeCount = await scrapeYouTube();
if (youtubeCount !== null) {
results.youtube_subscribers = youtubeCount;
console.log(`✅ YouTube subscribers: ${results.youtube_subscribers}`);
}
} catch (error) {
console.error("Error scraping YouTube:", error.message);
}
// Scrape LinkedIn followers
try {
const linkedinCount = await scrapeLinkedIn();
if (linkedinCount !== null) {
results.linkedin_followers = linkedinCount;
console.log(`✅ LinkedIn followers: ${results.linkedin_followers}`);
}
} catch (error) {
console.error("Error scraping LinkedIn:", error.message);
}
// Update cache - only update fields that successfully fetched
// Preserve existing values if fetch failed
if (results.github_stars !== null) {
socialMediaStatsCache.github_stars = results.github_stars;
}
if (results.discord_members !== null) {
socialMediaStatsCache.discord_members = results.discord_members;
}
if (results.buymeacoffee_supporters !== null) {
socialMediaStatsCache.buymeacoffee_supporters =
results.buymeacoffee_supporters;
}
if (results.youtube_subscribers !== null) {
socialMediaStatsCache.youtube_subscribers = results.youtube_subscribers;
}
if (results.linkedin_followers !== null) {
socialMediaStatsCache.linkedin_followers = results.linkedin_followers;
}
// Update last_updated timestamp if at least one stat was fetched
if (
results.github_stars !== null ||
results.discord_members !== null ||
results.buymeacoffee_supporters !== null ||
results.youtube_subscribers !== null ||
results.linkedin_followers !== null
) {
socialMediaStatsCache.last_updated = new Date();
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Social media stats collection completed in ${executionTime}ms`,
);
return {
success: true,
...results,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Social media stats collection failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring social media stats collection (daily at midnight)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"social-media-stats",
{},
{
repeat: { cron: "0 0 * * *" }, // Daily at midnight
jobId: "social-media-stats-recurring",
},
);
console.log("✅ Social media stats collection scheduled");
return job;
}
/**
* Trigger manual social media stats collection
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"social-media-stats-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual social media stats collection triggered");
return job;
}
}
module.exports = SocialMediaStats;
module.exports.socialMediaStatsCache = socialMediaStatsCache;

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -225,6 +225,79 @@ If you wish to bind either if their respective container paths to a host path ra
---
## Docker Swarm Deployment
> [!NOTE]
> This section covers deploying PatchMon to a Docker Swarm cluster. For standard Docker Compose deployments on a single host, use the production deployment guide above.
### Network Configuration
When deploying to Docker Swarm with a reverse proxy (e.g., Traefik), proper network configuration is critical. The default `docker-compose.yml` uses an internal bridge network that enables service-to-service communication:
```yaml
networks:
patchmon-internal:
driver: bridge
```
All services (database, redis, backend, and frontend) connect to this internal network, allowing them to discover each other by service name.
**Important**: If you're using an external reverse proxy network (like `traefik-net`), ensure that:
1. All PatchMon services remain on the `patchmon-internal` network for internal communication
2. The frontend service (NGINX) can be configured to also bind to the reverse proxy network if needed
3. Service names resolve correctly within the same network
### Service Discovery in Swarm
In Docker Swarm, service discovery works through:
- **Service Name Resolution**: Service names resolve to virtual IPs within the same network
- **Load Balancing**: Requests to a service name are automatically load-balanced across all replicas
- **Network Isolation**: Services on different networks cannot communicate directly
### Configuration for Swarm with Traefik
If you're using Traefik as a reverse proxy:
1. Keep the default `patchmon-internal` network for backend services
2. Configure Traefik in your Swarm deployment with its own network
3. Ensure the frontend service can reach the backend through the internal network
Example modification for Swarm:
```yaml
services:
frontend:
image: ghcr.io/patchmon/patchmon-frontend:latest
networks:
- patchmon-internal
deploy:
replicas: 1
labels:
- "traefik.enable=true"
- "traefik.http.routers.patchmon.rule=Host(`patchmon.my.domain`)"
# ... other Traefik labels
```
The frontend reaches the backend via the `patchmon-internal` network using the hostname `backend`, while Traefik routes external traffic to the frontend service.
### Troubleshooting Network Issues
**Error: `host not found in upstream "backend"`**
This typically occurs when:
1. Frontend and backend services are on different networks
2. Services haven't fully started (check health checks)
3. Service names haven't propagated through DNS
**Solution**:
- Verify all services are on the same internal network
- Check service health status: `docker ps` (production) or `docker service ps` (Swarm)
- Wait for health checks to pass before accessing the application
- Confirm network connectivity: `docker exec <container> ping backend`
---
# Development
This section is for developers who want to contribute to PatchMon or run it in development mode.

View File

@@ -12,6 +12,8 @@ services:
- "5432:5432"
volumes:
- ./compose_dev_data/db:/var/lib/postgresql/data
networks:
- patchmon-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
interval: 3s
@@ -28,6 +30,8 @@ services:
- "6379:6379"
volumes:
- ./compose_dev_data/redis:/data
networks:
- patchmon-internal
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "1NS3CU6E_DEV_R3DIS_PASSW0RD", "ping"]
interval: 3s
@@ -68,10 +72,15 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
REDIS_DB: 0
# Assets directory for custom branding (logos, favicons)
ASSETS_DIR: /app/assets
ports:
- "3001:3001"
volumes:
- ./compose_dev_data/agents:/app/agents
- ./compose_dev_data/assets:/app/assets
networks:
- patchmon-internal
depends_on:
database:
condition: service_healthy
@@ -102,6 +111,10 @@ services:
BACKEND_PORT: 3001
ports:
- "3000:3000"
volumes:
- ./compose_dev_data/assets:/app/frontend/public/assets
networks:
- patchmon-internal
depends_on:
backend:
condition: service_healthy
@@ -114,3 +127,7 @@ services:
- node_modules/
- action: rebuild
path: ../frontend/package.json
networks:
patchmon-internal:
driver: bridge

View File

@@ -26,6 +26,8 @@ services:
POSTGRES_PASSWORD: # CREATE A STRONG DB PASSWORD AND PUT IT HERE
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- patchmon-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
interval: 3s
@@ -38,6 +40,8 @@ services:
command: redis-server --requirepass your-redis-password-here # CHANGE THIS TO YOUR REDIS PASSWORD
volumes:
- redis_data:/data
networks:
- patchmon-internal
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "your-redis-password-here", "ping"] # CHANGE THIS TO YOUR REDIS PASSWORD
interval: 3s
@@ -74,8 +78,13 @@ services:
REDIS_PORT: 6379
REDIS_PASSWORD: your-redis-password-here
REDIS_DB: 0
# Assets directory for custom branding (logos, favicons)
ASSETS_DIR: /app/assets
volumes:
- agent_files:/app/agents
- branding_assets:/app/assets
networks:
- patchmon-internal
depends_on:
database:
condition: service_healthy
@@ -87,6 +96,10 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- branding_assets:/usr/share/nginx/html/assets
networks:
- patchmon-internal
depends_on:
backend:
condition: service_healthy
@@ -95,3 +108,8 @@ volumes:
postgres_data:
redis_data:
agent_files:
branding_assets:
networks:
patchmon-internal:
driver: bridge

View File

@@ -95,6 +95,16 @@ server {
}
# Custom branding assets (logos, favicons) - served from mounted volume
# This allows logos to persist across container restarts
location /assets/ {
alias /usr/share/nginx/html/assets/;
expires 1h;
add_header Cache-Control "public, must-revalidate";
# Allow CORS for assets if needed
add_header Access-Control-Allow-Origin *;
}
# Static assets caching (exclude Bull Board assets)
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;

File diff suppressed because one or more lines are too long

View File

@@ -14,16 +14,32 @@ const Logo = ({
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to encode logo path for URLs (handles spaces and special characters)
const encodeLogoPath = (path) => {
if (!path) return path;
// Split path into directory and filename
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
// Encode only the filename part, keep directory structure
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
// Determine which logo to use based on theme
const logoSrc = isDark
? settings?.logo_dark || "/assets/logo_dark.png"
: settings?.logo_light || "/assets/logo_light.png";
// Encode the path to handle spaces and special characters
const encodedLogoSrc = encodeLogoPath(logoSrc);
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
const logoSrcWithCache = `${encodedLogoSrc}?v=${cacheBuster}`;
return (
<img

View File

@@ -8,11 +8,23 @@ const LogoProvider = ({ children }) => {
// Use custom favicon or fallback to default
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
// Encode the path to handle spaces and special characters
const encodeLogoPath = (path) => {
if (!path) return path;
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
const encodedFaviconUrl = encodeLogoPath(faviconUrl);
// Add cache-busting parameter using updated_at timestamp
const cacheBuster = settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now();
const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
const faviconUrlWithCache = `${encodedFaviconUrl}?v=${cacheBuster}`;
// Update favicon
const favicon = document.querySelector('link[rel="icon"]');

View File

@@ -35,15 +35,11 @@ const ReleaseNotesModal = ({ isOpen, onAccept }) => {
enabled: isOpen && !!versionInfo?.version,
});
// Fetch supporter count from Buy Me a Coffee
const { data: supporterData, isLoading: isLoadingSupporters } = useQuery({
queryKey: ["buyMeACoffeeSupporters"],
// Fetch supporter count from social media stats
const { data: socialMediaStats, isLoading: isLoadingSupporters } = useQuery({
queryKey: ["socialMediaStats"],
queryFn: async () => {
const response = await fetch("/api/v1/buy-me-a-coffee/supporter-count", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
const response = await fetch("/api/v1/social-media-stats");
if (!response.ok) return null;
return response.json();
},
@@ -173,7 +169,7 @@ const ReleaseNotesModal = ({ isOpen, onAccept }) => {
{currentStep === 2 && (
<button
type="button"
onClick={onAccept}
onClick={handleClose}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-secondary-700 dark:text-secondary-300 bg-secondary-100 dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary-500 transition-colors"
aria-label="Close"
>
@@ -322,12 +318,15 @@ const ReleaseNotesModal = ({ isOpen, onAccept }) => {
{/* Supporter/Member count - only show for self-hosted */}
{!isCloudVersion &&
!isLoadingSupporters &&
supporterData?.count !== undefined && (
socialMediaStats?.buymeacoffee_supporters !== undefined &&
socialMediaStats?.buymeacoffee_supporters !== null && (
<p className="text-sm text-secondary-600 dark:text-secondary-400 text-center sm:text-left">
<span className="font-semibold text-secondary-900 dark:text-secondary-100">
{supporterData.count}
{socialMediaStats.buymeacoffee_supporters}
</span>{" "}
{supporterData.count === 1 ? "person has" : "people have"}{" "}
{socialMediaStats.buymeacoffee_supporters === 1
? "person has"
: "people have"}{" "}
bought me a coffee so far!
</p>
)}

View File

@@ -25,19 +25,45 @@ const BrandingTab = () => {
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to encode logo path for URLs (handles spaces and special characters)
const encodeLogoPath = (path) => {
if (!path) return path;
// Split path into directory and filename
const parts = path.split("/");
const filename = parts.pop();
const directory = parts.join("/");
// Encode only the filename part, keep directory structure
return directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
};
// Logo upload mutation
const uploadLogoMutation = useMutation({
mutationFn: ({ logoType, fileContent, fileName }) =>
fetch("/api/v1/settings/logos/upload", {
mutationFn: async ({ logoType, fileContent, fileName }) => {
const res = await fetch("/api/v1/settings/logos/upload", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify({ logoType, fileContent, fileName }),
}).then((res) => res.json()),
onSuccess: (_data, variables) => {
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || data.details || "Failed to upload logo");
}
return data;
},
onSuccess: async (data, variables) => {
// Invalidate and refetch settings to get updated timestamp
queryClient.invalidateQueries(["settings"]);
// Wait for refetch to complete before closing modal
try {
await queryClient.refetchQueries(["settings"]);
} catch (error) {
// Continue anyway - settings will update on next render
}
setLogoUploadState((prev) => ({
...prev,
[variables.logoType]: { uploading: false, error: null },
@@ -133,7 +159,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
key={`dark-${settings?.logo_dark}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.logo_dark || "/assets/logo_dark.png")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Dark Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
@@ -194,7 +225,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
key={`light-${settings?.logo_light}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.logo_light || "/assets/logo_light.png")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Light Logo"
className="max-h-16 max-w-full object-contain"
onError={(e) => {
@@ -255,7 +291,12 @@ const BrandingTab = () => {
</h4>
<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
<img
src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
key={`favicon-${settings?.favicon}-${settings?.updated_at}`}
src={`${encodeLogoPath(settings?.favicon || "/assets/favicon.svg")}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`}
alt="Favicon"
className="h-8 w-8 object-contain"
onError={(e) => {

View File

@@ -1604,7 +1604,7 @@ const Dashboard = () => {
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="hidden md:flex btn-outline items-center gap-2"
className="hidden md:flex btn-outline items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />

View File

@@ -881,7 +881,7 @@ const HostDetail = () => {
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-3">
Network Interfaces
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{host.network_interfaces.map((iface) => (
<div
key={iface.name}
@@ -1187,7 +1187,7 @@ const HostDetail = () => {
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h5>
<div className="space-y-3">
<div className="space-y-3 max-h-80 overflow-y-auto pr-2">
{host.disk_details.map((disk, index) => (
<div
key={disk.name || `disk-${index}`}
@@ -1812,7 +1812,7 @@ const HostDetail = () => {
<Wifi className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Network Interfaces
</h4>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{host.network_interfaces.map((iface) => (
<div
key={iface.name}
@@ -2141,7 +2141,7 @@ const HostDetail = () => {
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h5>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-h-80 overflow-y-auto pr-2">
{host.disk_details.map((disk, index) => (
<div
key={disk.name || `disk-${index}`}

View File

@@ -1,3 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowLeft,
@@ -12,17 +13,25 @@ import {
Star,
User,
} from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { FaReddit, FaYoutube } from "react-icons/fa";
import { FaLinkedin, FaYoutube } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import DiscordIcon from "../components/DiscordIcon";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { authAPI, isCorsError } from "../utils/api";
import { authAPI, isCorsError, settingsAPI } from "../utils/api";
const Login = () => {
// Helper function to format numbers in k format (e.g., 1704 -> 1.8k)
const formatNumber = (num) => {
if (num >= 1000) {
const rounded = Math.ceil((num / 1000) * 10) / 10; // Round up to 1 decimal place
return `${rounded.toFixed(1)}K`;
}
return num.toString();
};
const usernameId = useId();
const firstNameId = useId();
const lastNameId = useId();
@@ -52,12 +61,24 @@ const Login = () => {
const [showGithubVersionOnLogin, setShowGithubVersionOnLogin] =
useState(true);
const [latestRelease, setLatestRelease] = useState(null);
const [githubStars, setGithubStars] = useState(null);
const [socialMediaStats, setSocialMediaStats] = useState({
github_stars: null,
discord_members: null,
buymeacoffee_supporters: null,
youtube_subscribers: null,
linkedin_followers: null,
});
const canvasRef = useRef(null);
const { themeConfig } = useColorTheme();
const navigate = useNavigate();
// Fetch settings for favicon
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Generate clean radial gradient background with subtle triangular accents
useEffect(() => {
const generateBackground = () => {
@@ -175,27 +196,52 @@ const Login = () => {
checkLoginSettings();
}, []);
// Fetch latest release and stars from GitHub
// Fetch latest release and social media stats
useEffect(() => {
// Only fetch if the setting allows it
if (!showGithubVersionOnLogin) {
return;
}
const fetchGitHubData = async () => {
const fetchData = async () => {
try {
// Try to get cached data first
// Try to get cached release data first
const cachedRelease = localStorage.getItem("githubLatestRelease");
const cachedStars = localStorage.getItem("githubStarsCount");
const cacheTime = localStorage.getItem("githubReleaseCacheTime");
const now = Date.now();
// Load cached data immediately
// Load cached release data immediately
if (cachedRelease) {
setLatestRelease(JSON.parse(cachedRelease));
}
if (cachedStars) {
setGithubStars(parseInt(cachedStars, 10));
// Fetch social media stats from cache
const statsResponse = await fetch("/api/v1/social-media-stats");
if (statsResponse.ok) {
const statsData = await statsResponse.json();
// Only update stats that are not null - preserve existing values if fetch failed
setSocialMediaStats((prev) => ({
github_stars:
statsData.github_stars !== null
? statsData.github_stars
: prev.github_stars,
discord_members:
statsData.discord_members !== null
? statsData.discord_members
: prev.discord_members,
buymeacoffee_supporters:
statsData.buymeacoffee_supporters !== null
? statsData.buymeacoffee_supporters
: prev.buymeacoffee_supporters,
youtube_subscribers:
statsData.youtube_subscribers !== null
? statsData.youtube_subscribers
: prev.youtube_subscribers,
linkedin_followers:
statsData.linkedin_followers !== null
? statsData.linkedin_followers
: prev.linkedin_followers,
}));
}
// Use cache if less than 1 hour old
@@ -203,25 +249,6 @@ const Login = () => {
return;
}
// Fetch repository info (includes star count)
const repoResponse = await fetch(
"https://api.github.com/repos/PatchMon/PatchMon",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
},
);
if (repoResponse.ok) {
const repoData = await repoResponse.json();
setGithubStars(repoData.stargazers_count);
localStorage.setItem(
"githubStarsCount",
repoData.stargazers_count.toString(),
);
}
// Fetch latest release
const releaseResponse = await fetch(
"https://api.github.com/repos/PatchMon/PatchMon/releases/latest",
@@ -271,7 +298,7 @@ const Login = () => {
}
};
fetchGitHubData();
fetchData();
}, [showGithubVersionOnLogin]); // Run once on mount
const handleSubmit = async (e) => {
@@ -572,16 +599,64 @@ const Login = () => {
title="GitHub Repository"
>
<Github className="h-5 w-5 text-white" />
{githubStars !== null && (
{socialMediaStats.github_stars !== null && (
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
<span className="text-sm font-medium text-white">
{githubStars}
{formatNumber(socialMediaStats.github_stars)}
</span>
</div>
)}
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
{socialMediaStats.discord_members !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.discord_members}
</span>
)}
</a>
{/* LinkedIn */}
<a
href="https://linkedin.com/company/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="LinkedIn Company Page"
>
<FaLinkedin className="h-5 w-5 text-[#0077B5]" />
{socialMediaStats.linkedin_followers !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.linkedin_followers}
</span>
)}
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-[#FF0000]" />
{socialMediaStats.youtube_subscribers !== null && (
<span className="text-sm font-medium text-white">
{socialMediaStats.youtube_subscribers}
</span>
)}
</a>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
@@ -593,7 +668,7 @@ const Login = () => {
<Route className="h-5 w-5 text-white" />
</a>
{/* Docs */}
{/* Documentation */}
<a
href="https://docs.patchmon.net"
target="_blank"
@@ -604,48 +679,6 @@ const Login = () => {
<BookOpen className="h-5 w-5 text-white" />
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Email Support"
>
<Mail className="h-5 w-5 text-white" />
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-white" />
</a>
{/* Reddit */}
<a
href="https://www.reddit.com/r/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Reddit Community"
>
<FaReddit className="h-5 w-5 text-white" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
@@ -671,9 +704,28 @@ const Login = () => {
<div>
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
src={
settings?.favicon
? `${(() => {
const parts = settings.favicon.split("/");
const filename = parts.pop();
const directory = parts.join("/");
const encodedPath = directory
? `${directory}/${encodeURIComponent(filename)}`
: encodeURIComponent(filename);
return `${encodedPath}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`;
})()}`
: "/assets/favicon.svg"
}
alt="PatchMon Logo"
className="h-16 w-16"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
@@ -884,9 +936,20 @@ const Login = () => {
<div className="text-center">
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
src={
settings?.favicon
? `${settings.favicon}?v=${
settings?.updated_at
? new Date(settings.updated_at).getTime()
: Date.now()
}`
: "/assets/favicon.svg"
}
alt="PatchMon Logo"
className="h-16 w-16"
onError={(e) => {
e.target.src = "/assets/favicon.svg";
}}
/>
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100">

View File

@@ -10,6 +10,7 @@ import {
Eye as EyeIcon,
EyeOff as EyeOffIcon,
GripVertical,
Info,
Package,
RefreshCw,
Search,
@@ -29,6 +30,7 @@ const Packages = () => {
const [sortField, setSortField] = useState("name");
const [sortDirection, setSortDirection] = useState("asc");
const [showColumnSettings, setShowColumnSettings] = useState(false);
const [descriptionModal, setDescriptionModal] = useState(null); // { packageName, description }
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem("packages-page-size");
@@ -168,13 +170,18 @@ const Packages = () => {
}, [packagesResponse]);
// Fetch dashboard stats for card counts (consistent with homepage)
const { data: dashboardStats } = useQuery({
const { data: dashboardStats, refetch: refetchDashboardStats } = useQuery({
queryKey: ["dashboardStats"],
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Handle refresh - refetch all related data
const handleRefresh = async () => {
await Promise.all([refetch(), refetchDashboardStats()]);
};
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ["hosts"],
@@ -363,28 +370,41 @@ const Packages = () => {
switch (column.id) {
case "name":
return (
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
<div className="flex items-center text-left w-full">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group flex-1"
>
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
<div className="flex-1">
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.name}
</div>
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
{pkg.description && (
<div className="text-sm text-secondary-600 dark:text-secondary-400">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</button>
</button>
{pkg.description && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDescriptionModal({
packageName: pkg.name,
description: pkg.description,
});
}}
className="ml-1 flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
title="View description"
>
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
</button>
)}
</div>
);
case "packageHosts": {
// Show total number of hosts where this package is installed
@@ -526,17 +546,17 @@ const Packages = () => {
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Packages
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
<p className="text-sm text-secondary-600 dark:text-white mt-1">
Manage package updates and security patches
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => refetch()}
onClick={handleRefresh}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh packages data"
title="Refresh packages and statistics data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
@@ -755,27 +775,36 @@ const Packages = () => {
{paginatedPackages.map((pkg) => (
<div key={pkg.id} className="card p-4 space-y-3">
{/* Package Name */}
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="text-left w-full"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
{pkg.name}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate(`/packages/${pkg.id}`)}
className="text-left flex-1"
>
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
{pkg.name}
</div>
</div>
</div>
</button>
{/* Description (only rendered if a description exists) */}
{pkg.description && (
<div className="text-sm text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-800/50 rounded-md p-2 border border-secondary-200 dark:border-secondary-700/50">
<div className="line-clamp-2 whitespace-normal">
{pkg.description}
</div>
</div>
)}
</button>
{pkg.description && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDescriptionModal({
packageName: pkg.name,
description: pkg.description,
});
}}
className="flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
title="View description"
>
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
</button>
)}
</div>
{/* Status and Hosts on same line */}
<div className="flex items-center justify-between gap-2">
@@ -961,6 +990,48 @@ const Packages = () => {
onReset={resetColumns}
/>
)}
{/* Description Modal */}
{descriptionModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="fixed inset-0 cursor-default"
aria-label="Close modal"
/>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-lg w-full mx-4 relative z-10">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{descriptionModal.packageName}
</h3>
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-6 py-4">
<p className="text-sm text-secondary-700 dark:text-secondary-300 whitespace-pre-wrap">
{descriptionModal.description}
</p>
</div>
<div className="px-6 py-4 border-t border-secondary-200 dark:border-secondary-600 flex justify-end">
<button
type="button"
onClick={() => setDescriptionModal(null)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import SettingsLayout from "../../components/SettingsLayout";
import api from "../../utils/api";
import api, { formatDate } from "../../utils/api";
const Integrations = () => {
// Generate unique IDs for form elements
@@ -235,11 +235,20 @@ const Integrations = () => {
try {
const data = {
token_name: form_data.token_name,
max_hosts_per_day: form_data.max_hosts_per_day,
allowed_ip_ranges: form_data.allowed_ip_ranges
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
: [],
};
// Add default host group if provided
if (form_data.default_host_group_id) {
data.default_host_group_id = form_data.default_host_group_id;
} else {
data.default_host_group_id = null;
}
// Add expiration if provided
if (form_data.expires_at) {
data.expires_at = form_data.expires_at;
@@ -323,7 +332,7 @@ const Integrations = () => {
}
};
const format_date = (date_string) => {
const formatDate = (date_string) => {
if (!date_string) return "Never";
return new Date(date_string).toLocaleString();
};
@@ -579,15 +588,15 @@ const Integrations = () => {
{token.allowed_ip_ranges.join(", ")}
</p>
)}
<p>Created: {format_date(token.created_at)}</p>
<p>Created: {formatDate(token.created_at)}</p>
{token.last_used_at && (
<p>
Last Used: {format_date(token.last_used_at)}
Last Used: {formatDate(token.last_used_at)}
</p>
)}
{token.expires_at && (
<p>
Expires: {format_date(token.expires_at)}
Expires: {formatDate(token.expires_at)}
{new Date(token.expires_at) <
new Date() && (
<span className="ml-2 text-red-600 dark:text-red-400">
@@ -599,15 +608,13 @@ const Integrations = () => {
</div>
</div>
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
{token.metadata?.integration_type === "api" && (
<button
type="button"
onClick={() => open_edit_modal(token)}
className="px-3 py-1 text-xs md:text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300"
>
Edit
</button>
)}
<button
type="button"
onClick={() => open_edit_modal(token)}
className="px-3 py-1 text-xs md:text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800"
>
Edit
</button>
<button
type="button"
onClick={() =>
@@ -791,15 +798,15 @@ const Integrations = () => {
)}
</button>
</div>
<p>Created: {format_date(token.created_at)}</p>
<p>Created: {formatDate(token.created_at)}</p>
{token.last_used_at && (
<p>
Last Used: {format_date(token.last_used_at)}
Last Used: {formatDate(token.last_used_at)}
</p>
)}
{token.expires_at && (
<p>
Expires: {format_date(token.expires_at)}
Expires: {formatDate(token.expires_at)}
{new Date(token.expires_at) <
new Date() && (
<span className="ml-2 text-red-600 dark:text-red-400">
@@ -1821,14 +1828,14 @@ const Integrations = () => {
</div>
)}
{/* Edit API Credential Modal */}
{/* Edit Token Modal */}
{show_edit_modal && edit_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 max-h-[90vh] overflow-y-auto">
<div className="p-4 md:p-6">
<div className="flex items-center justify-between mb-4 md:mb-6 gap-3">
<h2 className="text-lg md:text-xl font-bold text-secondary-900 dark:text-white">
Edit API Credential
Edit Token
</h2>
<button
type="button"
@@ -1843,21 +1850,76 @@ const Integrations = () => {
</div>
<form onSubmit={update_token} className="space-y-4">
<div className="block">
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Token Name
</span>
<input
type="text"
value={form_data.token_name}
readOnly
disabled
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-100 dark:bg-secondary-900 text-secondary-500 dark:text-secondary-400"
onChange={(e) =>
setFormData({ ...form_data, token_name: e.target.value })
}
placeholder="e.g., my-pve"
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"
required
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Token name cannot be changed
Update the token name for better organization
</p>
</div>
</label>
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Max Hosts Per Day
</span>
<input
type="number"
min="1"
max="1000"
value={form_data.max_hosts_per_day}
onChange={(e) =>
setFormData({
...form_data,
max_hosts_per_day: parseInt(e.target.value, 10) || 100,
})
}
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 with
this token
</p>
</label>
{(edit_token?.metadata?.integration_type === "proxmox-lxc" ||
edit_token?.metadata?.integration_type === "direct-host") && (
<label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Default Host Group (Optional)
</span>
<select
value={form_data.default_host_group_id}
onChange={(e) =>
setFormData({
...form_data,
default_host_group_id: e.target.value,
})
}
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"
>
<option value="">No default group</option>
{host_groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Auto-enrolled hosts will be assigned to this group
</p>
</label>
)}
{edit_token?.metadata?.integration_type === "api" && (
<div className="block">

72
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"frontend"
],
"devDependencies": {
"@biomejs/biome": "^2.3.4",
"@biomejs/biome": "^2.3.11",
"concurrently": "^8.2.2",
"lefthook": "^1.13.4"
},
@@ -363,9 +363,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.4.tgz",
"integrity": "sha512-TU08LXjBHdy0mEY9APtEtZdNQQijXUDSXR7IK1i45wgoPD5R0muK7s61QcFir6FpOj/RP1+YkPx5QJlycXUU3w==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz",
"integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -379,20 +379,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.4",
"@biomejs/cli-darwin-x64": "2.3.4",
"@biomejs/cli-linux-arm64": "2.3.4",
"@biomejs/cli-linux-arm64-musl": "2.3.4",
"@biomejs/cli-linux-x64": "2.3.4",
"@biomejs/cli-linux-x64-musl": "2.3.4",
"@biomejs/cli-win32-arm64": "2.3.4",
"@biomejs/cli-win32-x64": "2.3.4"
"@biomejs/cli-darwin-arm64": "2.3.11",
"@biomejs/cli-darwin-x64": "2.3.11",
"@biomejs/cli-linux-arm64": "2.3.11",
"@biomejs/cli-linux-arm64-musl": "2.3.11",
"@biomejs/cli-linux-x64": "2.3.11",
"@biomejs/cli-linux-x64-musl": "2.3.11",
"@biomejs/cli-win32-arm64": "2.3.11",
"@biomejs/cli-win32-x64": "2.3.11"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.4.tgz",
"integrity": "sha512-w40GvlNzLaqmuWYiDU6Ys9FNhJiclngKqcGld3iJIiy2bpJ0Q+8n3haiaC81uTPY/NA0d8Q/I3Z9+ajc14102Q==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz",
"integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==",
"cpu": [
"arm64"
],
@@ -407,9 +407,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.4.tgz",
"integrity": "sha512-3s7TLVtjJ7ni1xADXsS7x7GMUrLBZXg8SemXc3T0XLslzvqKj/dq1xGeBQ+pOWQzng9MaozfacIHdK2UlJ3jGA==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz",
"integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==",
"cpu": [
"x64"
],
@@ -424,9 +424,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.4.tgz",
"integrity": "sha512-y7efHyyM2gYmHy/AdWEip+VgTMe9973aP7XYKPzu/j8JxnPHuSUXftzmPhkVw0lfm4ECGbdBdGD6+rLmTgNZaA==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz",
"integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==",
"cpu": [
"arm64"
],
@@ -441,9 +441,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.4.tgz",
"integrity": "sha512-IruVGQRwMURivWazchiq7gKAqZSFs5so6gi0hJyxk7x6HR+iwZbO2IxNOqyLURBvL06qkIHs7Wffl6Bw30vCbQ==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz",
"integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==",
"cpu": [
"arm64"
],
@@ -458,9 +458,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.4.tgz",
"integrity": "sha512-gKfjWR/6/dfIxPJCw8REdEowiXCkIpl9jycpNVHux8aX2yhWPLjydOshkDL6Y/82PcQJHn95VCj7J+BRcE5o1Q==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz",
"integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==",
"cpu": [
"x64"
],
@@ -475,9 +475,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.4.tgz",
"integrity": "sha512-mzKFFv/w66e4/jCobFmD3kymCqG+FuWE7sVa4Yjqd9v7qt2UhXo67MSZKY9Ih18V2IwPzRKQPCw6KwdZs6AXSA==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz",
"integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==",
"cpu": [
"x64"
],
@@ -492,9 +492,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.4.tgz",
"integrity": "sha512-5TJ6JfVez+yyupJ/iGUici2wzKf0RrSAxJhghQXtAEsc67OIpdwSKAQboemILrwKfHDi5s6mu7mX+VTCTUydkw==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz",
"integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==",
"cpu": [
"arm64"
],
@@ -509,9 +509,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.4.tgz",
"integrity": "sha512-FGCijXecmC4IedQ0esdYNlMpx0Jxgf4zceCaMu6fkjWyjgn50ZQtMiqZZQ0Q/77yqPxvtkgZAvt5uGw0gAAjig==",
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz",
"integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==",
"cpu": [
"x64"
],

View File

@@ -25,7 +25,7 @@
"lint:fix": "biome check --write ."
},
"devDependencies": {
"@biomejs/biome": "^2.3.4",
"@biomejs/biome": "^2.3.11",
"concurrently": "^8.2.2",
"lefthook": "^1.13.4"
},

View File

@@ -1348,6 +1348,62 @@ EOF
print_status "Systemd service created: $SERVICE_NAME (running as $INSTANCE_USER)"
}
# Get nginx HTTP/2 syntax based on version
get_nginx_http2_syntax() {
local listen_line=""
local http2_line=""
# Get nginx version (e.g., "nginx version: nginx/1.18.0" or "nginx/1.24.0")
local nginx_version=""
if command -v nginx >/dev/null 2>&1; then
local nginx_version_output=$(nginx -v 2>&1)
# Try Perl regex first (more reliable)
if echo "$nginx_version_output" | grep -oP 'nginx/\K[0-9]+\.[0-9]+' >/dev/null 2>&1; then
nginx_version=$(echo "$nginx_version_output" | grep -oP 'nginx/\K[0-9]+\.[0-9]+' | head -1)
else
# Fallback: use sed for systems without Perl regex support
nginx_version=$(echo "$nginx_version_output" | sed -n 's/.*nginx\/\([0-9]\+\.[0-9]\+\).*/\1/p' | head -1)
fi
# If still empty, try nginx -V (verbose output)
if [ -z "$nginx_version" ]; then
local nginx_verbose_output=$(nginx -V 2>&1)
if echo "$nginx_verbose_output" | grep -oP 'nginx/\K[0-9]+\.[0-9]+' >/dev/null 2>&1; then
nginx_version=$(echo "$nginx_verbose_output" | grep -oP 'nginx/\K[0-9]+\.[0-9]+' | head -1)
else
nginx_version=$(echo "$nginx_verbose_output" | sed -n 's/.*nginx\/\([0-9]\+\.[0-9]\+\).*/\1/p' | head -1)
fi
fi
fi
if [ -z "$nginx_version" ]; then
# Fallback: assume newer version syntax (1.24.x+)
print_warning "Could not detect nginx version, using newer syntax (1.24.x+)"
listen_line=" listen 443 ssl;"
http2_line=" http2 on;"
else
# Extract major and minor version
local major_version=$(echo "$nginx_version" | cut -d. -f1)
local minor_version=$(echo "$nginx_version" | cut -d. -f2)
# nginx 1.18.x and earlier use: listen 443 ssl http2;
# nginx 1.24.x and later use: listen 443 ssl; and http2 on; separately
# For versions 1.19-1.23, use newer syntax as it's safer
if [ "$major_version" -lt 1 ] || ([ "$major_version" -eq 1 ] && [ "$minor_version" -le 18 ]); then
print_info "Detected nginx version $nginx_version, using legacy HTTP/2 syntax (listen 443 ssl http2;)"
listen_line=" listen 443 ssl http2;"
http2_line=""
else
print_info "Detected nginx version $nginx_version, using modern HTTP/2 syntax (listen 443 ssl; http2 on;)"
listen_line=" listen 443 ssl;"
http2_line=" http2 on;"
fi
fi
echo "$listen_line|$http2_line"
}
# Unified nginx configuration generator
generate_nginx_config() {
local fqdn="$1"
@@ -1359,6 +1415,11 @@ generate_nginx_config() {
print_info "Generating nginx configuration for $fqdn (SSL: $ssl_enabled)"
if [ "$ssl_enabled" = "true" ]; then
# Get HTTP/2 syntax based on nginx version
local http2_syntax=$(get_nginx_http2_syntax)
local listen_line=$(echo "$http2_syntax" | cut -d'|' -f1)
local http2_line=$(echo "$http2_syntax" | cut -d'|' -f2)
# SSL Configuration
cat > "$config_file" << EOF
# HTTP to HTTPS redirect
@@ -1379,8 +1440,14 @@ server {
# HTTPS server block
server {
listen 443 ssl;
http2 on;
$listen_line
EOF
# Add http2_line only if it's not empty (for newer versions)
if [ -n "$http2_line" ]; then
echo "$http2_line" >> "$config_file"
fi
cat >> "$config_file" << EOF
server_name $fqdn;
# SSL Configuration