mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2026-01-08 05:59:40 -06:00
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
27
backend/src/routes/socialMediaStatsRoutes.js
Normal file
27
backend/src/routes/socialMediaStatsRoutes.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
710
backend/src/services/automation/socialMediaStats.js
Normal file
710
backend/src/services/automation/socialMediaStats.js
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
72
package-lock.json
generated
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
71
setup.sh
71
setup.sh
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user