mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-29 19:13:36 +00:00
Compare commits
52 Commits
v1.3.0
...
release/1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79317b0052 | ||
|
|
77a945a5b6 | ||
|
|
dae536e96b | ||
|
|
f6d23e45b2 | ||
|
|
aba0f5cb6b | ||
|
|
2ec2b3992c | ||
|
|
f85721b292 | ||
|
|
1d2c003830 | ||
|
|
2975da0f69 | ||
|
|
93760d03e1 | ||
|
|
43fb54a683 | ||
|
|
e9368d1a95 | ||
|
|
3ce8c02a31 | ||
|
|
ac420901a6 | ||
|
|
eb0218bdcb | ||
|
|
746451c296 | ||
|
|
285e4c59ee | ||
|
|
9050595b7c | ||
|
|
cc46940b0c | ||
|
|
8864de6c15 | ||
|
|
3df2057f7e | ||
|
|
42f4e58bb4 | ||
|
|
12eef22912 | ||
|
|
c2121e3995 | ||
|
|
6792f96af9 | ||
|
|
1e617c8bb8 | ||
|
|
a76c5b8963 | ||
|
|
212b24b1c8 | ||
|
|
9fc3f4f9d1 | ||
|
|
3029278742 | ||
|
|
e4d6c1205c | ||
|
|
0f5272d12a | ||
|
|
5776d32e71 | ||
|
|
a11ff842eb | ||
|
|
48ce1951de | ||
|
|
9705e24b83 | ||
|
|
933c7a067e | ||
|
|
68f10c6c43 | ||
|
|
4b6f19c28e | ||
|
|
ae6afb0ef4 | ||
|
|
61523c9a44 | ||
|
|
3f9a5576ac | ||
|
|
e2dd7acca5 | ||
|
|
1c3b01f13c | ||
|
|
2c5a35b6c2 | ||
|
|
f42c53d34b | ||
|
|
95800e6d76 | ||
|
|
de449c547f | ||
|
|
a8bd09be89 | ||
|
|
3ae8422487 | ||
|
|
c98203a997 | ||
|
|
37c8f5fa76 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -356,6 +356,7 @@ api_version: "v1"
|
||||
credentials_file: "/etc/patchmon/credentials.yml"
|
||||
log_file: "/etc/patchmon/logs/patchmon-agent.log"
|
||||
log_level: "info"
|
||||
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
|
||||
EOF
|
||||
|
||||
# Create credentials file
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||
DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db"
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=your-redis-username-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT=30 # Maximum connections per instance (default: 30)
|
||||
DB_POOL_TIMEOUT=20 # Seconds to wait for available connection (default: 20)
|
||||
DB_CONNECT_TIMEOUT=10 # Seconds to wait for initial connection (default: 10)
|
||||
DB_IDLE_TIMEOUT=300 # Seconds before closing idle connections (default: 300)
|
||||
DB_MAX_LIFETIME=1800 # Maximum lifetime of a connection in seconds (default: 1800)
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
API_VERSION=v1
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Session Configuration
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=5000
|
||||
@@ -26,20 +39,18 @@ AUTH_RATE_LIMIT_MAX=500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS=60000
|
||||
AGENT_RATE_LIMIT_MAX=1000
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=your-redis-username-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
ENABLE_LOGGING=true
|
||||
|
||||
# User Registration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# TFA Configuration
|
||||
# TFA Configuration (optional - used if TFA is enabled)
|
||||
TFA_REMEMBER_ME_EXPIRES_IN=30d
|
||||
TFA_MAX_REMEMBER_SESSIONS=5
|
||||
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
-- Add color_theme field to settings table for customizable app theming
|
||||
ALTER TABLE "settings" ADD COLUMN "color_theme" TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AddMetricsTelemetry
|
||||
-- Add anonymous metrics and telemetry fields to settings table
|
||||
|
||||
-- Add metrics fields to settings table
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_anonymous_id" TEXT;
|
||||
ALTER TABLE "settings" ADD COLUMN "metrics_last_sent" TIMESTAMP(3);
|
||||
|
||||
-- Generate UUID for existing records (if any exist)
|
||||
-- This will use PostgreSQL's gen_random_uuid() function
|
||||
UPDATE "settings"
|
||||
SET "metrics_anonymous_id" = gen_random_uuid()::text
|
||||
WHERE "metrics_anonymous_id" IS NULL;
|
||||
|
||||
@@ -170,27 +170,31 @@ model role_permissions {
|
||||
}
|
||||
|
||||
model settings {
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
id String @id
|
||||
server_url String @default("http://localhost:3001")
|
||||
server_protocol String @default("http")
|
||||
server_host String @default("localhost")
|
||||
server_port Int @default(3001)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime
|
||||
update_interval Int @default(60)
|
||||
auto_update Boolean @default(false)
|
||||
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
|
||||
ssh_key_path String?
|
||||
repository_type String @default("public")
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
default_user_role String @default("user")
|
||||
ignore_ssl_self_signed Boolean @default(false)
|
||||
logo_dark String? @default("/assets/logo_dark.png")
|
||||
logo_light String? @default("/assets/logo_light.png")
|
||||
favicon String? @default("/assets/logo_square.svg")
|
||||
metrics_enabled Boolean @default(true)
|
||||
metrics_anonymous_id String?
|
||||
metrics_last_sent DateTime?
|
||||
color_theme String @default("default")
|
||||
}
|
||||
|
||||
model update_history {
|
||||
|
||||
@@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() {
|
||||
// Parse the URL
|
||||
const url = new URL(originalUrl);
|
||||
|
||||
// Add connection pooling parameters for multiple instances
|
||||
url.searchParams.set("connection_limit", "5"); // Reduced from default 10
|
||||
url.searchParams.set("pool_timeout", "10"); // 10 seconds
|
||||
url.searchParams.set("connect_timeout", "10"); // 10 seconds
|
||||
url.searchParams.set("idle_timeout", "300"); // 5 minutes
|
||||
url.searchParams.set("max_lifetime", "1800"); // 30 minutes
|
||||
// Add connection pooling parameters - configurable via environment variables
|
||||
const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
|
||||
const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
|
||||
const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
|
||||
const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
|
||||
const maxLifetime = process.env.DB_MAX_LIFETIME || "1800";
|
||||
|
||||
url.searchParams.set("connection_limit", connectionLimit);
|
||||
url.searchParams.set("pool_timeout", poolTimeout);
|
||||
url.searchParams.set("connect_timeout", connectTimeout);
|
||||
url.searchParams.set("idle_timeout", idleTimeout);
|
||||
url.searchParams.set("max_lifetime", maxLifetime);
|
||||
|
||||
// Log connection pool settings in development/debug mode
|
||||
if (
|
||||
process.env.ENABLE_LOGGING === "true" ||
|
||||
process.env.LOG_LEVEL === "debug"
|
||||
) {
|
||||
console.log(
|
||||
`[Database Pool] connection_limit=${connectionLimit}, pool_timeout=${poolTimeout}s, connect_timeout=${connectTimeout}s`,
|
||||
);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -218,6 +218,30 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Trigger manual Docker inventory cleanup
|
||||
router.post(
|
||||
"/trigger/docker-inventory-cleanup",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const job = await queueManager.triggerDockerInventoryCleanup();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
jobId: job.id,
|
||||
message: "Docker inventory cleanup triggered successfully",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error triggering Docker inventory cleanup:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to trigger Docker inventory cleanup",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get queue health status
|
||||
router.get("/health", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
@@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1),
|
||||
queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1),
|
||||
]);
|
||||
|
||||
@@ -283,19 +308,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed,
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed,
|
||||
|
||||
runningTasks:
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active,
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active,
|
||||
|
||||
failedTasks:
|
||||
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
|
||||
stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed,
|
||||
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed +
|
||||
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed,
|
||||
|
||||
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
|
||||
return (
|
||||
@@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => {
|
||||
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Collect Host Statistics",
|
||||
queue: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
description: "Collects package statistics from connected agents only",
|
||||
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
|
||||
name: "Docker Inventory Cleanup",
|
||||
queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
|
||||
description:
|
||||
"Removes Docker containers and images for non-existent hosts",
|
||||
schedule: "Daily at 4 AM",
|
||||
lastRun: recentJobs[4][0]?.finishedOn
|
||||
? new Date(recentJobs[4][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
@@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
|
||||
: recentJobs[4][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
|
||||
},
|
||||
{
|
||||
name: "Collect Host Statistics",
|
||||
queue: QUEUE_NAMES.AGENT_COMMANDS,
|
||||
description: "Collects package statistics from connected agents only",
|
||||
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
|
||||
lastRun: recentJobs[5][0]?.finishedOn
|
||||
? new Date(recentJobs[5][0].finishedOn).toLocaleString()
|
||||
: "Never",
|
||||
lastRunTimestamp: recentJobs[5][0]?.finishedOn || 0,
|
||||
status: recentJobs[5][0]?.failedReason
|
||||
? "Failed"
|
||||
: recentJobs[5][0]
|
||||
? "Success"
|
||||
: "Never run",
|
||||
stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
|
||||
},
|
||||
].sort((a, b) => {
|
||||
|
||||
@@ -193,11 +193,16 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get hosts with their update status
|
||||
// Get hosts with their update status - OPTIMIZED
|
||||
router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
try {
|
||||
// Get settings once (outside the loop)
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const updateIntervalMinutes = settings?.update_interval || 60;
|
||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||
|
||||
// Fetch hosts with groups
|
||||
const hosts = await prisma.hosts.findMany({
|
||||
// Show all hosts regardless of status
|
||||
select: {
|
||||
id: true,
|
||||
machine_id: true,
|
||||
@@ -223,61 +228,65 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
host_packages: {
|
||||
where: {
|
||||
needs_update: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { last_update: "desc" },
|
||||
});
|
||||
|
||||
// Get update counts for each host separately
|
||||
const hostsWithUpdateInfo = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const updatesCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: host.id,
|
||||
needs_update: true,
|
||||
},
|
||||
});
|
||||
// OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries
|
||||
const hostIds = hosts.map((h) => h.id);
|
||||
|
||||
// Get total packages count for this host
|
||||
const totalPackagesCount = await prisma.host_packages.count({
|
||||
where: {
|
||||
host_id: host.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the agent update interval setting for stale calculation
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const updateIntervalMinutes = settings?.update_interval || 60;
|
||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||
|
||||
// Calculate effective status based on reporting interval
|
||||
const isStale = moment(host.last_update).isBefore(
|
||||
moment().subtract(thresholdMinutes, "minutes"),
|
||||
);
|
||||
let effectiveStatus = host.status;
|
||||
|
||||
// Override status if host hasn't reported within threshold
|
||||
if (isStale && host.status === "active") {
|
||||
effectiveStatus = "inactive";
|
||||
}
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
totalPackagesCount,
|
||||
isStale,
|
||||
effectiveStatus,
|
||||
};
|
||||
const [updateCounts, totalCounts] = await Promise.all([
|
||||
// Get update counts for all hosts at once
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["host_id"],
|
||||
where: {
|
||||
host_id: { in: hostIds },
|
||||
needs_update: true,
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Get total counts for all hosts at once
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["host_id"],
|
||||
where: {
|
||||
host_id: { in: hostIds },
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create lookup maps for O(1) access
|
||||
const updateCountMap = new Map(
|
||||
updateCounts.map((item) => [item.host_id, item._count.id]),
|
||||
);
|
||||
const totalCountMap = new Map(
|
||||
totalCounts.map((item) => [item.host_id, item._count.id]),
|
||||
);
|
||||
|
||||
// Process hosts with counts from maps (no more DB queries!)
|
||||
const hostsWithUpdateInfo = hosts.map((host) => {
|
||||
const updatesCount = updateCountMap.get(host.id) || 0;
|
||||
const totalPackagesCount = totalCountMap.get(host.id) || 0;
|
||||
|
||||
// Calculate effective status based on reporting interval
|
||||
const isStale = moment(host.last_update).isBefore(
|
||||
moment().subtract(thresholdMinutes, "minutes"),
|
||||
);
|
||||
let effectiveStatus = host.status;
|
||||
|
||||
// Override status if host hasn't reported within threshold
|
||||
if (isStale && host.status === "active") {
|
||||
effectiveStatus = "inactive";
|
||||
}
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
totalPackagesCount,
|
||||
isStale,
|
||||
effectiveStatus,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(hostsWithUpdateInfo);
|
||||
} catch (error) {
|
||||
|
||||
@@ -522,7 +522,8 @@ router.get("/updates", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/docker/collect - Collect Docker data from agent
|
||||
// POST /api/v1/docker/collect - Collect Docker data from agent (DEPRECATED - kept for backward compatibility)
|
||||
// New agents should use POST /api/v1/integrations/docker
|
||||
router.post("/collect", async (req, res) => {
|
||||
try {
|
||||
const { apiId, apiKey, containers, images, updates } = req.body;
|
||||
@@ -745,6 +746,322 @@ router.post("/collect", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v1/integrations/docker - New integration endpoint for Docker data collection
|
||||
router.post("/../integrations/docker", async (req, res) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"];
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
const {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Received data from ${hostname || machine_id}`,
|
||||
);
|
||||
|
||||
// Validate API credentials
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: { api_id: apiId, api_key: apiKey },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.warn("[Docker Integration] Invalid API credentials");
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Processing for host: ${host.friendly_name}`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to validate and parse dates
|
||||
const parseDate = (dateString) => {
|
||||
if (!dateString) return now;
|
||||
const date = new Date(dateString);
|
||||
return Number.isNaN(date.getTime()) ? now : date;
|
||||
};
|
||||
|
||||
let containersProcessed = 0;
|
||||
let imagesProcessed = 0;
|
||||
let updatesProcessed = 0;
|
||||
|
||||
// Process containers
|
||||
if (containers && Array.isArray(containers)) {
|
||||
console.log(
|
||||
`[Docker Integration] Processing ${containers.length} containers`,
|
||||
);
|
||||
for (const containerData of containers) {
|
||||
const containerId = uuidv4();
|
||||
|
||||
// Find or create image
|
||||
let imageId = null;
|
||||
if (containerData.image_repository && containerData.image_tag) {
|
||||
const image = await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
source: containerData.image_source || "docker-hub",
|
||||
created_at: parseDate(containerData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imageId = image.id;
|
||||
}
|
||||
|
||||
// Upsert container
|
||||
await prisma.docker_containers.upsert({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
last_checked: now,
|
||||
},
|
||||
create: {
|
||||
id: containerId,
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
created_at: parseDate(containerData.created_at),
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
containersProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process standalone images
|
||||
if (images && Array.isArray(images)) {
|
||||
console.log(`[Docker Integration] Processing ${images.length} images`);
|
||||
for (const imageData of images) {
|
||||
await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
digest: imageData.digest || null,
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
digest: imageData.digest,
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
source: imageData.source || "docker-hub",
|
||||
created_at: parseDate(imageData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imagesProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
if (updates && Array.isArray(updates)) {
|
||||
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
||||
for (const updateData of updates) {
|
||||
// Find the image by repository and image_id
|
||||
const image = await prisma.docker_images.findFirst({
|
||||
where: {
|
||||
repository: updateData.repository,
|
||||
tag: updateData.current_tag,
|
||||
image_id: updateData.image_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (image) {
|
||||
// Store digest info in changelog_url field as JSON
|
||||
const digestInfo = JSON.stringify({
|
||||
method: "digest_comparison",
|
||||
current_digest: updateData.current_digest,
|
||||
available_digest: updateData.available_digest,
|
||||
});
|
||||
|
||||
// Upsert the update record
|
||||
await prisma.docker_image_updates.upsert({
|
||||
where: {
|
||||
image_id_available_tag: {
|
||||
image_id: image.id,
|
||||
available_tag: updateData.available_tag,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
updated_at: now,
|
||||
changelog_url: digestInfo,
|
||||
severity: "digest_changed",
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
image_id: image.id,
|
||||
current_tag: updateData.current_tag,
|
||||
available_tag: updateData.available_tag,
|
||||
severity: "digest_changed",
|
||||
changelog_url: digestInfo,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
updatesProcessed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Docker data collected successfully",
|
||||
containers_received: containersProcessed,
|
||||
images_received: imagesProcessed,
|
||||
updates_found: updatesProcessed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Docker Integration] Error collecting Docker data:", error);
|
||||
console.error("[Docker Integration] Error stack:", error.stack);
|
||||
res.status(500).json({
|
||||
error: "Failed to collect Docker data",
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/docker/containers/:id - Delete a container
|
||||
router.delete("/containers/:id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if container exists
|
||||
const container = await prisma.docker_containers.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!container) {
|
||||
return res.status(404).json({ error: "Container not found" });
|
||||
}
|
||||
|
||||
// Delete the container
|
||||
await prisma.docker_containers.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
console.log(`🗑️ Deleted container: ${container.name} (${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Container ${container.name} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting container:", error);
|
||||
res.status(500).json({ error: "Failed to delete container" });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/v1/docker/images/:id - Delete an image
|
||||
router.delete("/images/:id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if image exists
|
||||
const image = await prisma.docker_images.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
docker_containers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: "Image not found" });
|
||||
}
|
||||
|
||||
// Check if image is in use by containers
|
||||
if (image._count.docker_containers > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete image: ${image._count.docker_containers} container(s) are using this image`,
|
||||
containersCount: image._count.docker_containers,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete image updates first
|
||||
await prisma.docker_image_updates.deleteMany({
|
||||
where: { image_id: id },
|
||||
});
|
||||
|
||||
// Delete the image
|
||||
await prisma.docker_images.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
console.log(`🗑️ Deleted image: ${image.repository}:${image.tag} (${id})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Image ${image.repository}:${image.tag} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting image:", error);
|
||||
res.status(500).json({ error: "Failed to delete image" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v1/docker/agent - Serve the Docker agent installation script
|
||||
router.get("/agent", async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -356,6 +356,26 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Host creation error:", error);
|
||||
|
||||
// Check if error is related to connection pool exhaustion
|
||||
if (
|
||||
error.message &&
|
||||
(error.message.includes("connection pool") ||
|
||||
error.message.includes("Timed out fetching") ||
|
||||
error.message.includes("pool timeout"))
|
||||
) {
|
||||
console.error("⚠️ DATABASE CONNECTION POOL EXHAUSTED!");
|
||||
console.error(
|
||||
`⚠️ Current limit: DB_CONNECTION_LIMIT=${process.env.DB_CONNECTION_LIMIT || "30"}`,
|
||||
);
|
||||
console.error(
|
||||
`⚠️ Pool timeout: DB_POOL_TIMEOUT=${process.env.DB_POOL_TIMEOUT || "20"}s`,
|
||||
);
|
||||
console.error(
|
||||
"⚠️ Suggestion: Increase DB_CONNECTION_LIMIT in your .env file",
|
||||
);
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Failed to create host" });
|
||||
}
|
||||
},
|
||||
@@ -786,19 +806,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
|
||||
// Ping endpoint for health checks (now uses API credentials)
|
||||
router.post("/ping", validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
// Update last update timestamp
|
||||
const now = new Date();
|
||||
const lastUpdate = req.hostRecord.last_update;
|
||||
|
||||
// Detect if this is an agent startup (first ping or after long absence)
|
||||
const timeSinceLastUpdate = lastUpdate ? now - lastUpdate : null;
|
||||
const isStartup =
|
||||
!timeSinceLastUpdate || timeSinceLastUpdate > 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Log agent startup
|
||||
if (isStartup) {
|
||||
console.log(
|
||||
`🚀 Agent startup detected: ${req.hostRecord.friendly_name} (${req.hostRecord.hostname || req.hostRecord.api_id})`,
|
||||
);
|
||||
|
||||
// Check if status was previously offline
|
||||
if (req.hostRecord.status === "offline") {
|
||||
console.log(`✅ Agent back online: ${req.hostRecord.friendly_name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last update timestamp and set status to active
|
||||
await prisma.hosts.update({
|
||||
where: { id: req.hostRecord.id },
|
||||
data: {
|
||||
last_update: new Date(),
|
||||
updated_at: new Date(),
|
||||
last_update: now,
|
||||
updated_at: now,
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
|
||||
const response = {
|
||||
message: "Ping successful",
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: now.toISOString(),
|
||||
friendlyName: req.hostRecord.friendly_name,
|
||||
agentStartup: isStartup,
|
||||
};
|
||||
|
||||
// Check if this is a crontab update trigger
|
||||
@@ -1388,6 +1430,69 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
// Force agent update for specific host
|
||||
router.post(
|
||||
"/:hostId/force-agent-update",
|
||||
authenticateToken,
|
||||
requireManageHosts,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
// Get host to verify it exists
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Get queue manager
|
||||
const { QUEUE_NAMES } = require("../services/automation");
|
||||
const queueManager = req.app.locals.queueManager;
|
||||
|
||||
if (!queueManager) {
|
||||
return res.status(500).json({
|
||||
error: "Queue manager not available",
|
||||
});
|
||||
}
|
||||
|
||||
// Get the agent-commands queue
|
||||
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
|
||||
|
||||
// Add job to queue
|
||||
await queue.add(
|
||||
"update_agent",
|
||||
{
|
||||
api_id: host.api_id,
|
||||
type: "update_agent",
|
||||
},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 2000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Agent update queued successfully",
|
||||
host: {
|
||||
id: host.id,
|
||||
friendlyName: host.friendly_name,
|
||||
apiId: host.api_id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Force agent update error:", error);
|
||||
res.status(500).json({ error: "Failed to force agent update" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Serve the installation script (requires API authentication)
|
||||
router.get("/install", async (req, res) => {
|
||||
try {
|
||||
@@ -1441,10 +1546,12 @@ router.get("/install", async (req, res) => {
|
||||
|
||||
// Determine curl flags dynamically from settings (ignore self-signed)
|
||||
let curlFlags = "-s";
|
||||
let skipSSLVerify = "false";
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings && settings.ignore_ssl_self_signed === true) {
|
||||
curlFlags = "-sk";
|
||||
skipSSLVerify = "true";
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -1454,12 +1561,13 @@ router.get("/install", async (req, res) => {
|
||||
// Get architecture parameter (default to amd64)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
|
||||
// Inject the API credentials, server URL, curl flags, force flag, and architecture into the script
|
||||
// Inject the API credentials, server URL, curl flags, SSL verify flag, force flag, and architecture into the script
|
||||
const envVars = `#!/bin/bash
|
||||
export PATCHMON_URL="${serverUrl}"
|
||||
export API_ID="${host.api_id}"
|
||||
export API_KEY="${host.api_key}"
|
||||
export CURL_FLAGS="${curlFlags}"
|
||||
export SKIP_SSL_VERIFY="${skipSSLVerify}"
|
||||
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
|
||||
export ARCHITECTURE="${architecture}"
|
||||
|
||||
|
||||
242
backend/src/routes/integrationRoutes.js
Normal file
242
backend/src/routes/integrationRoutes.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const express = require("express");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/v1/integrations/docker - Docker data collection endpoint
|
||||
router.post("/docker", async (req, res) => {
|
||||
try {
|
||||
const apiId = req.headers["x-api-id"];
|
||||
const apiKey = req.headers["x-api-key"];
|
||||
const {
|
||||
containers,
|
||||
images,
|
||||
updates,
|
||||
daemon_info: _daemon_info,
|
||||
hostname,
|
||||
machine_id,
|
||||
agent_version: _agent_version,
|
||||
} = req.body;
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Received data from ${hostname || machine_id}`,
|
||||
);
|
||||
|
||||
// Validate API credentials
|
||||
const host = await prisma.hosts.findFirst({
|
||||
where: { api_id: apiId, api_key: apiKey },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.warn("[Docker Integration] Invalid API credentials");
|
||||
return res.status(401).json({ error: "Invalid API credentials" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Processing for host: ${host.friendly_name}`,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Helper function to validate and parse dates
|
||||
const parseDate = (dateString) => {
|
||||
if (!dateString) return now;
|
||||
const date = new Date(dateString);
|
||||
return Number.isNaN(date.getTime()) ? now : date;
|
||||
};
|
||||
|
||||
let containersProcessed = 0;
|
||||
let imagesProcessed = 0;
|
||||
let updatesProcessed = 0;
|
||||
|
||||
// Process containers
|
||||
if (containers && Array.isArray(containers)) {
|
||||
console.log(
|
||||
`[Docker Integration] Processing ${containers.length} containers`,
|
||||
);
|
||||
for (const containerData of containers) {
|
||||
const containerId = uuidv4();
|
||||
|
||||
// Find or create image
|
||||
let imageId = null;
|
||||
if (containerData.image_repository && containerData.image_tag) {
|
||||
const image = await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
},
|
||||
},
|
||||
update: {
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: containerData.image_repository,
|
||||
tag: containerData.image_tag,
|
||||
image_id: containerData.image_id || "unknown",
|
||||
source: containerData.image_source || "docker-hub",
|
||||
created_at: parseDate(containerData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imageId = image.id;
|
||||
}
|
||||
|
||||
// Upsert container
|
||||
await prisma.docker_containers.upsert({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
last_checked: now,
|
||||
},
|
||||
create: {
|
||||
id: containerId,
|
||||
host_id: host.id,
|
||||
container_id: containerData.container_id,
|
||||
name: containerData.name,
|
||||
image_id: imageId,
|
||||
image_name: containerData.image_name,
|
||||
image_tag: containerData.image_tag || "latest",
|
||||
status: containerData.status,
|
||||
state: containerData.state || containerData.status,
|
||||
ports: containerData.ports || null,
|
||||
created_at: parseDate(containerData.created_at),
|
||||
started_at: containerData.started_at
|
||||
? parseDate(containerData.started_at)
|
||||
: null,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
containersProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process standalone images
|
||||
if (images && Array.isArray(images)) {
|
||||
console.log(`[Docker Integration] Processing ${images.length} images`);
|
||||
for (const imageData of images) {
|
||||
await prisma.docker_images.upsert({
|
||||
where: {
|
||||
repository_tag_image_id: {
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
digest: imageData.digest || null,
|
||||
last_checked: now,
|
||||
updated_at: now,
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
repository: imageData.repository,
|
||||
tag: imageData.tag,
|
||||
image_id: imageData.image_id,
|
||||
digest: imageData.digest,
|
||||
size_bytes: imageData.size_bytes
|
||||
? BigInt(imageData.size_bytes)
|
||||
: null,
|
||||
source: imageData.source || "docker-hub",
|
||||
created_at: parseDate(imageData.created_at),
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
imagesProcessed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process updates
|
||||
if (updates && Array.isArray(updates)) {
|
||||
console.log(`[Docker Integration] Processing ${updates.length} updates`);
|
||||
for (const updateData of updates) {
|
||||
// Find the image by repository and image_id
|
||||
const image = await prisma.docker_images.findFirst({
|
||||
where: {
|
||||
repository: updateData.repository,
|
||||
tag: updateData.current_tag,
|
||||
image_id: updateData.image_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (image) {
|
||||
// Store digest info in changelog_url field as JSON
|
||||
const digestInfo = JSON.stringify({
|
||||
method: "digest_comparison",
|
||||
current_digest: updateData.current_digest,
|
||||
available_digest: updateData.available_digest,
|
||||
});
|
||||
|
||||
// Upsert the update record
|
||||
await prisma.docker_image_updates.upsert({
|
||||
where: {
|
||||
image_id_available_tag: {
|
||||
image_id: image.id,
|
||||
available_tag: updateData.available_tag,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
updated_at: now,
|
||||
changelog_url: digestInfo,
|
||||
severity: "digest_changed",
|
||||
},
|
||||
create: {
|
||||
id: uuidv4(),
|
||||
image_id: image.id,
|
||||
current_tag: updateData.current_tag,
|
||||
available_tag: updateData.available_tag,
|
||||
severity: "digest_changed",
|
||||
changelog_url: digestInfo,
|
||||
updated_at: now,
|
||||
},
|
||||
});
|
||||
updatesProcessed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Docker data collected successfully",
|
||||
containers_received: containersProcessed,
|
||||
images_received: imagesProcessed,
|
||||
updates_found: updatesProcessed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Docker Integration] Error collecting Docker data:", error);
|
||||
console.error("[Docker Integration] Error stack:", error.stack);
|
||||
res.status(500).json({
|
||||
error: "Failed to collect Docker data",
|
||||
message: error.message,
|
||||
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
148
backend/src/routes/metricsRoutes.js
Normal file
148
backend/src/routes/metricsRoutes.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const express = require("express");
|
||||
const { body, validationResult } = require("express-validator");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requireManageSettings } = require("../middleware/permissions");
|
||||
const { getSettings, updateSettings } = require("../services/settingsService");
|
||||
const { queueManager, QUEUE_NAMES } = require("../services/automation");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get metrics settings
|
||||
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
// Generate anonymous ID if it doesn't exist
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
const anonymousId = uuidv4();
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: anonymousId,
|
||||
});
|
||||
settings.metrics_anonymous_id = anonymousId;
|
||||
}
|
||||
|
||||
res.json({
|
||||
metrics_enabled: settings.metrics_enabled ?? true,
|
||||
metrics_anonymous_id: settings.metrics_anonymous_id,
|
||||
metrics_last_sent: settings.metrics_last_sent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings fetch error:", error);
|
||||
res.status(500).json({ error: "Failed to fetch metrics settings" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update metrics settings
|
||||
router.put(
|
||||
"/",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
[
|
||||
body("metrics_enabled")
|
||||
.isBoolean()
|
||||
.withMessage("Metrics enabled must be a boolean"),
|
||||
],
|
||||
async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { metrics_enabled } = req.body;
|
||||
const settings = await getSettings();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_enabled,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Metrics settings updated successfully",
|
||||
metrics_enabled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Metrics settings update error:", error);
|
||||
res.status(500).json({ error: "Failed to update metrics settings" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Regenerate anonymous ID
|
||||
router.post(
|
||||
"/regenerate-id",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const newAnonymousId = uuidv4();
|
||||
|
||||
await updateSettings(settings.id, {
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
|
||||
console.log("Anonymous ID regenerated");
|
||||
|
||||
res.json({
|
||||
message: "Anonymous ID regenerated successfully",
|
||||
metrics_anonymous_id: newAnonymousId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Anonymous ID regeneration error:", error);
|
||||
res.status(500).json({ error: "Failed to regenerate anonymous ID" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Manually send metrics now
|
||||
router.post(
|
||||
"/send-now",
|
||||
authenticateToken,
|
||||
requireManageSettings,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
if (!settings.metrics_enabled) {
|
||||
return res.status(400).json({
|
||||
error: "Metrics are disabled. Please enable metrics first.",
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger metrics directly (no queue delay for manual trigger)
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
const result = await metricsReporting.process(
|
||||
{ name: "manual-send" },
|
||||
false,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log("✅ Manual metrics sent successfully");
|
||||
res.json({
|
||||
message: "Metrics sent successfully",
|
||||
data: result,
|
||||
});
|
||||
} else {
|
||||
console.error("❌ Failed to send metrics:", result);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: result.reason || result.error,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Send metrics error:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to send metrics",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -101,74 +101,107 @@ router.get("/", async (req, res) => {
|
||||
prisma.packages.count({ where }),
|
||||
]);
|
||||
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
// Build base where clause for this package
|
||||
const baseWhere = { package_id: pkg.id };
|
||||
// OPTIMIZATION: Batch query all stats instead of N individual queries
|
||||
const packageIds = packages.map((pkg) => pkg.id);
|
||||
|
||||
// If host filter is specified, add host filter to all queries
|
||||
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
|
||||
|
||||
const [updatesCount, securityCount, packageHosts] = await Promise.all([
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.count({
|
||||
where: {
|
||||
...hostWhere,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
}),
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
...hostWhere,
|
||||
// If host filter is specified, include all packages for that host
|
||||
// Otherwise, only include packages that need updates
|
||||
...(host ? {} : { needs_update: true }),
|
||||
},
|
||||
select: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
available_version: true,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
take: 10, // Limit to first 10 for performance
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
packageHostsCount: pkg._count.host_packages,
|
||||
packageHosts: packageHosts.map((hp) => ({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
osType: hp.hosts.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
})),
|
||||
stats: {
|
||||
totalInstalls: pkg._count.host_packages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
// Get all counts and host data in 3 batch queries instead of N*3 queries
|
||||
const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
|
||||
await Promise.all([
|
||||
// Batch count all packages that need updates
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["package_id"],
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
needs_update: true,
|
||||
...(host ? { host_id: host } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Batch count all packages with security updates
|
||||
prisma.host_packages.groupBy({
|
||||
by: ["package_id"],
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
...(host ? { host_id: host } : {}),
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
// Batch fetch all host data for packages
|
||||
prisma.host_packages.findMany({
|
||||
where: {
|
||||
package_id: { in: packageIds },
|
||||
...(host ? { host_id: host } : { needs_update: true }),
|
||||
},
|
||||
select: {
|
||||
package_id: true,
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
friendly_name: true,
|
||||
hostname: true,
|
||||
os_type: true,
|
||||
},
|
||||
},
|
||||
current_version: true,
|
||||
available_version: true,
|
||||
needs_update: true,
|
||||
is_security_update: true,
|
||||
},
|
||||
// Limit to first 10 per package
|
||||
take: 100, // Increased from package-based limit
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create lookup maps for O(1) access
|
||||
const updatesCountMap = new Map(
|
||||
allUpdatesCounts.map((item) => [item.package_id, item._count.id]),
|
||||
);
|
||||
const securityCountMap = new Map(
|
||||
allSecurityCounts.map((item) => [item.package_id, item._count.id]),
|
||||
);
|
||||
const packageHostsMap = new Map();
|
||||
|
||||
// Group host data by package_id
|
||||
for (const hp of allPackageHostsData) {
|
||||
if (!packageHostsMap.has(hp.package_id)) {
|
||||
packageHostsMap.set(hp.package_id, []);
|
||||
}
|
||||
const hosts = packageHostsMap.get(hp.package_id);
|
||||
hosts.push({
|
||||
hostId: hp.hosts.id,
|
||||
friendlyName: hp.hosts.friendly_name,
|
||||
osType: hp.hosts.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
needsUpdate: hp.needs_update,
|
||||
isSecurityUpdate: hp.is_security_update,
|
||||
});
|
||||
|
||||
// Limit to 10 hosts per package
|
||||
if (hosts.length > 10) {
|
||||
packageHostsMap.set(hp.package_id, hosts.slice(0, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Map packages with stats from lookup maps (no more DB queries!)
|
||||
const packagesWithStats = packages.map((pkg) => {
|
||||
const updatesCount = updatesCountMap.get(pkg.id) || 0;
|
||||
const securityCount = securityCountMap.get(pkg.id) || 0;
|
||||
const packageHosts = packageHostsMap.get(pkg.id) || [];
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
packageHostsCount: pkg._count.host_packages,
|
||||
packageHosts,
|
||||
stats: {
|
||||
totalInstalls: pkg._count.host_packages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
packages: packagesWithStats,
|
||||
|
||||
@@ -158,6 +158,7 @@ router.put(
|
||||
logoDark,
|
||||
logoLight,
|
||||
favicon,
|
||||
colorTheme,
|
||||
} = req.body;
|
||||
|
||||
// Get current settings to check for update interval changes
|
||||
@@ -189,6 +190,7 @@ router.put(
|
||||
if (logoDark !== undefined) updateData.logo_dark = logoDark;
|
||||
if (logoLight !== undefined) updateData.logo_light = logoLight;
|
||||
if (favicon !== undefined) updateData.favicon = favicon;
|
||||
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
|
||||
|
||||
const updatedSettings = await updateSettings(
|
||||
currentSettings.id,
|
||||
|
||||
@@ -14,13 +14,16 @@ const router = express.Router();
|
||||
function getCurrentVersion() {
|
||||
try {
|
||||
const packageJson = require("../../package.json");
|
||||
return packageJson?.version || "1.3.0";
|
||||
if (!packageJson?.version) {
|
||||
throw new Error("Version not found in package.json");
|
||||
}
|
||||
return packageJson.version;
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json, using fallback:",
|
||||
console.error(
|
||||
"Could not read version from package.json:",
|
||||
packageError.message,
|
||||
);
|
||||
return "1.3.0";
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,31 @@ const {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get WebSocket connection status by api_id (no database access - pure memory lookup)
|
||||
// Get WebSocket connection status for multiple hosts at once (bulk endpoint)
|
||||
router.get("/status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiIds } = req.query; // Comma-separated list of api_ids
|
||||
const idArray = apiIds ? apiIds.split(",").filter((id) => id.trim()) : [];
|
||||
|
||||
const statusMap = {};
|
||||
idArray.forEach((apiId) => {
|
||||
statusMap[apiId] = getConnectionInfo(apiId);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statusMap,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching bulk WebSocket status:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to fetch WebSocket status",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get WebSocket connection status by api_id (single endpoint)
|
||||
router.get("/status/:apiId", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
|
||||
@@ -66,8 +66,10 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
|
||||
const gethomepageRoutes = require("./routes/gethomepageRoutes");
|
||||
const automationRoutes = require("./routes/automationRoutes");
|
||||
const dockerRoutes = require("./routes/dockerRoutes");
|
||||
const integrationRoutes = require("./routes/integrationRoutes");
|
||||
const wsRoutes = require("./routes/wsRoutes");
|
||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||
const metricsRoutes = require("./routes/metricsRoutes");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { queueManager } = require("./services/automation");
|
||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||
@@ -295,7 +297,7 @@ app.disable("x-powered-by");
|
||||
// Rate limiting with monitoring
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000,
|
||||
message: {
|
||||
error: "Too many requests from this IP, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -424,7 +426,7 @@ const apiVersion = process.env.API_VERSION || "v1";
|
||||
const authLimiter = rateLimit({
|
||||
windowMs:
|
||||
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500,
|
||||
message: {
|
||||
error: "Too many authentication requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -438,7 +440,7 @@ const authLimiter = rateLimit({
|
||||
});
|
||||
const agentLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000,
|
||||
message: {
|
||||
error: "Too many agent requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -471,8 +473,10 @@ app.use(
|
||||
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
|
||||
app.use(`/api/${apiVersion}/automation`, automationRoutes);
|
||||
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
|
||||
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
@@ -1198,6 +1202,15 @@ async function startServer() {
|
||||
initAgentWs(server, prisma);
|
||||
await agentVersionService.initialize();
|
||||
|
||||
// Send metrics on startup (silent - no console output)
|
||||
try {
|
||||
const metricsReporting =
|
||||
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
|
||||
await metricsReporting.sendSilent();
|
||||
} catch (_error) {
|
||||
// Silent failure - don't block server startup if metrics fail
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
|
||||
@@ -428,26 +428,29 @@ class AgentVersionService {
|
||||
async getVersionInfo() {
|
||||
let hasUpdate = false;
|
||||
let updateStatus = "unknown";
|
||||
let effectiveLatestVersion = this.currentVersion; // Always use local version if available
|
||||
|
||||
// If we have a local version, use it as the latest regardless of GitHub
|
||||
if (this.currentVersion) {
|
||||
effectiveLatestVersion = this.currentVersion;
|
||||
// Latest version should ALWAYS come from GitHub, not from local binaries
|
||||
// currentVersion = what's installed locally
|
||||
// latestVersion = what's available on GitHub
|
||||
if (this.latestVersion) {
|
||||
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
|
||||
} else {
|
||||
console.log(
|
||||
`🔄 Using local agent version ${this.currentVersion} as latest`,
|
||||
);
|
||||
} else if (this.latestVersion) {
|
||||
// Fallback to GitHub version only if no local version
|
||||
effectiveLatestVersion = this.latestVersion;
|
||||
console.log(
|
||||
`🔄 No local version found, using GitHub version ${this.latestVersion}`,
|
||||
`⚠️ No GitHub release version available (API may be unavailable)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.currentVersion && effectiveLatestVersion) {
|
||||
if (this.currentVersion) {
|
||||
console.log(`💾 Current local agent version: ${this.currentVersion}`);
|
||||
} else {
|
||||
console.log(`⚠️ No local agent binary found`);
|
||||
}
|
||||
|
||||
// Determine update status by comparing current vs latest (from GitHub)
|
||||
if (this.currentVersion && this.latestVersion) {
|
||||
const comparison = compareVersions(
|
||||
this.currentVersion,
|
||||
effectiveLatestVersion,
|
||||
this.latestVersion,
|
||||
);
|
||||
if (comparison < 0) {
|
||||
hasUpdate = true;
|
||||
@@ -459,25 +462,25 @@ class AgentVersionService {
|
||||
hasUpdate = false;
|
||||
updateStatus = "up-to-date";
|
||||
}
|
||||
} else if (effectiveLatestVersion && !this.currentVersion) {
|
||||
} else if (this.latestVersion && !this.currentVersion) {
|
||||
hasUpdate = true;
|
||||
updateStatus = "no-agent";
|
||||
} else if (this.currentVersion && !effectiveLatestVersion) {
|
||||
} else if (this.currentVersion && !this.latestVersion) {
|
||||
// We have a current version but no latest version (GitHub API unavailable)
|
||||
hasUpdate = false;
|
||||
updateStatus = "github-unavailable";
|
||||
} else if (!this.currentVersion && !effectiveLatestVersion) {
|
||||
} else if (!this.currentVersion && !this.latestVersion) {
|
||||
updateStatus = "no-data";
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion: this.currentVersion,
|
||||
latestVersion: effectiveLatestVersion,
|
||||
latestVersion: this.latestVersion, // Always return GitHub version, not local
|
||||
hasUpdate: hasUpdate,
|
||||
updateStatus: updateStatus,
|
||||
lastChecked: this.lastChecked,
|
||||
supportedArchitectures: this.supportedArchitectures,
|
||||
status: effectiveLatestVersion ? "ready" : "no-releases",
|
||||
status: this.latestVersion ? "ready" : "no-releases",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,22 @@ function init(server, prismaClient) {
|
||||
// Notify subscribers of connection
|
||||
notifyConnectionChange(apiId, true);
|
||||
|
||||
ws.on("message", () => {
|
||||
// Currently we don't need to handle agent->server messages
|
||||
ws.on("message", async (data) => {
|
||||
// Handle incoming messages from agent (e.g., Docker status updates)
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === "docker_status") {
|
||||
// Handle Docker container status events
|
||||
await handleDockerStatusEvent(apiId, message);
|
||||
}
|
||||
// Add more message types here as needed
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[agent-ws] error parsing message from ${apiId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
@@ -162,6 +176,15 @@ function pushSettingsUpdate(apiId, newInterval) {
|
||||
);
|
||||
}
|
||||
|
||||
function pushUpdateAgent(apiId) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
safeSend(ws, JSON.stringify({ type: "update_agent" }));
|
||||
}
|
||||
|
||||
function getConnectionByApiId(apiId) {
|
||||
return apiIdToSocket.get(apiId);
|
||||
}
|
||||
|
||||
function pushUpdateNotification(apiId, updateInfo) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -255,15 +278,73 @@ function subscribeToConnectionChanges(apiId, callback) {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Docker container status events from agent
|
||||
async function handleDockerStatusEvent(apiId, message) {
|
||||
try {
|
||||
const { event: _event, container_id, name, status, timestamp } = message;
|
||||
|
||||
console.log(
|
||||
`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`,
|
||||
);
|
||||
|
||||
// Find the host
|
||||
const host = await prisma.hosts.findUnique({
|
||||
where: { api_id: apiId },
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
console.error(`[Docker Event] Host not found for api_id: ${apiId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update container status in database
|
||||
const container = await prisma.docker_containers.findUnique({
|
||||
where: {
|
||||
host_id_container_id: {
|
||||
host_id: host.id,
|
||||
container_id: container_id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (container) {
|
||||
await prisma.docker_containers.update({
|
||||
where: { id: container.id },
|
||||
data: {
|
||||
status: status,
|
||||
state: status,
|
||||
updated_at: new Date(timestamp || Date.now()),
|
||||
last_checked: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Docker Event] Updated container ${name} status to ${status}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[Docker Event] Container ${name} not found in database (may be new)`,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Broadcast to connected dashboard clients via SSE or WebSocket
|
||||
// This would notify the frontend UI in real-time
|
||||
} catch (error) {
|
||||
console.error(`[Docker Event] Error handling Docker status event:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
broadcastSettingsUpdate,
|
||||
pushReportNow,
|
||||
pushSettingsUpdate,
|
||||
pushUpdateAgent,
|
||||
pushUpdateNotification,
|
||||
pushUpdateNotificationToAll,
|
||||
// Expose read-only view of connected agents
|
||||
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
|
||||
getConnectionByApiId,
|
||||
isConnected: (apiId) => {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
return !!ws && ws.readyState === WebSocket.OPEN;
|
||||
|
||||
164
backend/src/services/automation/dockerInventoryCleanup.js
Normal file
164
backend/src/services/automation/dockerInventoryCleanup.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const { prisma } = require("./shared/prisma");
|
||||
|
||||
/**
|
||||
* Docker Inventory Cleanup Automation
|
||||
* Removes Docker containers and images for hosts that no longer exist
|
||||
*/
|
||||
class DockerInventoryCleanup {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "docker-inventory-cleanup";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process Docker inventory cleanup job
|
||||
*/
|
||||
async process(_job) {
|
||||
const startTime = Date.now();
|
||||
console.log("🧹 Starting Docker inventory cleanup...");
|
||||
|
||||
try {
|
||||
// Step 1: Find and delete orphaned containers (containers for non-existent hosts)
|
||||
const orphanedContainers = await prisma.docker_containers.findMany({
|
||||
where: {
|
||||
host_id: {
|
||||
// Find containers where the host doesn't exist
|
||||
notIn: await prisma.hosts
|
||||
.findMany({ select: { id: true } })
|
||||
.then((hosts) => hosts.map((h) => h.id)),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let deletedContainersCount = 0;
|
||||
const deletedContainers = [];
|
||||
|
||||
for (const container of orphanedContainers) {
|
||||
try {
|
||||
await prisma.docker_containers.delete({
|
||||
where: { id: container.id },
|
||||
});
|
||||
deletedContainersCount++;
|
||||
deletedContainers.push({
|
||||
id: container.id,
|
||||
container_id: container.container_id,
|
||||
name: container.name,
|
||||
image_name: container.image_name,
|
||||
host_id: container.host_id,
|
||||
});
|
||||
console.log(
|
||||
`🗑️ Deleted orphaned container: ${container.name} (host_id: ${container.host_id})`,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`❌ Failed to delete container ${container.id}:`,
|
||||
deleteError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Find and delete orphaned images (images with no containers using them)
|
||||
const orphanedImages = await prisma.docker_images.findMany({
|
||||
where: {
|
||||
docker_containers: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
docker_containers: true,
|
||||
docker_image_updates: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let deletedImagesCount = 0;
|
||||
const deletedImages = [];
|
||||
|
||||
for (const image of orphanedImages) {
|
||||
try {
|
||||
// First delete any image updates associated with this image
|
||||
if (image._count.docker_image_updates > 0) {
|
||||
await prisma.docker_image_updates.deleteMany({
|
||||
where: { image_id: image.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Then delete the image itself
|
||||
await prisma.docker_images.delete({
|
||||
where: { id: image.id },
|
||||
});
|
||||
deletedImagesCount++;
|
||||
deletedImages.push({
|
||||
id: image.id,
|
||||
repository: image.repository,
|
||||
tag: image.tag,
|
||||
image_id: image.image_id,
|
||||
});
|
||||
console.log(
|
||||
`🗑️ Deleted orphaned image: ${image.repository}:${image.tag}`,
|
||||
);
|
||||
} catch (deleteError) {
|
||||
console.error(
|
||||
`❌ Failed to delete image ${image.id}:`,
|
||||
deleteError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.log(
|
||||
`✅ Docker inventory cleanup completed in ${executionTime}ms - Deleted ${deletedContainersCount} containers and ${deletedImagesCount} images`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedContainersCount,
|
||||
deletedImagesCount,
|
||||
deletedContainers,
|
||||
deletedImages,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
console.error(
|
||||
`❌ Docker inventory cleanup failed after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring Docker inventory cleanup (daily at 4 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-inventory-cleanup",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 4 * * *" }, // Daily at 4 AM
|
||||
jobId: "docker-inventory-cleanup-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Docker inventory cleanup scheduled");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual Docker inventory cleanup
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"docker-inventory-cleanup-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual Docker inventory cleanup triggered");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DockerInventoryCleanup;
|
||||
@@ -52,17 +52,24 @@ class GitHubUpdateCheck {
|
||||
}
|
||||
|
||||
// Read version from package.json
|
||||
let currentVersion = "1.3.0"; // fallback
|
||||
let currentVersion = null;
|
||||
try {
|
||||
const packageJson = require("../../../package.json");
|
||||
if (packageJson?.version) {
|
||||
currentVersion = packageJson.version;
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
console.error(
|
||||
"Could not read version from package.json:",
|
||||
packageError.message,
|
||||
);
|
||||
throw new Error(
|
||||
"Could not determine current version from package.json",
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentVersion) {
|
||||
throw new Error("Version not found in package.json");
|
||||
}
|
||||
|
||||
const isUpdateAvailable =
|
||||
|
||||
@@ -8,6 +8,8 @@ const GitHubUpdateCheck = require("./githubUpdateCheck");
|
||||
const SessionCleanup = require("./sessionCleanup");
|
||||
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
|
||||
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
|
||||
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
|
||||
const MetricsReporting = require("./metricsReporting");
|
||||
|
||||
// Queue names
|
||||
const QUEUE_NAMES = {
|
||||
@@ -15,6 +17,8 @@ const QUEUE_NAMES = {
|
||||
SESSION_CLEANUP: "session-cleanup",
|
||||
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
|
||||
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
|
||||
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
|
||||
METRICS_REPORTING: "metrics-reporting",
|
||||
AGENT_COMMANDS: "agent-commands",
|
||||
};
|
||||
|
||||
@@ -91,6 +95,11 @@ class QueueManager {
|
||||
new OrphanedRepoCleanup(this);
|
||||
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
|
||||
new OrphanedPackageCleanup(this);
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
|
||||
new DockerInventoryCleanup(this);
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
|
||||
this,
|
||||
);
|
||||
|
||||
console.log("✅ All automation classes initialized");
|
||||
}
|
||||
@@ -149,6 +158,24 @@ class QueueManager {
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Docker Inventory Cleanup Worker
|
||||
this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker(
|
||||
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind(
|
||||
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
|
||||
),
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Metrics Reporting Worker
|
||||
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
|
||||
QUEUE_NAMES.METRICS_REPORTING,
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
|
||||
this.automations[QUEUE_NAMES.METRICS_REPORTING],
|
||||
),
|
||||
workerOptions,
|
||||
);
|
||||
|
||||
// Agent Commands Worker
|
||||
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
|
||||
QUEUE_NAMES.AGENT_COMMANDS,
|
||||
@@ -163,6 +190,19 @@ class QueueManager {
|
||||
// For settings update, we need additional data
|
||||
const { update_interval } = job.data;
|
||||
agentWs.pushSettingsUpdate(api_id, update_interval);
|
||||
} else if (type === "update_agent") {
|
||||
// Force agent to update by sending WebSocket command
|
||||
const ws = agentWs.getConnectionByApiId(api_id);
|
||||
if (ws && ws.readyState === 1) {
|
||||
// WebSocket.OPEN
|
||||
agentWs.pushUpdateAgent(api_id);
|
||||
console.log(`✅ Update command sent to agent ${api_id}`);
|
||||
} else {
|
||||
console.error(`❌ Agent ${api_id} is not connected`);
|
||||
throw new Error(
|
||||
`Agent ${api_id} is not connected. Cannot send update command.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(`Unknown agent command type: ${type}`);
|
||||
}
|
||||
@@ -205,6 +245,8 @@ class QueueManager {
|
||||
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
|
||||
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,6 +270,16 @@ class QueueManager {
|
||||
].triggerManual();
|
||||
}
|
||||
|
||||
async triggerDockerInventoryCleanup() {
|
||||
return this.automations[
|
||||
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
|
||||
].triggerManual();
|
||||
}
|
||||
|
||||
async triggerMetricsReporting() {
|
||||
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
|
||||
172
backend/src/services/automation/metricsReporting.js
Normal file
172
backend/src/services/automation/metricsReporting.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const axios = require("axios");
|
||||
const { prisma } = require("./shared/prisma");
|
||||
const { updateSettings } = require("../../services/settingsService");
|
||||
|
||||
const METRICS_API_URL =
|
||||
process.env.METRICS_API_URL || "https://metrics.patchmon.cloud";
|
||||
|
||||
/**
|
||||
* Metrics Reporting Automation
|
||||
* Sends anonymous usage metrics every 24 hours
|
||||
*/
|
||||
class MetricsReporting {
|
||||
constructor(queueManager) {
|
||||
this.queueManager = queueManager;
|
||||
this.queueName = "metrics-reporting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metrics reporting job
|
||||
*/
|
||||
async process(_job, silent = false) {
|
||||
const startTime = Date.now();
|
||||
if (!silent) console.log("📊 Starting metrics reporting...");
|
||||
|
||||
try {
|
||||
// Fetch fresh settings directly from database (bypass cache)
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: "desc" },
|
||||
});
|
||||
|
||||
// Check if metrics are enabled
|
||||
if (settings.metrics_enabled !== true) {
|
||||
if (!silent) console.log("📊 Metrics reporting is disabled");
|
||||
return { success: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
// Check if we have an anonymous ID
|
||||
if (!settings.metrics_anonymous_id) {
|
||||
if (!silent) console.log("📊 No anonymous ID found, skipping metrics");
|
||||
return { success: false, reason: "no_id" };
|
||||
}
|
||||
|
||||
// Get host count
|
||||
const hostCount = await prisma.hosts.count();
|
||||
|
||||
// Get version
|
||||
const packageJson = require("../../../package.json");
|
||||
const version = packageJson.version;
|
||||
|
||||
// Prepare metrics data
|
||||
const metricsData = {
|
||||
anonymous_id: settings.metrics_anonymous_id,
|
||||
host_count: hostCount,
|
||||
version,
|
||||
};
|
||||
|
||||
if (!silent)
|
||||
console.log(
|
||||
`📊 Sending metrics: ${hostCount} hosts, version ${version}`,
|
||||
);
|
||||
|
||||
// Send to metrics API
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${METRICS_API_URL}/metrics/submit`,
|
||||
metricsData,
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Update last sent timestamp
|
||||
await updateSettings(settings.id, {
|
||||
metrics_last_sent: new Date(),
|
||||
});
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.log(
|
||||
`✅ Metrics sent successfully in ${executionTime}ms:`,
|
||||
response.data,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
hostCount,
|
||||
version,
|
||||
executionTime,
|
||||
};
|
||||
} catch (apiError) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Failed to send metrics to API after ${executionTime}ms:`,
|
||||
apiError.message,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
reason: "api_error",
|
||||
error: apiError.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
if (!silent)
|
||||
console.error(
|
||||
`❌ Error in metrics reporting after ${executionTime}ms:`,
|
||||
error.message,
|
||||
);
|
||||
// Don't throw on silent mode, just return failure
|
||||
if (silent) {
|
||||
return {
|
||||
success: false,
|
||||
reason: "error",
|
||||
error: error.message,
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule recurring metrics reporting (daily at 2 AM)
|
||||
*/
|
||||
async schedule() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting",
|
||||
{},
|
||||
{
|
||||
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
|
||||
jobId: "metrics-reporting-recurring",
|
||||
},
|
||||
);
|
||||
console.log("✅ Metrics reporting scheduled (daily at 2 AM)");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual metrics reporting
|
||||
*/
|
||||
async triggerManual() {
|
||||
const job = await this.queueManager.queues[this.queueName].add(
|
||||
"metrics-reporting-manual",
|
||||
{},
|
||||
{ priority: 1 },
|
||||
);
|
||||
console.log("✅ Manual metrics reporting triggered");
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send metrics immediately (silent mode)
|
||||
* Used for automatic sending on server startup
|
||||
*/
|
||||
async sendSilent() {
|
||||
try {
|
||||
const result = await this.process({ name: "startup-silent" }, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Silent failure on startup
|
||||
return { success: false, reason: "error", error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MetricsReporting;
|
||||
@@ -33,7 +33,8 @@ async function checkPublicRepo(owner, repo) {
|
||||
try {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
let currentVersion = "1.3.0"; // fallback
|
||||
// Get current version for User-Agent (or use generic if unavailable)
|
||||
let currentVersion = "unknown";
|
||||
try {
|
||||
const packageJson = require("../../../package.json");
|
||||
if (packageJson?.version) {
|
||||
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
|
||||
}
|
||||
} catch (packageError) {
|
||||
console.warn(
|
||||
"Could not read version from package.json for User-Agent, using fallback:",
|
||||
"Could not read version from package.json for User-Agent:",
|
||||
packageError.message,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!**/*.css"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -136,6 +136,24 @@ When you do this, updating to a new version requires manually updating the image
|
||||
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
|
||||
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
|
||||
|
||||
##### Database Connection Pool Configuration (Prisma)
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------- | ---------------------------------------------------------- | ------- |
|
||||
| `DB_CONNECTION_LIMIT` | Maximum number of database connections per instance | `30` |
|
||||
| `DB_POOL_TIMEOUT` | Seconds to wait for an available connection before timeout | `20` |
|
||||
| `DB_CONNECT_TIMEOUT` | Seconds to wait for initial database connection | `10` |
|
||||
| `DB_IDLE_TIMEOUT` | Seconds before closing idle connections | `300` |
|
||||
| `DB_MAX_LIFETIME` | Maximum lifetime of a connection in seconds | `1800` |
|
||||
|
||||
> [!TIP]
|
||||
> The connection pool limit should be adjusted based on your deployment size:
|
||||
> - **Small deployment (1-10 hosts)**: `DB_CONNECTION_LIMIT=15` is sufficient
|
||||
> - **Medium deployment (10-50 hosts)**: `DB_CONNECTION_LIMIT=30` (default)
|
||||
> - **Large deployment (50+ hosts)**: `DB_CONNECTION_LIMIT=50` or higher
|
||||
>
|
||||
> Each connection pool serves one backend instance. If you have concurrent operations (multiple users, background jobs, agent checkins), increase the pool size accordingly.
|
||||
|
||||
##### Redis Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
||||
@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
RUN npm ci --ignore-scripts &&\
|
||||
npx prisma generate &&\
|
||||
RUN npm cache clean --force &&\
|
||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
|
||||
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
|
||||
npm prune --omit=dev &&\
|
||||
npm cache clean --force
|
||||
|
||||
|
||||
@@ -50,6 +50,19 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT: 30
|
||||
DB_POOL_TIMEOUT: 20
|
||||
DB_CONNECT_TIMEOUT: 10
|
||||
DB_IDLE_TIMEOUT: 300
|
||||
DB_MAX_LIFETIME: 1800
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
|
||||
@@ -56,6 +56,19 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Database Connection Pool Configuration (Prisma)
|
||||
DB_CONNECTION_LIMIT: 30
|
||||
DB_POOL_TIMEOUT: 20
|
||||
DB_CONNECT_TIMEOUT: 10
|
||||
DB_IDLE_TIMEOUT: 300
|
||||
DB_MAX_LIFETIME: 1800
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
|
||||
@@ -17,16 +17,21 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
# Builder stage for production
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY package*.json ./
|
||||
COPY frontend/package*.json ./frontend/
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
RUN npm ci --ignore-scripts
|
||||
RUN echo "=== Starting npm install ===" &&\
|
||||
npm cache clean --force &&\
|
||||
rm -rf node_modules ~/.npm /root/.npm &&\
|
||||
echo "=== npm install ===" &&\
|
||||
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
|
||||
echo "=== npm install completed ===" &&\
|
||||
npm cache clean --force
|
||||
|
||||
COPY frontend/ ./frontend/
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN npm run build:frontend
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
10
frontend/env.example
Normal file
10
frontend/env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Frontend Environment Configuration
|
||||
# This file is used by Vite during build and runtime
|
||||
|
||||
# API URL - Update this to match your backend server
|
||||
VITE_API_URL=http://localhost:3001/api/v1
|
||||
|
||||
# Application Metadata
|
||||
VITE_APP_NAME=PatchMon
|
||||
VITE_APP_VERSION=1.3.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import SettingsLayout from "./components/SettingsLayout";
|
||||
import { isAuthPhase } from "./constants/authPhases";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
|
||||
@@ -41,6 +42,7 @@ const SettingsServerConfig = lazy(
|
||||
() => import("./pages/settings/SettingsServerConfig"),
|
||||
);
|
||||
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
|
||||
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
|
||||
|
||||
// Loading fallback component
|
||||
const LoadingFallback = () => (
|
||||
@@ -388,6 +390,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/metrics"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_manage_settings">
|
||||
<Layout>
|
||||
<SettingsMetrics />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/options"
|
||||
element={
|
||||
@@ -416,13 +428,15 @@ function AppRoutes() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
<ColorThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ColorThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaYoutube } from "react-icons/fa";
|
||||
import { FaReddit, FaYoutube } from "react-icons/fa";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||
import DiscordIcon from "./DiscordIcon";
|
||||
@@ -61,7 +62,9 @@ const Layout = ({ children }) => {
|
||||
canManageSettings,
|
||||
} = useAuth();
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
const { themeConfig } = useColorTheme();
|
||||
const userMenuRef = useRef(null);
|
||||
const bgCanvasRef = useRef(null);
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const {
|
||||
@@ -233,27 +236,165 @@ const Layout = ({ children }) => {
|
||||
navigate("/hosts?action=add");
|
||||
};
|
||||
|
||||
// Generate clean radial gradient background with subtle triangular accents for dark mode
|
||||
useEffect(() => {
|
||||
const generateBackground = () => {
|
||||
if (
|
||||
!bgCanvasRef.current ||
|
||||
!themeConfig?.login ||
|
||||
!document.documentElement.classList.contains("dark")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = bgCanvasRef.current;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Get theme colors - pick first color from each palette
|
||||
const xColors = themeConfig.login.xColors || [
|
||||
"#667eea",
|
||||
"#764ba2",
|
||||
"#f093fb",
|
||||
"#4facfe",
|
||||
];
|
||||
const yColors = themeConfig.login.yColors || [
|
||||
"#667eea",
|
||||
"#764ba2",
|
||||
"#f093fb",
|
||||
"#4facfe",
|
||||
];
|
||||
|
||||
// Use date for daily color rotation
|
||||
const today = new Date();
|
||||
const seed =
|
||||
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
|
||||
const random = (s) => {
|
||||
const x = Math.sin(s) * 10000;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
|
||||
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
|
||||
|
||||
// Create clean radial gradient from center to bottom-right corner
|
||||
const gradient = ctx.createRadialGradient(
|
||||
canvas.width * 0.3, // Center slightly left
|
||||
canvas.height * 0.3, // Center slightly up
|
||||
0,
|
||||
canvas.width * 0.5, // Expand to cover screen
|
||||
canvas.height * 0.5,
|
||||
Math.max(canvas.width, canvas.height) * 1.2,
|
||||
);
|
||||
|
||||
// Subtle gradient with darker corners
|
||||
gradient.addColorStop(0, color1);
|
||||
gradient.addColorStop(0.6, color2);
|
||||
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Add subtle triangular shapes as accents across entire background
|
||||
const cellSize = 180;
|
||||
const cols = Math.ceil(canvas.width / cellSize) + 1;
|
||||
const rows = Math.ceil(canvas.height / cellSize) + 1;
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const idx = y * cols + x;
|
||||
// Draw more triangles (less sparse)
|
||||
if (random(seed + idx + 5000) > 0.4) {
|
||||
const baseX =
|
||||
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
|
||||
const baseY =
|
||||
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
|
||||
const size = 50 + random(seed + idx * 4) * 100;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseX, baseY);
|
||||
ctx.lineTo(baseX + size, baseY);
|
||||
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
|
||||
ctx.closePath();
|
||||
|
||||
// More visible white with slightly higher opacity
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
generateBackground();
|
||||
|
||||
// Regenerate on window resize or theme change
|
||||
const handleResize = () => {
|
||||
generateBackground();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
generateBackground();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [themeConfig]);
|
||||
|
||||
// Fetch GitHub stars count
|
||||
const fetchGitHubStars = useCallback(async () => {
|
||||
// Skip if already fetched recently
|
||||
// Try to load cached star count first
|
||||
const cachedStars = localStorage.getItem("githubStarsCount");
|
||||
if (cachedStars) {
|
||||
setGithubStars(parseInt(cachedStars, 10));
|
||||
}
|
||||
|
||||
// Skip API call if fetched recently
|
||||
const lastFetch = localStorage.getItem("githubStarsFetchTime");
|
||||
const now = Date.now();
|
||||
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
|
||||
// 15 minute cache
|
||||
if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
|
||||
// 10 minute cache
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/9technologygroup/patchmon.net",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubStars(data.stargazers_count);
|
||||
localStorage.setItem(
|
||||
"githubStarsCount",
|
||||
data.stargazers_count.toString(),
|
||||
);
|
||||
localStorage.setItem("githubStarsFetchTime", now.toString());
|
||||
} else if (response.status === 403 || response.status === 429) {
|
||||
console.warn("GitHub API rate limit exceeded, using cached value");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch GitHub stars:", error);
|
||||
// Keep using cached value if available
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -303,11 +444,76 @@ const Layout = ({ children }) => {
|
||||
fetchGitHubStars();
|
||||
}, [fetchGitHubStars]);
|
||||
|
||||
// Set CSS custom properties for glassmorphism and theme colors in dark mode
|
||||
useEffect(() => {
|
||||
const updateThemeStyles = () => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const root = document.documentElement;
|
||||
|
||||
if (isDark && themeConfig?.app) {
|
||||
// Glass navigation bars - very light for pattern visibility
|
||||
root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--sidebar-blur", "blur(12px)");
|
||||
root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)");
|
||||
root.style.setProperty("--topbar-blur", "blur(12px)");
|
||||
root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)");
|
||||
root.style.setProperty("--button-blur", "blur(8px)");
|
||||
|
||||
// Theme-colored cards and buttons - darker to stand out
|
||||
root.style.setProperty("--card-bg", themeConfig.app.cardBg);
|
||||
root.style.setProperty("--card-border", themeConfig.app.cardBorder);
|
||||
root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary);
|
||||
root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg);
|
||||
root.style.setProperty(
|
||||
"--theme-button-hover",
|
||||
themeConfig.app.buttonHover,
|
||||
);
|
||||
} else {
|
||||
// Light mode - standard colors
|
||||
root.style.setProperty("--sidebar-bg", "white");
|
||||
root.style.setProperty("--sidebar-blur", "none");
|
||||
root.style.setProperty("--topbar-bg", "white");
|
||||
root.style.setProperty("--topbar-blur", "none");
|
||||
root.style.setProperty("--button-bg", "white");
|
||||
root.style.setProperty("--button-blur", "none");
|
||||
root.style.setProperty("--card-bg", "white");
|
||||
root.style.setProperty("--card-border", "#e5e7eb");
|
||||
root.style.setProperty("--card-bg-hover", "#f9fafb");
|
||||
root.style.setProperty("--theme-button-bg", "#f3f4f6");
|
||||
root.style.setProperty("--theme-button-hover", "#e5e7eb");
|
||||
}
|
||||
};
|
||||
|
||||
updateThemeStyles();
|
||||
|
||||
// Watch for dark mode changes
|
||||
const observer = new MutationObserver(() => {
|
||||
updateThemeStyles();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [themeConfig]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
<div className="min-h-screen bg-secondary-50 dark:bg-black relative overflow-hidden">
|
||||
{/* Full-screen Trianglify Background (Dark Mode Only) */}
|
||||
<canvas
|
||||
ref={bgCanvasRef}
|
||||
className="fixed inset-0 w-full h-full hidden dark:block"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
<div
|
||||
className="fixed inset-0 bg-gradient-to-br from-black/10 to-black/20 hidden dark:block pointer-events-none"
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Mobile sidebar */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,7 +521,14 @@ const Layout = ({ children }) => {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
<div className="relative flex w-full max-w-[280px] flex-col bg-white dark:bg-secondary-800 pb-4 pt-5 shadow-xl">
|
||||
<div
|
||||
className="relative flex w-full max-w-[280px] flex-col bg-white dark:border-r dark:border-white/10 pb-4 pt-5 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -534,17 +747,43 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div
|
||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
className={`hidden lg:fixed lg:inset-y-0 z-[100] lg:flex lg:flex-col transition-all duration-300 relative ${
|
||||
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
||||
} bg-white dark:bg-secondary-800`}
|
||||
} bg-white dark:bg-transparent`}
|
||||
>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-[200] flex items-center justify-center w-6 h-6 rounded-full bg-white border border-secondary-300 dark:border-white/20 shadow-md hover:bg-secondary-50 transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--button-bg, white)",
|
||||
backdropFilter: "var(--button-blur, none)",
|
||||
WebkitBackdropFilter: "var(--button-blur, none)",
|
||||
}}
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
className={`flex grow flex-col gap-y-5 border-r border-secondary-200 dark:border-white/10 bg-white ${
|
||||
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: "var(--sidebar-bg, white)",
|
||||
backdropFilter: "var(--sidebar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--sidebar-blur, none)",
|
||||
overflowY: "auto",
|
||||
overflowX: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-white/10 ${
|
||||
sidebarCollapsed ? "justify-center" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
@@ -562,19 +801,6 @@ const Layout = ({ children }) => {
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{/* Collapse/Expand button on border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
|
||||
)}
|
||||
</button>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul className="flex flex-1 flex-col gap-y-6">
|
||||
{/* Show message for users with very limited permissions */}
|
||||
@@ -930,12 +1156,19 @@ const Layout = ({ children }) => {
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
className={`flex flex-col min-h-screen transition-all duration-300 relative z-10 ${
|
||||
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
|
||||
}`}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="sticky top-0 z-[90] flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-white/10 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
|
||||
style={{
|
||||
backgroundColor: "var(--topbar-bg, white)",
|
||||
backdropFilter: "var(--topbar-blur, none)",
|
||||
WebkitBackdropFilter: "var(--topbar-blur, none)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden"
|
||||
@@ -987,8 +1220,8 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Github className="h-5 w-5 flex-shrink-0" />
|
||||
{githubStars !== null && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current text-yellow-500" />
|
||||
<span className="text-sm font-medium">{githubStars}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1059,7 +1292,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<FaYoutube className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 7) Web */}
|
||||
{/* 8) 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-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Reddit Community"
|
||||
>
|
||||
<FaReddit className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 9) Web */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
@@ -1074,7 +1317,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-transparent">
|
||||
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
|
||||
href: "/settings/server-version",
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
href: "/settings/metrics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Image,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
||||
import { settingsAPI } from "../../utils/api";
|
||||
|
||||
const BrandingTab = () => {
|
||||
@@ -12,6 +20,7 @@ const BrandingTab = () => {
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -75,6 +84,22 @@ const BrandingTab = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Theme update mutation
|
||||
const updateThemeMutation = useMutation({
|
||||
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
|
||||
onSuccess: (_data, theme) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setColorTheme(theme);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update theme error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme) => {
|
||||
updateThemeMutation.mutate(theme);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -102,17 +127,110 @@ const BrandingTab = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center mb-6">
|
||||
<Image className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Logo & Branding
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos, favicon, and
|
||||
color themes. These will be displayed throughout the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Selector */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Color Theme
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Choose a color theme that will be applied to the login page and
|
||||
background areas throughout the app.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={themeKey}
|
||||
type="button"
|
||||
onClick={() => handleThemeChange(themeKey)}
|
||||
disabled={updateThemeMutation.isPending}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
|
||||
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
|
||||
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Theme Preview */}
|
||||
<div
|
||||
className="h-20 rounded-md mb-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme Name */}
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
|
||||
{theme.name}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-label="Selected theme"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{updateThemeMutation.isPending && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Updating theme...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateThemeMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Failed to update theme: {updateThemeMutation.error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Section Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Logos
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos and favicon.
|
||||
These will be displayed throughout the application.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Dark Logo */}
|
||||
|
||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const _updateUserMutation = useMutation({
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
|
||||
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
194
frontend/src/contexts/ColorThemeContext.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const ColorThemeContext = createContext();
|
||||
|
||||
// Theme configurations matching the login backgrounds
|
||||
export const THEME_PRESETS = {
|
||||
default: {
|
||||
name: "Normal Dark",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1e293b",
|
||||
bgSecondary: "#1e293b",
|
||||
bgTertiary: "#334155",
|
||||
borderColor: "#475569",
|
||||
cardBg: "#1e293b",
|
||||
cardBorder: "#334155",
|
||||
buttonBg: "#334155",
|
||||
buttonHover: "#475569",
|
||||
},
|
||||
},
|
||||
cyber_blue: {
|
||||
name: "Cyber Blue",
|
||||
login: {
|
||||
cellSize: 90,
|
||||
variance: 0.85,
|
||||
xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0a0820",
|
||||
bgSecondary: "#1a1f3a",
|
||||
bgTertiary: "#2d3561",
|
||||
borderColor: "#4a5584",
|
||||
cardBg: "#1a1f3a",
|
||||
cardBorder: "#2d3561",
|
||||
buttonBg: "#2d3561",
|
||||
buttonHover: "#4a5584",
|
||||
},
|
||||
},
|
||||
neon_purple: {
|
||||
name: "Neon Purple",
|
||||
login: {
|
||||
cellSize: 80,
|
||||
variance: 0.9,
|
||||
xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#0f0a1e",
|
||||
bgSecondary: "#1e0f3e",
|
||||
bgTertiary: "#4a0082",
|
||||
borderColor: "#7209b7",
|
||||
cardBg: "#1e0f3e",
|
||||
cardBorder: "#4a0082",
|
||||
buttonBg: "#4a0082",
|
||||
buttonHover: "#7209b7",
|
||||
},
|
||||
},
|
||||
matrix_green: {
|
||||
name: "Matrix Green",
|
||||
login: {
|
||||
cellSize: 70,
|
||||
variance: 0.7,
|
||||
xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001a00",
|
||||
bgSecondary: "#003300",
|
||||
bgTertiary: "#004d00",
|
||||
borderColor: "#006600",
|
||||
cardBg: "#003300",
|
||||
cardBorder: "#004d00",
|
||||
buttonBg: "#004d00",
|
||||
buttonHover: "#006600",
|
||||
},
|
||||
},
|
||||
ocean_blue: {
|
||||
name: "Ocean Blue",
|
||||
login: {
|
||||
cellSize: 85,
|
||||
variance: 0.8,
|
||||
xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#001845",
|
||||
bgSecondary: "#023e7d",
|
||||
bgTertiary: "#0077b6",
|
||||
borderColor: "#0096c7",
|
||||
cardBg: "#023e7d",
|
||||
cardBorder: "#0077b6",
|
||||
buttonBg: "#0077b6",
|
||||
buttonHover: "#0096c7",
|
||||
},
|
||||
},
|
||||
sunset_gradient: {
|
||||
name: "Sunset Gradient",
|
||||
login: {
|
||||
cellSize: 95,
|
||||
variance: 0.75,
|
||||
xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"],
|
||||
yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"],
|
||||
},
|
||||
app: {
|
||||
bgPrimary: "#1a0033",
|
||||
bgSecondary: "#330066",
|
||||
bgTertiary: "#4d0099",
|
||||
borderColor: "#6600cc",
|
||||
cardBg: "#330066",
|
||||
cardBorder: "#4d0099",
|
||||
buttonBg: "#4d0099",
|
||||
buttonHover: "#6600cc",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorThemeProvider = ({ children }) => {
|
||||
const [colorTheme, setColorTheme] = useState("default");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch theme from settings on mount
|
||||
useEffect(() => {
|
||||
const fetchTheme = async () => {
|
||||
try {
|
||||
// Check localStorage first for unauthenticated pages (login)
|
||||
const cachedTheme = localStorage.getItem("colorTheme");
|
||||
if (cachedTheme) {
|
||||
setColorTheme(cachedTheme);
|
||||
}
|
||||
|
||||
// Try to fetch from API (will fail on login page, that's ok)
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const response = await fetch("/api/v1/settings", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.color_theme) {
|
||||
setColorTheme(data.color_theme);
|
||||
localStorage.setItem("colorTheme", data.color_theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_apiError) {
|
||||
// Silent fail - use cached or default theme
|
||||
console.log("Could not fetch theme from API, using cached/default");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading color theme:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTheme();
|
||||
}, []);
|
||||
|
||||
const updateColorTheme = (theme) => {
|
||||
setColorTheme(theme);
|
||||
localStorage.setItem("colorTheme", theme);
|
||||
};
|
||||
|
||||
const value = {
|
||||
colorTheme,
|
||||
setColorTheme: updateColorTheme,
|
||||
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColorTheme = () => {
|
||||
const context = useContext(ColorThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useColorTheme must be used within ColorThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
@apply bg-secondary-50 dark:bg-transparent text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +39,46 @@
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
@apply btn border-secondary-300 text-secondary-700 bg-white hover:bg-secondary-50 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.dark .btn-outline {
|
||||
background-color: var(--theme-button-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dark .btn-outline:hover {
|
||||
background-color: var(--theme-button-hover, #334155);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
@apply bg-white rounded-lg shadow-card border border-secondary-200;
|
||||
}
|
||||
|
||||
.dark .card {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
@apply card transition-all duration-150;
|
||||
}
|
||||
|
||||
.dark .card-hover:hover {
|
||||
background-color: var(--card-bg-hover, #334155);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white text-secondary-900;
|
||||
}
|
||||
|
||||
.dark .input {
|
||||
background-color: var(--card-bg, #1e293b);
|
||||
border-color: var(--card-border, #334155);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -84,6 +111,27 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Theme-aware backgrounds for general elements */
|
||||
.dark .bg-secondary-800 {
|
||||
background-color: var(--card-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-700 {
|
||||
background-color: var(--card-bg-hover, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .bg-secondary-900 {
|
||||
background-color: var(--theme-button-bg, #1e293b) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-600 {
|
||||
border-color: var(--card-border, #334155) !important;
|
||||
}
|
||||
|
||||
.dark .border-secondary-700 {
|
||||
border-color: var(--theme-button-hover, #475569) !important;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,20 @@ const Automation = () => {
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Daily at 4 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(4, 0, 0, 0);
|
||||
return tomorrow.toLocaleString([], {
|
||||
hour12: true,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -209,6 +223,13 @@ const Automation = () => {
|
||||
tomorrow.setHours(3, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Daily at 4 AM") {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(4, 0, 0, 0);
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
if (schedule === "Every hour") {
|
||||
const now = new Date();
|
||||
const nextHour = new Date(now);
|
||||
@@ -269,6 +290,8 @@ const Automation = () => {
|
||||
endpoint = "/automation/trigger/orphaned-repo-cleanup";
|
||||
} else if (jobType === "orphaned-packages") {
|
||||
endpoint = "/automation/trigger/orphaned-package-cleanup";
|
||||
} else if (jobType === "docker-inventory") {
|
||||
endpoint = "/automation/trigger/docker-inventory-cleanup";
|
||||
} else if (jobType === "agent-collection") {
|
||||
endpoint = "/automation/trigger/agent-collection";
|
||||
}
|
||||
@@ -584,6 +607,10 @@ const Automation = () => {
|
||||
automation.queue.includes("orphaned-package")
|
||||
) {
|
||||
triggerManualJob("orphaned-packages");
|
||||
} else if (
|
||||
automation.queue.includes("docker-inventory")
|
||||
) {
|
||||
triggerManualJob("docker-inventory");
|
||||
} else if (
|
||||
automation.queue.includes("agent-commands")
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -18,12 +19,15 @@ import { Link } from "react-router-dom";
|
||||
import api from "../utils/api";
|
||||
|
||||
const Docker = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("containers");
|
||||
const [sortField, setSortField] = useState("status");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [sourceFilter, setSourceFilter] = useState("all");
|
||||
const [deleteContainerModal, setDeleteContainerModal] = useState(null);
|
||||
const [deleteImageModal, setDeleteImageModal] = useState(null);
|
||||
|
||||
// Fetch Docker dashboard data
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useQuery({
|
||||
@@ -36,7 +40,11 @@ const Docker = () => {
|
||||
});
|
||||
|
||||
// Fetch containers
|
||||
const { data: containersData, isLoading: containersLoading } = useQuery({
|
||||
const {
|
||||
data: containersData,
|
||||
isLoading: containersLoading,
|
||||
refetch: refetchContainers,
|
||||
} = useQuery({
|
||||
queryKey: ["docker", "containers", statusFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -49,7 +57,11 @@ const Docker = () => {
|
||||
});
|
||||
|
||||
// Fetch images
|
||||
const { data: imagesData, isLoading: imagesLoading } = useQuery({
|
||||
const {
|
||||
data: imagesData,
|
||||
isLoading: imagesLoading,
|
||||
refetch: refetchImages,
|
||||
} = useQuery({
|
||||
queryKey: ["docker", "images", sourceFilter],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -81,6 +93,42 @@ const Docker = () => {
|
||||
enabled: activeTab === "updates",
|
||||
});
|
||||
|
||||
// Delete container mutation
|
||||
const deleteContainerMutation = useMutation({
|
||||
mutationFn: async (containerId) => {
|
||||
const response = await api.delete(`/docker/containers/${containerId}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["docker", "containers"]);
|
||||
queryClient.invalidateQueries(["docker", "dashboard"]);
|
||||
setDeleteContainerModal(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(
|
||||
`Failed to delete container: ${error.response?.data?.error || error.message}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete image mutation
|
||||
const deleteImageMutation = useMutation({
|
||||
mutationFn: async (imageId) => {
|
||||
const response = await api.delete(`/docker/images/${imageId}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["docker", "images"]);
|
||||
queryClient.invalidateQueries(["docker", "dashboard"]);
|
||||
setDeleteImageModal(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(
|
||||
`Failed to delete image: ${error.response?.data?.error || error.message}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter and sort containers
|
||||
const filteredContainers = useMemo(() => {
|
||||
if (!containersData?.containers) return [];
|
||||
@@ -288,32 +336,36 @@ const Docker = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Docker Inventory
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Monitor containers, images, and updates across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Trigger refresh of all queries
|
||||
window.location.reload();
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Trigger refresh based on active tab
|
||||
if (activeTab === "containers") refetchContainers();
|
||||
else if (activeTab === "images") refetchImages();
|
||||
else window.location.reload();
|
||||
}}
|
||||
className="btn-outline flex items-center justify-center p-2"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Cards */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -400,11 +452,11 @@ const Docker = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs and Content */}
|
||||
<div className="card">
|
||||
{/* Docker List */}
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-700">
|
||||
<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
|
||||
{[
|
||||
{ id: "containers", label: "Containers", icon: Container },
|
||||
{ id: "images", label: "Images", icon: Package },
|
||||
@@ -443,7 +495,7 @@ const Docker = () => {
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div className="p-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
@@ -498,7 +550,7 @@ const Docker = () => {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
{/* Containers Tab */}
|
||||
{activeTab === "containers" && (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -522,83 +574,80 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Container Name
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("image")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("image")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Image
|
||||
{getSortIcon("image")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Status
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("host")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("host")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Host
|
||||
{getSortIcon("host")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredContainers.map((container) => (
|
||||
<tr
|
||||
key={container.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Container className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{container.name}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{container.image_name}:{container.image_tag}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{getStatusBadge(container.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
to={`/hosts/${container.host_id}`}
|
||||
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@@ -608,14 +657,24 @@ const Docker = () => {
|
||||
"Unknown"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/docker/containers/${container.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteContainerModal(container)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
|
||||
title="Delete container from inventory"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -648,88 +707,79 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("repository")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("repository")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Repository
|
||||
{getSortIcon("repository")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("tag")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("tag")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Tag
|
||||
{getSortIcon("tag")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("containers")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("containers")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Containers
|
||||
{getSortIcon("containers")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Updates
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredImages.map((image) => (
|
||||
<tr
|
||||
key={image.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{image.repository}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
{image.tag}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{getSourceBadge(image.source)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{image._count?.docker_containers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{image.hasUpdates ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
@@ -741,14 +791,24 @@ const Docker = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to={`/docker/images/${image.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
title="View details"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteImageModal(image)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
|
||||
title="Delete image from inventory"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -781,86 +841,80 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Host Name
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("containers")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("containers")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Containers
|
||||
{getSortIcon("containers")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Running
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
|
||||
onClick={() => handleSort("images")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("images")}
|
||||
className="flex items-center gap-2 hover:text-secondary-700"
|
||||
>
|
||||
Images
|
||||
{getSortIcon("images")}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredHosts.map((host) => (
|
||||
<tr
|
||||
key={host.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/hosts/${host.id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{host.friendly_name || host.hostname}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{host.dockerStats?.totalContainers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
{host.dockerStats?.runningContainers || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
|
||||
{host.dockerStats?.totalImages || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<Link
|
||||
to={`/docker/hosts/${host.id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -892,82 +946,64 @@ const Docker = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-900">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Image
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Tag
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Detection Method
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Affected
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
|
||||
>
|
||||
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{updatesData.updates.map((update) => (
|
||||
<tr
|
||||
key={update.id}
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
|
||||
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
|
||||
<Link
|
||||
to={`/docker/images/${update.image_id}`}
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
|
||||
>
|
||||
{update.docker_images?.repository}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
{update.current_tag}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<Package className="h-3 w-3 mr-1" />
|
||||
Digest Comparison
|
||||
Digest
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Update Available
|
||||
Available
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{update.affectedContainersCount} container
|
||||
{update.affectedContainersCount !== 1 ? "s" : ""}
|
||||
{update.affectedHosts?.length > 0 && (
|
||||
@@ -978,13 +1014,13 @@ const Docker = () => {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td className="px-4 py-2 whitespace-nowrap text-center">
|
||||
<Link
|
||||
to={`/docker/images/${update.image_id}`}
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
|
||||
title="View details"
|
||||
>
|
||||
View
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -996,6 +1032,141 @@ const Docker = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Container Modal */}
|
||||
{deleteContainerModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-start mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Delete Container
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete this container from the
|
||||
inventory?
|
||||
</p>
|
||||
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{deleteContainerModal.name}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Image: {deleteContainerModal.image_name}:
|
||||
{deleteContainerModal.image_tag}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Host:{" "}
|
||||
{deleteContainerModal.host?.friendly_name || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ This only removes the container from PatchMon's inventory.
|
||||
It does NOT stop or delete the actual Docker container on
|
||||
the host.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
deleteContainerMutation.mutate(deleteContainerModal.id)
|
||||
}
|
||||
disabled={deleteContainerMutation.isPending}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteContainerMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete from Inventory"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteContainerModal(null)}
|
||||
disabled={deleteContainerMutation.isPending}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Image Modal */}
|
||||
{deleteImageModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex items-start mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Delete Image
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete this image from the
|
||||
inventory?
|
||||
</p>
|
||||
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
|
||||
<p className="font-medium text-secondary-900 dark:text-white">
|
||||
{deleteImageModal.repository}:{deleteImageModal.tag}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Source: {deleteImageModal.source}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400">
|
||||
Containers using this:{" "}
|
||||
{deleteImageModal._count?.docker_containers || 0}
|
||||
</p>
|
||||
</div>
|
||||
{deleteImageModal._count?.docker_containers > 0 ? (
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ Cannot delete: This image is in use by{" "}
|
||||
{deleteImageModal._count.docker_containers} container(s).
|
||||
Delete the containers first.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
|
||||
⚠️ This only removes the image from PatchMon's inventory.
|
||||
It does NOT delete the actual Docker image from hosts.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteImageMutation.mutate(deleteImageModal.id)}
|
||||
disabled={
|
||||
deleteImageMutation.isPending ||
|
||||
deleteImageModal._count?.docker_containers > 0
|
||||
}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleteImageMutation.isPending
|
||||
? "Deleting..."
|
||||
: "Delete from Inventory"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteImageModal(null)}
|
||||
disabled={deleteImageMutation.isPending}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -187,6 +187,16 @@ const HostDetail = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Force agent update mutation
|
||||
const forceAgentUpdateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["host", hostId]);
|
||||
queryClient.invalidateQueries(["hosts"]);
|
||||
},
|
||||
});
|
||||
|
||||
const updateFriendlyNameMutation = useMutation({
|
||||
mutationFn: (friendlyName) =>
|
||||
adminHostsAPI
|
||||
@@ -703,6 +713,29 @@ const HostDetail = () => {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
|
||||
Force Update
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => forceAgentUpdateMutation.mutate()}
|
||||
disabled={forceAgentUpdateMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${
|
||||
forceAgentUpdateMutation.isPending
|
||||
? "animate-spin"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
{forceAgentUpdateMutation.isPending
|
||||
? "Updating..."
|
||||
: "Update Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -402,105 +402,71 @@ const Hosts = () => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
// Fetch initial WebSocket status for all hosts
|
||||
// Fetch initial WebSocket status for all hosts
|
||||
const fetchInitialStatus = async () => {
|
||||
const statusPromises = hosts
|
||||
const apiIds = hosts
|
||||
.filter((host) => host.api_id)
|
||||
.map(async (host) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ws/status/${host.api_id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { apiId: host.api_id, status: data.data };
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently handle errors
|
||||
}
|
||||
return {
|
||||
apiId: host.api_id,
|
||||
status: { connected: false, secure: false },
|
||||
};
|
||||
});
|
||||
.map((host) => host.api_id);
|
||||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const initialStatusMap = {};
|
||||
results.forEach(({ apiId, status }) => {
|
||||
initialStatusMap[apiId] = status;
|
||||
});
|
||||
if (apiIds.length === 0) return;
|
||||
|
||||
setWsStatusMap(initialStatusMap);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/ws/status?apiIds=${apiIds.join(",")}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setWsStatusMap(result.data);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Silently handle errors
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialStatus();
|
||||
}, [hosts]);
|
||||
|
||||
// Subscribe to WebSocket status changes for all hosts via SSE
|
||||
// Subscribe to WebSocket status changes for all hosts via polling (lightweight alternative to SSE)
|
||||
useEffect(() => {
|
||||
if (!hosts || hosts.length === 0) return;
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
|
||||
const eventSources = new Map();
|
||||
let isMounted = true;
|
||||
// Use polling instead of SSE to avoid connection pool issues
|
||||
// Poll every 10 seconds instead of 19 persistent connections
|
||||
const pollInterval = setInterval(() => {
|
||||
const apiIds = hosts
|
||||
.filter((host) => host.api_id)
|
||||
.map((host) => host.api_id);
|
||||
|
||||
const connectHost = (apiId) => {
|
||||
if (!isMounted || eventSources.has(apiId)) return;
|
||||
if (apiIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const es = new EventSource(
|
||||
`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`,
|
||||
);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (isMounted) {
|
||||
setWsStatusMap((prev) => {
|
||||
const newMap = { ...prev, [apiId]: data };
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silently handle parse errors
|
||||
fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setWsStatusMap(result.data);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (_error) => {
|
||||
console.log(`[SSE] Connection error for ${apiId}, retrying...`);
|
||||
es?.close();
|
||||
eventSources.delete(apiId);
|
||||
if (isMounted) {
|
||||
// Retry connection after 5 seconds with exponential backoff
|
||||
setTimeout(() => connectHost(apiId), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
eventSources.set(apiId, es);
|
||||
} catch (_err) {
|
||||
// Silently handle connection errors
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to all hosts
|
||||
for (const host of hosts) {
|
||||
if (host.api_id) {
|
||||
connectHost(host.api_id);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently handle errors
|
||||
});
|
||||
}, 10000); // Poll every 10 seconds
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
isMounted = false;
|
||||
for (const es of eventSources.values()) {
|
||||
es.close();
|
||||
}
|
||||
eventSources.clear();
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
399
frontend/src/pages/settings/SettingsMetrics.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import SettingsLayout from "../../components/SettingsLayout";
|
||||
|
||||
// API functions - will be added to utils/api.js
|
||||
const metricsAPI = {
|
||||
getSettings: () =>
|
||||
fetch("/api/v1/metrics", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
updateSettings: (data) =>
|
||||
fetch("/api/v1/metrics", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}).then((res) => res.json()),
|
||||
regenerateId: () =>
|
||||
fetch("/api/v1/metrics/regenerate-id", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
sendNow: () =>
|
||||
fetch("/api/v1/metrics/send-now", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
};
|
||||
|
||||
const SettingsMetrics = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [showFullId, setShowFullId] = useState(false);
|
||||
|
||||
// Fetch metrics settings
|
||||
const {
|
||||
data: metricsSettings,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["metrics-settings"],
|
||||
queryFn: () => metricsAPI.getSettings(),
|
||||
});
|
||||
|
||||
// Toggle metrics mutation
|
||||
const toggleMetricsMutation = useMutation({
|
||||
mutationFn: (enabled) =>
|
||||
metricsAPI.updateSettings({ metrics_enabled: enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Regenerate ID mutation
|
||||
const regenerateIdMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.regenerateId(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
// Send now mutation
|
||||
const sendNowMutation = useMutation({
|
||||
mutationFn: () => metricsAPI.sendNow(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["metrics-settings"]);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error loading metrics settings
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.message || "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const maskId = (id) => {
|
||||
if (!id) return "";
|
||||
if (showFullId) return id;
|
||||
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center mb-6">
|
||||
<BarChart3 className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Anonymous Metrics & Telemetry
|
||||
</h2>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Help us understand PatchMon's global usage (100% anonymous)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Your Privacy Matters
|
||||
</h3>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We do NOT collect:</strong> IP addresses, hostnames,
|
||||
system details, or any personally identifiable information
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>We ONLY collect:</strong> An anonymous UUID (for
|
||||
deduplication) and the number of hosts you're monitoring
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Purpose:</strong> Display a live counter on our
|
||||
website showing global PatchMon adoption
|
||||
</span>
|
||||
</p>
|
||||
<p className="flex items-start">
|
||||
<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Open Source:</strong> All code is public and
|
||||
auditable on GitHub
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Toggle */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Enable Anonymous Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Share anonymous usage statistics to help us showcase PatchMon's
|
||||
global adoption. Data is sent automatically every 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
|
||||
}
|
||||
disabled={toggleMetricsMutation.isPending}
|
||||
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "bg-primary-600"
|
||||
: "bg-secondary-200 dark:bg-secondary-700"
|
||||
} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
metricsSettings?.metrics_enabled
|
||||
? "translate-x-5"
|
||||
: "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center text-sm">
|
||||
{metricsSettings?.metrics_enabled ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||
<span className="text-green-700 dark:text-green-400">
|
||||
Metrics enabled - Thank you for supporting PatchMon!
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4 text-secondary-500 mr-2" />
|
||||
<span className="text-secondary-600 dark:text-secondary-400">
|
||||
Metrics disabled - No data is being sent
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymous ID Section */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Your Anonymous Instance ID
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
This UUID identifies your instance without revealing any
|
||||
personal information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all">
|
||||
{maskId(metricsSettings?.metrics_anonymous_id)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullId(!showFullId)}
|
||||
className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white"
|
||||
title={showFullId ? "Hide ID" : "Show full ID"}
|
||||
>
|
||||
{showFullId ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenerateIdMutation.mutate()}
|
||||
disabled={regenerateIdMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
{regenerateIdMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div>
|
||||
Regenerating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Regenerate ID
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendNowMutation.mutate()}
|
||||
disabled={
|
||||
!metricsSettings?.metrics_enabled || sendNowMutation.isPending
|
||||
}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendNowMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Send Metrics Now
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{metricsSettings?.metrics_last_sent && (
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Last sent:{" "}
|
||||
{new Date(metricsSettings.metrics_last_sent).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{regenerateIdMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<p className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
Anonymous ID regenerated successfully
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isSuccess && (
|
||||
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-green-700 dark:text-green-300">
|
||||
<p className="font-medium">Metrics sent successfully!</p>
|
||||
{sendNowMutation.data?.data && (
|
||||
<p className="mt-1">
|
||||
Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "}
|
||||
{sendNowMutation.data.data.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendNowMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" />
|
||||
<div className="ml-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sendNowMutation.error?.message || "Failed to send metrics"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Information Panel */}
|
||||
<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6">
|
||||
<div className="flex">
|
||||
<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<h4 className="font-medium mb-2">How it works:</h4>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Metrics are sent automatically every 24 hours when enabled
|
||||
</li>
|
||||
<li>
|
||||
Only host count and version number are transmitted (no
|
||||
sensitive data)
|
||||
</li>
|
||||
<li>The anonymous UUID prevents duplicate counting</li>
|
||||
<li>You can regenerate your ID or opt-out at any time</li>
|
||||
<li>
|
||||
All collected data is displayed publicly on{" "}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
patchmon.net
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMetrics;
|
||||
@@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
timeout: 10000, // 10 seconds
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
@@ -95,6 +95,7 @@ export const adminHostsAPI = {
|
||||
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
|
||||
toggleAutoUpdate: (hostId, autoUpdate) =>
|
||||
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
|
||||
forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
|
||||
updateFriendlyName: (hostId, friendlyName) =>
|
||||
api.patch(`/hosts/${hostId}/friendly-name`, {
|
||||
friendly_name: friendlyName,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Agent as HttpAgent } from "node:http";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
@@ -14,6 +15,15 @@ export default defineConfig({
|
||||
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// Configure HTTP agent to support more concurrent connections
|
||||
// Fixes 1000ms timeout issue when using HTTP (not HTTPS) with multiple hosts
|
||||
agent: new HttpAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: 50, // Increase from default 6 to handle multiple hosts
|
||||
maxFreeSockets: 10,
|
||||
timeout: 60000,
|
||||
keepAliveMsecs: 1000,
|
||||
}),
|
||||
configure:
|
||||
process.env.VITE_ENABLE_LOGGING === "true"
|
||||
? (proxy, _options) => {
|
||||
|
||||
1956
package-lock.json
generated
1956
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint:fix": "biome check --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
"@biomejs/biome": "^2.3.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"lefthook": "^1.13.4"
|
||||
},
|
||||
|
||||
715
tools/diagnostics.sh
Executable file
715
tools/diagnostics.sh
Executable file
@@ -0,0 +1,715 @@
|
||||
#!/bin/bash
|
||||
# PatchMon Diagnostics Collection Script
|
||||
# Collects system information, logs, and configuration for troubleshooting
|
||||
# Usage: sudo bash diagnostics.sh [instance-name]
|
||||
|
||||
# Note: Not using 'set -e' because we want to continue even if some commands fail
|
||||
set -o pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print functions
|
||||
print_status() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}🎉 $1${NC}"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
print_error "This script must be run as root"
|
||||
print_info "Please run: sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to sanitize sensitive information
|
||||
sanitize_sensitive() {
|
||||
local input="$1"
|
||||
# Replace passwords, secrets, and tokens with [REDACTED]
|
||||
echo "$input" | \
|
||||
sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)=[^"]*$/\1=[REDACTED]/gi' | \
|
||||
sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)="[^"]*"/\1="[REDACTED]"/gi' | \
|
||||
sed -E 's/(password|secret|token|key|pass)": *"[^"]*"/\1": "[REDACTED]"/gi' | \
|
||||
sed -E 's/(>)[a-zA-Z0-9+\/=]{20,}/\1[REDACTED]/g' | \
|
||||
sed -E 's|postgresql://([^:]+):([^@]+)@|postgresql://\1:[REDACTED]@|g' | \
|
||||
sed -E 's|mysql://([^:]+):([^@]+)@|mysql://\1:[REDACTED]@|g' | \
|
||||
sed -E 's|mongodb://([^:]+):([^@]+)@|mongodb://\1:[REDACTED]@|g'
|
||||
}
|
||||
|
||||
# Function to detect PatchMon installations
|
||||
detect_installations() {
|
||||
local installations=()
|
||||
|
||||
if [ ! -d "/opt" ]; then
|
||||
print_error "/opt directory does not exist"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for dir in /opt/*/; do
|
||||
# Skip if no directories found
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
local dirname=$(basename "$dir")
|
||||
|
||||
# Skip backup directories
|
||||
if [[ "$dirname" =~ \.backup\. ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if it's a PatchMon installation
|
||||
if [ -f "$dir/backend/package.json" ]; then
|
||||
if grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
|
||||
installations+=("$dirname")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "${installations[@]}"
|
||||
}
|
||||
|
||||
# Function to select installation
|
||||
select_installation() {
|
||||
local installations=($(detect_installations))
|
||||
|
||||
if [ ${#installations[@]} -eq 0 ]; then
|
||||
print_error "No PatchMon installations found in /opt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
# Use provided instance name
|
||||
if [[ " ${installations[@]} " =~ " $1 " ]]; then
|
||||
echo "$1"
|
||||
return 0
|
||||
else
|
||||
print_error "Instance '$1' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Send status messages to stderr so they don't contaminate the return value
|
||||
print_info "Found ${#installations[@]} installation(s):" >&2
|
||||
echo "" >&2
|
||||
|
||||
local i=1
|
||||
declare -A install_map
|
||||
for install in "${installations[@]}"; do
|
||||
# Get service status
|
||||
local status="unknown"
|
||||
if systemctl is-active --quiet "$install" 2>/dev/null; then
|
||||
status="${GREEN}running${NC}"
|
||||
elif systemctl is-enabled --quiet "$install" 2>/dev/null; then
|
||||
status="${RED}stopped${NC}"
|
||||
fi
|
||||
|
||||
printf "%2d. %-30s (%b)\n" "$i" "$install" "$status" >&2
|
||||
install_map[$i]="$install"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "" >&2
|
||||
|
||||
# If only one installation, select it automatically
|
||||
if [ ${#installations[@]} -eq 1 ]; then
|
||||
print_info "Only one installation found, selecting automatically: ${installations[0]}" >&2
|
||||
echo "${installations[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Multiple installations - prompt user
|
||||
printf "${BLUE}Select installation number [1]: ${NC}" >&2
|
||||
read -r selection </dev/tty
|
||||
|
||||
selection=${selection:-1}
|
||||
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then
|
||||
echo "${install_map[$selection]}"
|
||||
return 0
|
||||
else
|
||||
print_error "Invalid selection" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script
|
||||
main() {
|
||||
# Capture the directory where script is run from at the very start
|
||||
ORIGINAL_DIR=$(pwd)
|
||||
|
||||
echo -e "${BLUE}====================================================${NC}"
|
||||
echo -e "${BLUE} PatchMon Diagnostics Collection${NC}"
|
||||
echo -e "${BLUE}====================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Select instance
|
||||
instance_name=$(select_installation "$1")
|
||||
instance_dir="/opt/$instance_name"
|
||||
|
||||
print_info "Selected instance: $instance_name"
|
||||
print_info "Directory: $instance_dir"
|
||||
echo ""
|
||||
|
||||
# Create single diagnostics file in the original directory
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
diag_file="${ORIGINAL_DIR}/patchmon_diagnostics_${instance_name}_${timestamp}.txt"
|
||||
|
||||
print_info "Collecting diagnostics to: $diag_file"
|
||||
echo ""
|
||||
|
||||
# Initialize the diagnostics file with header
|
||||
cat > "$diag_file" << EOF
|
||||
===================================================
|
||||
PatchMon Diagnostics Report
|
||||
===================================================
|
||||
Instance: $instance_name
|
||||
Generated: $(date)
|
||||
Hostname: $(hostname)
|
||||
Generated from: ${ORIGINAL_DIR}
|
||||
===================================================
|
||||
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 1. System Information
|
||||
# ========================================
|
||||
print_info "Collecting system information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
=== System Information ===
|
||||
OS: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || echo "Unknown")
|
||||
Kernel: $(uname -r)
|
||||
Uptime: $(uptime)
|
||||
|
||||
=== CPU Information ===
|
||||
$(lscpu | grep -E "Model name|CPU\(s\)|Thread|Core" || echo "Not available")
|
||||
|
||||
=== Memory Information ===
|
||||
$(free -h)
|
||||
|
||||
=== Disk Usage ===
|
||||
$(df -h | grep -E "Filesystem|/dev/|/opt")
|
||||
|
||||
=== Network Interfaces ===
|
||||
$(ip -br addr)
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 2. PatchMon Instance Information
|
||||
# ========================================
|
||||
print_info "Collecting instance information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
|
||||
=== PatchMon Instance Information ===
|
||||
|
||||
=== Directory Structure ===
|
||||
$(ls -lah "$instance_dir" 2>/dev/null || echo "Cannot access directory")
|
||||
|
||||
=== Backend Package Info ===
|
||||
$(cat "$instance_dir/backend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found")
|
||||
|
||||
=== Frontend Package Info ===
|
||||
$(cat "$instance_dir/frontend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found")
|
||||
|
||||
=== Deployment Info ===
|
||||
$(cat "$instance_dir/deployment-info.txt" 2>/dev/null || echo "No deployment-info.txt found")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 3. Environment Configuration (Sanitized)
|
||||
# ========================================
|
||||
print_info "Collecting environment configuration (sanitized)..."
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Backend Environment Configuration (Sanitized) ===" >> "$diag_file"
|
||||
if [ -f "$instance_dir/backend/.env" ]; then
|
||||
sanitize_sensitive "$(cat "$instance_dir/backend/.env")" >> "$diag_file"
|
||||
else
|
||||
echo "Backend .env file not found" >> "$diag_file"
|
||||
fi
|
||||
echo "" >> "$diag_file"
|
||||
|
||||
# ========================================
|
||||
# 4. Service Status and Configuration
|
||||
# ========================================
|
||||
print_info "Collecting service information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
|
||||
=== Service Status and Configuration ===
|
||||
|
||||
=== Service Status ===
|
||||
$(systemctl status "$instance_name" 2>/dev/null || echo "Service not found")
|
||||
|
||||
=== Service File ===
|
||||
$(cat "/etc/systemd/system/${instance_name}.service" 2>/dev/null || echo "Service file not found")
|
||||
|
||||
=== Service is-enabled ===
|
||||
$(systemctl is-enabled "$instance_name" 2>/dev/null || echo "unknown")
|
||||
|
||||
=== Service is-active ===
|
||||
$(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 5. Service Logs
|
||||
# ========================================
|
||||
print_info "Collecting service logs..."
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Service Logs (last 500 lines) ===" >> "$diag_file"
|
||||
journalctl -u "$instance_name" -n 500 --no-pager >> "$diag_file" 2>&1 || \
|
||||
echo "Could not retrieve service logs" >> "$diag_file"
|
||||
echo "" >> "$diag_file"
|
||||
|
||||
# ========================================
|
||||
# 6. Nginx Configuration
|
||||
# ========================================
|
||||
print_info "Collecting nginx configuration..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
|
||||
=== Nginx Configuration ===
|
||||
|
||||
=== Nginx Status ===
|
||||
$(systemctl status nginx 2>/dev/null | head -20 || echo "Nginx not found")
|
||||
|
||||
=== Site Configuration ===
|
||||
$(cat "/etc/nginx/sites-available/$instance_name" 2>/dev/null || echo "Nginx config not found")
|
||||
|
||||
=== Nginx Error Log (last 100 lines) ===
|
||||
$(tail -100 /var/log/nginx/error.log 2>/dev/null || echo "Error log not accessible")
|
||||
|
||||
=== Nginx Access Log (last 50 lines) ===
|
||||
$(tail -50 /var/log/nginx/access.log 2>/dev/null || echo "Access log not accessible")
|
||||
|
||||
=== Nginx Test ===
|
||||
$(nginx -t 2>&1 || echo "Nginx test failed")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 7. Database Connection Test
|
||||
# ========================================
|
||||
print_info "Testing database connection..."
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Database Information ===" >> "$diag_file"
|
||||
echo "" >> "$diag_file"
|
||||
|
||||
if [ -f "$instance_dir/backend/.env" ]; then
|
||||
# Load .env
|
||||
set -a
|
||||
source "$instance_dir/backend/.env"
|
||||
set +a
|
||||
|
||||
# Parse DATABASE_URL
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
|
||||
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
=== Database Connection Details ===
|
||||
Host: $DB_HOST
|
||||
Port: $DB_PORT
|
||||
Database: $DB_NAME
|
||||
User: $DB_USER
|
||||
|
||||
=== PostgreSQL Status ===
|
||||
$(systemctl status postgresql 2>/dev/null | head -20 || echo "PostgreSQL status not available")
|
||||
|
||||
=== Connection Test ===
|
||||
EOF
|
||||
|
||||
if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT version();" >> "$diag_file" 2>&1; then
|
||||
echo "✅ Database connection: SUCCESSFUL" >> "$diag_file"
|
||||
else
|
||||
echo "❌ Database connection: FAILED" >> "$diag_file"
|
||||
fi
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Database Size ===" >> "$diag_file"
|
||||
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "
|
||||
SELECT
|
||||
pg_size_pretty(pg_database_size('$DB_NAME')) as database_size;
|
||||
" >> "$diag_file" 2>&1 || echo "Could not get database size" >> "$diag_file"
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Table Sizes ===" >> "$diag_file"
|
||||
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "
|
||||
SELECT
|
||||
schemaname,
|
||||
tablename,
|
||||
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
|
||||
LIMIT 10;
|
||||
" >> "$diag_file" 2>&1 || echo "Could not get table sizes" >> "$diag_file"
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Migration Status ===" >> "$diag_file"
|
||||
cd "$instance_dir/backend"
|
||||
npx prisma migrate status >> "$diag_file" 2>&1 || echo "Could not get migration status" >> "$diag_file"
|
||||
|
||||
echo "===================================================" >> "$diag_file"
|
||||
else
|
||||
echo "DATABASE_URL not found in .env" >> "$diag_file"
|
||||
fi
|
||||
else
|
||||
echo ".env file not found" >> "$diag_file"
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# 8. Redis Connection Test
|
||||
# ========================================
|
||||
print_info "Testing Redis connection..."
|
||||
|
||||
if [ -f "$instance_dir/backend/.env" ]; then
|
||||
# Load .env
|
||||
set -a
|
||||
source "$instance_dir/backend/.env"
|
||||
set +a
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
===================================================
|
||||
Redis Information
|
||||
===================================================
|
||||
|
||||
=== Redis Connection Details ===
|
||||
Host: ${REDIS_HOST:-localhost}
|
||||
Port: ${REDIS_PORT:-6379}
|
||||
User: ${REDIS_USER:-(none)}
|
||||
Database: ${REDIS_DB:-0}
|
||||
|
||||
=== Redis Status ===
|
||||
$(systemctl status redis-server 2>/dev/null | head -20 || echo "Redis status not available")
|
||||
|
||||
=== Connection Test ===
|
||||
EOF
|
||||
|
||||
# Test connection
|
||||
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
|
||||
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
|
||||
echo "✅ Redis connection (with user): SUCCESSFUL" >> "$diag_file"
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis INFO ===" >> "$diag_file"
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis Database Size ===" >> "$diag_file"
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1
|
||||
else
|
||||
echo "❌ Redis connection (with user): FAILED" >> "$diag_file"
|
||||
fi
|
||||
elif [ -n "$REDIS_PASSWORD" ]; then
|
||||
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
|
||||
echo "✅ Redis connection (requirepass): SUCCESSFUL" >> "$diag_file"
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis INFO ===" >> "$diag_file"
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis Database Size ===" >> "$diag_file"
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1
|
||||
else
|
||||
echo "❌ Redis connection (requirepass): FAILED" >> "$diag_file"
|
||||
fi
|
||||
else
|
||||
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
|
||||
echo "✅ Redis connection (no auth): SUCCESSFUL" >> "$diag_file"
|
||||
else
|
||||
echo "❌ Redis connection: FAILED" >> "$diag_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis ACL Users ===" >> "$diag_file"
|
||||
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file"
|
||||
elif [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file"
|
||||
fi
|
||||
|
||||
echo "===================================================" >> "$diag_file"
|
||||
else
|
||||
echo ".env file not found" >> "$diag_file"
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# 9. Network and Port Information
|
||||
# ========================================
|
||||
print_info "Collecting network information..."
|
||||
|
||||
# Get backend port from .env
|
||||
local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000")
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
===================================================
|
||||
Network and Port Information
|
||||
===================================================
|
||||
|
||||
=== Listening Ports ===
|
||||
$(ss -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || netstat -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || echo "Could not get port information")
|
||||
|
||||
=== Active Connections ===
|
||||
$(ss -tn state established | head -20 || echo "Could not get connection information")
|
||||
|
||||
=== Backend Port Connections (Port $backend_port) ===
|
||||
Total connections to backend: $(ss -tn | grep ":$backend_port" | wc -l || echo "0")
|
||||
$(ss -tn | grep ":$backend_port" | head -10 || echo "No connections found")
|
||||
|
||||
=== PostgreSQL Connections ===
|
||||
EOF
|
||||
|
||||
# Get PostgreSQL connection count
|
||||
if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
|
||||
PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c "
|
||||
SELECT
|
||||
count(*) as total_connections,
|
||||
count(*) FILTER (WHERE state = 'active') as active_connections,
|
||||
count(*) FILTER (WHERE state = 'idle') as idle_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '$DB_NAME';
|
||||
" >> "$diag_file" 2>&1 || echo "Could not get PostgreSQL connection stats" >> "$diag_file"
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== PostgreSQL Connection Details ===" >> "$diag_file"
|
||||
PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c "
|
||||
SELECT
|
||||
pid,
|
||||
usename,
|
||||
application_name,
|
||||
client_addr,
|
||||
state,
|
||||
query_start,
|
||||
state_change
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '$DB_NAME'
|
||||
ORDER BY query_start DESC
|
||||
LIMIT 20;
|
||||
" >> "$diag_file" 2>&1 || echo "Could not get connection details" >> "$diag_file"
|
||||
else
|
||||
echo "Database credentials not available" >> "$diag_file"
|
||||
fi
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Redis Connections ===" >> "$diag_file"
|
||||
|
||||
# Get Redis connection count
|
||||
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file"
|
||||
elif [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file"
|
||||
fi
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
|
||||
=== Firewall Status (UFW) ===
|
||||
$(ufw status 2>/dev/null || echo "UFW not available")
|
||||
|
||||
=== Firewall Status (iptables) ===
|
||||
$(iptables -L -n | head -50 2>/dev/null || echo "iptables not available")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 10. Process Information
|
||||
# ========================================
|
||||
print_info "Collecting process information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
===================================================
|
||||
Process Information
|
||||
===================================================
|
||||
|
||||
=== PatchMon Node Processes ===
|
||||
$(ps aux | grep -E "node.*$instance_dir|PID" | grep -v grep || echo "No processes found")
|
||||
|
||||
=== Top Processes (CPU) ===
|
||||
$(ps aux --sort=-%cpu | head -15)
|
||||
|
||||
=== Top Processes (Memory) ===
|
||||
$(ps aux --sort=-%mem | head -15)
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 11. SSL Certificate Information
|
||||
# ========================================
|
||||
print_info "Collecting SSL certificate information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
===================================================
|
||||
SSL Certificate Information
|
||||
===================================================
|
||||
|
||||
=== Certbot Certificates ===
|
||||
$(certbot certificates 2>/dev/null || echo "Certbot not available or no certificates")
|
||||
|
||||
=== SSL Certificate Files ===
|
||||
$(ls -lh /etc/letsencrypt/live/$instance_name/ 2>/dev/null || echo "No SSL certificates found for $instance_name")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# 12. Recent System Logs
|
||||
# ========================================
|
||||
print_info "Collecting recent system logs..."
|
||||
|
||||
journalctl -n 200 --no-pager >> "$diag_file" 2>&1 || \
|
||||
echo "Could not retrieve system logs" >> "$diag_file"
|
||||
|
||||
# ========================================
|
||||
# 13. Installation Log (if exists)
|
||||
# ========================================
|
||||
print_info "Collecting installation log..."
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "=== Installation Log (last 200 lines) ===" >> "$diag_file"
|
||||
if [ -f "$instance_dir/patchmon-install.log" ]; then
|
||||
tail -200 "$instance_dir/patchmon-install.log" >> "$diag_file" 2>&1
|
||||
else
|
||||
echo "No installation log found" >> "$diag_file"
|
||||
fi
|
||||
echo "" >> "$diag_file"
|
||||
|
||||
# ========================================
|
||||
# 14. Node.js and npm Information
|
||||
# ========================================
|
||||
print_info "Collecting Node.js information..."
|
||||
|
||||
cat >> "$diag_file" << EOF
|
||||
===================================================
|
||||
Node.js and npm Information
|
||||
===================================================
|
||||
|
||||
=== Node.js Version ===
|
||||
$(node --version 2>/dev/null || echo "Node.js not found")
|
||||
|
||||
=== npm Version ===
|
||||
$(npm --version 2>/dev/null || echo "npm not found")
|
||||
|
||||
=== Backend Dependencies ===
|
||||
$(cd "$instance_dir/backend" && npm list --depth=0 2>/dev/null || echo "Could not list backend dependencies")
|
||||
|
||||
===================================================
|
||||
EOF
|
||||
|
||||
# ========================================
|
||||
# Finalize diagnostics file
|
||||
# ========================================
|
||||
print_info "Finalizing diagnostics file..."
|
||||
|
||||
echo "" >> "$diag_file"
|
||||
echo "====================================================" >> "$diag_file"
|
||||
echo "END OF DIAGNOSTICS REPORT" >> "$diag_file"
|
||||
echo "====================================================" >> "$diag_file"
|
||||
echo "" >> "$diag_file"
|
||||
echo "IMPORTANT: Sensitive Information" >> "$diag_file"
|
||||
echo "Passwords, secrets, and tokens have been sanitized" >> "$diag_file"
|
||||
echo "and replaced with [REDACTED]. However, please review" >> "$diag_file"
|
||||
echo "before sharing to ensure no sensitive data is included." >> "$diag_file"
|
||||
echo "====================================================" >> "$diag_file"
|
||||
|
||||
print_status "Diagnostics file created: $diag_file"
|
||||
|
||||
# ========================================
|
||||
# Display summary
|
||||
# ========================================
|
||||
echo ""
|
||||
echo -e "${GREEN}====================================================${NC}"
|
||||
echo -e "${GREEN} Diagnostics Collection Complete!${NC}"
|
||||
echo -e "${GREEN}====================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Get service statuses and file size
|
||||
local service_status=$(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown")
|
||||
local nginx_status=$(systemctl is-active nginx 2>/dev/null || echo "unknown")
|
||||
local postgres_status=$(systemctl is-active postgresql 2>/dev/null || echo "unknown")
|
||||
local redis_status=$(systemctl is-active redis-server 2>/dev/null || echo "unknown")
|
||||
local file_size=$(du -h "$diag_file" 2>/dev/null | cut -f1 || echo "unknown")
|
||||
local line_count=$(wc -l < "$diag_file" 2>/dev/null || echo "unknown")
|
||||
|
||||
# Get connection counts for summary
|
||||
local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000")
|
||||
local backend_conn_count=$(ss -tn 2>/dev/null | grep ":$backend_port" | wc -l || echo "0")
|
||||
|
||||
local db_conn_count="N/A"
|
||||
if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
|
||||
db_conn_count=$(PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -t -A -c "SELECT count(*) FROM pg_stat_activity WHERE datname = '$DB_NAME';" 2>/dev/null || echo "N/A")
|
||||
fi
|
||||
|
||||
local redis_conn_count="N/A"
|
||||
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A")
|
||||
elif [ -n "$REDIS_PASSWORD" ]; then
|
||||
redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A")
|
||||
fi
|
||||
|
||||
# Compact, copyable summary
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
|
||||
echo -e "${BLUE}DIAGNOSTICS SUMMARY (copy-paste friendly)${NC}"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
|
||||
echo "Instance: $instance_name"
|
||||
echo "File: $diag_file"
|
||||
echo "Size: $file_size ($line_count lines)"
|
||||
echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "---"
|
||||
echo "Service Status: $service_status"
|
||||
echo "Nginx Status: $nginx_status"
|
||||
echo "PostgreSQL: $postgres_status"
|
||||
echo "Redis: $redis_status"
|
||||
echo "---"
|
||||
echo "Backend Port: $backend_port (Active Connections: $backend_conn_count)"
|
||||
echo "Database Connections: $db_conn_count"
|
||||
echo "Redis Connections: $redis_conn_count"
|
||||
echo "---"
|
||||
echo "View: cat $(basename "$diag_file")"
|
||||
echo "Or: less $(basename "$diag_file")"
|
||||
echo "Share: Send $(basename "$diag_file") to support"
|
||||
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
print_warning "Review file before sharing - sensitive data has been sanitized"
|
||||
echo ""
|
||||
|
||||
print_success "Done!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
|
||||
286
tools/fix-migrations.sh
Executable file
286
tools/fix-migrations.sh
Executable file
@@ -0,0 +1,286 @@
|
||||
#!/bin/bash
|
||||
# PatchMon Migration Fixer
|
||||
# Standalone script to detect and fix failed Prisma migrations
|
||||
# Usage: sudo bash fix-migrations.sh [instance-name]
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print functions
|
||||
print_status() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
print_error "This script must be run as root"
|
||||
print_info "Please run: sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to detect PatchMon installations
|
||||
detect_installations() {
|
||||
local installations=()
|
||||
|
||||
if [ -d "/opt" ]; then
|
||||
for dir in /opt/*/; do
|
||||
local dirname=$(basename "$dir")
|
||||
# Skip backup directories
|
||||
if [[ "$dirname" =~ \.backup\. ]]; then
|
||||
continue
|
||||
fi
|
||||
# Check if it's a PatchMon installation
|
||||
if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
|
||||
installations+=("$dirname")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "${installations[@]}"
|
||||
}
|
||||
|
||||
# Function to select installation
|
||||
select_installation() {
|
||||
local installations=($(detect_installations))
|
||||
|
||||
if [ ${#installations[@]} -eq 0 ]; then
|
||||
print_error "No PatchMon installations found in /opt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$1" ]; then
|
||||
# Use provided instance name
|
||||
if [[ " ${installations[@]} " =~ " $1 " ]]; then
|
||||
echo "$1"
|
||||
return 0
|
||||
else
|
||||
print_error "Instance '$1' not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_info "Found ${#installations[@]} installation(s):"
|
||||
echo ""
|
||||
|
||||
local i=1
|
||||
declare -A install_map
|
||||
for install in "${installations[@]}"; do
|
||||
printf "%2d. %s\n" "$i" "$install"
|
||||
install_map[$i]="$install"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -n -e "${BLUE}Select installation number [1]: ${NC}"
|
||||
read -r selection
|
||||
|
||||
selection=${selection:-1}
|
||||
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then
|
||||
echo "${install_map[$selection]}"
|
||||
return 0
|
||||
else
|
||||
print_error "Invalid selection"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check and fix failed migrations
|
||||
fix_failed_migrations() {
|
||||
local db_name="$1"
|
||||
local db_user="$2"
|
||||
local db_pass="$3"
|
||||
local db_host="${4:-localhost}"
|
||||
|
||||
print_info "Checking for failed migrations in database..."
|
||||
|
||||
# Query for failed migrations
|
||||
local failed_migrations
|
||||
failed_migrations=$(PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -t -A -c \
|
||||
"SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$failed_migrations" ]; then
|
||||
print_status "No failed migrations found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_warning "Found failed migration(s):"
|
||||
echo "$failed_migrations" | while read -r migration; do
|
||||
[ -n "$migration" ] && print_warning " - $migration"
|
||||
done
|
||||
echo ""
|
||||
|
||||
print_info "What would you like to do?"
|
||||
echo " 1. Clean and retry (delete failed records and re-run migration)"
|
||||
echo " 2. Mark as completed (if schema changes are already applied)"
|
||||
echo " 3. Show migration details only"
|
||||
echo " 4. Cancel"
|
||||
echo ""
|
||||
echo -n -e "${BLUE}Select option [1]: ${NC}"
|
||||
read -r option
|
||||
|
||||
option=${option:-1}
|
||||
|
||||
case $option in
|
||||
1)
|
||||
print_info "Cleaning failed migrations and preparing for retry..."
|
||||
echo "$failed_migrations" | while read -r migration; do
|
||||
if [ -n "$migration" ]; then
|
||||
print_info "Processing: $migration"
|
||||
|
||||
# Mark as rolled back
|
||||
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
|
||||
"UPDATE _prisma_migrations SET rolled_back_at = NOW() WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
|
||||
|
||||
# Delete the failed record
|
||||
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
|
||||
"DELETE FROM _prisma_migrations WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
|
||||
|
||||
print_status "Cleared: $migration"
|
||||
fi
|
||||
done
|
||||
print_status "Failed migrations cleared - ready to retry"
|
||||
return 0
|
||||
;;
|
||||
2)
|
||||
print_info "Marking migrations as completed..."
|
||||
echo "$failed_migrations" | while read -r migration; do
|
||||
if [ -n "$migration" ]; then
|
||||
print_info "Marking as complete: $migration"
|
||||
|
||||
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
|
||||
"UPDATE _prisma_migrations SET finished_at = NOW(), logs = 'Manually resolved by fix-migrations.sh' WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
|
||||
|
||||
print_status "Marked complete: $migration"
|
||||
fi
|
||||
done
|
||||
print_status "All migrations marked as completed"
|
||||
return 0
|
||||
;;
|
||||
3)
|
||||
print_info "Migration details:"
|
||||
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
|
||||
"SELECT migration_name, started_at, finished_at, rolled_back_at, logs FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;"
|
||||
return 0
|
||||
;;
|
||||
4)
|
||||
print_info "Cancelled"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
print_error "Invalid option"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main script
|
||||
main() {
|
||||
echo -e "${BLUE}====================================================${NC}"
|
||||
echo -e "${BLUE} PatchMon Migration Fixer${NC}"
|
||||
echo -e "${BLUE}====================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Select instance
|
||||
instance_name=$(select_installation "$1")
|
||||
instance_dir="/opt/$instance_name"
|
||||
|
||||
print_info "Selected instance: $instance_name"
|
||||
print_info "Directory: $instance_dir"
|
||||
echo ""
|
||||
|
||||
# Load .env to get database credentials
|
||||
if [ ! -f "$instance_dir/backend/.env" ]; then
|
||||
print_error "Cannot find .env file at $instance_dir/backend/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source .env
|
||||
set -a
|
||||
source "$instance_dir/backend/.env"
|
||||
set +a
|
||||
|
||||
# Parse DATABASE_URL
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
print_error "DATABASE_URL not found in .env file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
|
||||
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
print_info "Database: $DB_NAME"
|
||||
print_info "User: $DB_USER"
|
||||
print_info "Host: $DB_HOST"
|
||||
echo ""
|
||||
|
||||
# Test database connection
|
||||
print_info "Testing database connection..."
|
||||
if ! PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then
|
||||
print_error "Cannot connect to database"
|
||||
exit 1
|
||||
fi
|
||||
print_status "Database connection successful"
|
||||
echo ""
|
||||
|
||||
# Check Prisma migration status
|
||||
print_info "Checking Prisma migration status..."
|
||||
cd "$instance_dir/backend"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}=== Prisma Migration Status ===${NC}"
|
||||
npx prisma migrate status 2>&1 || true
|
||||
echo -e "${YELLOW}==============================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check for failed migrations
|
||||
fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_HOST"
|
||||
|
||||
# Ask if user wants to run migrations now
|
||||
echo ""
|
||||
echo -n -e "${BLUE}Do you want to run 'npx prisma migrate deploy' now? [y/N]: ${NC}"
|
||||
read -r run_migrate
|
||||
|
||||
if [[ "$run_migrate" =~ ^[Yy] ]]; then
|
||||
print_info "Running migrations..."
|
||||
cd "$instance_dir/backend"
|
||||
|
||||
if npx prisma migrate deploy; then
|
||||
print_status "Migrations completed successfully!"
|
||||
else
|
||||
print_error "Migration failed"
|
||||
print_info "You may need to run this script again or investigate further"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_info "Skipped migration deployment"
|
||||
print_info "Run manually: cd $instance_dir/backend && npx prisma migrate deploy"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
print_status "Done!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
|
||||
41
tools/fixconnlimit.sh
Normal file
41
tools/fixconnlimit.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to update hardcoded connection pool values in prisma.js
|
||||
# Usage: ./update_pool_values.sh [connection_limit] [pool_timeout] [connect_timeout] [idle_timeout] [max_lifetime]
|
||||
|
||||
set -e
|
||||
|
||||
FILE="${1:-backend/src/config/prisma.js}"
|
||||
|
||||
# Get values from arguments or use defaults
|
||||
NEW_CONN_LIMIT="${2:-30}"
|
||||
NEW_POOL_TIMEOUT="${3:-20}"
|
||||
NEW_CONNECT_TIMEOUT="${4:-10}"
|
||||
NEW_IDLE_TIMEOUT="${5:-300}"
|
||||
NEW_MAX_LIFETIME="${6:-1800}"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "Error: File not found: $FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create backup
|
||||
BACKUP_FILE="${FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$FILE" "$BACKUP_FILE"
|
||||
echo "Backup created: $BACKUP_FILE"
|
||||
|
||||
# Replace the hardcoded values
|
||||
sed -i "s|url\.searchParams\.set(\"connection_limit\", \".*\");|url.searchParams.set(\"connection_limit\", \"$NEW_CONN_LIMIT\");|g" "$FILE"
|
||||
sed -i "s|url\.searchParams\.set(\"pool_timeout\", \".*\");|url.searchParams.set(\"pool_timeout\", \"$NEW_POOL_TIMEOUT\");|g" "$FILE"
|
||||
sed -i "s|url\.searchParams\.set(\"connect_timeout\", \".*\");|url.searchParams.set(\"connect_timeout\", \"$NEW_CONNECT_TIMEOUT\");|g" "$FILE"
|
||||
sed -i "s|url\.searchParams\.set(\"idle_timeout\", \".*\");|url.searchParams.set(\"idle_timeout\", \"$NEW_IDLE_TIMEOUT\");|g" "$FILE"
|
||||
sed -i "s|url\.searchParams\.set(\"max_lifetime\", \".*\");|url.searchParams.set(\"max_lifetime\", \"$NEW_MAX_LIFETIME\");|g" "$FILE"
|
||||
|
||||
echo "Updated values:"
|
||||
echo " connection_limit: $NEW_CONN_LIMIT"
|
||||
echo " pool_timeout: $NEW_POOL_TIMEOUT"
|
||||
echo " connect_timeout: $NEW_CONNECT_TIMEOUT"
|
||||
echo " idle_timeout: $NEW_IDLE_TIMEOUT"
|
||||
echo " max_lifetime: $NEW_MAX_LIFETIME"
|
||||
echo ""
|
||||
echo "Changes applied to $FILE"
|
||||
128
tools/fixconnstrings.sh
Normal file
128
tools/fixconnstrings.sh
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to fix HTTP connection limit issue for hosts page
|
||||
# This adds a bulk status endpoint and updates the frontend to use it
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Fixing HTTP connection limit issue..."
|
||||
|
||||
# Backup files first
|
||||
echo "📦 Creating backups..."
|
||||
cp backend/src/routes/wsRoutes.js backend/src/routes/wsRoutes.js.bak
|
||||
cp frontend/src/pages/Hosts.jsx frontend/src/pages/Hosts.jsx.bak
|
||||
|
||||
# Add bulk status endpoint to wsRoutes.js
|
||||
echo "➕ Adding bulk status endpoint to backend..."
|
||||
|
||||
cat > /tmp/ws_routes_addition.txt << 'EOF'
|
||||
// Get WebSocket connection status for multiple hosts at once
|
||||
router.get("/status", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiIds } = req.query; // Comma-separated list of api_ids
|
||||
const idArray = apiIds ? apiIds.split(',').filter(id => id.trim()) : [];
|
||||
|
||||
const statusMap = {};
|
||||
idArray.forEach(apiId => {
|
||||
statusMap[apiId] = getConnectionInfo(apiId);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: statusMap,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching bulk WebSocket status:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to fetch WebSocket status",
|
||||
});
|
||||
}
|
||||
});
|
||||
EOF
|
||||
|
||||
# Find the line number of the first router.get and insert after it
|
||||
LINENUM=$(grep -n "router.get.*status.*apiId" backend/src/routes/wsRoutes.js | head -1 | cut -d: -f1)
|
||||
sed -i "${LINENUM}r /tmp/ws_routes_addition.txt" backend/src/routes/wsRoutes.js
|
||||
|
||||
# Now update the frontend to use the bulk endpoint
|
||||
echo "🔄 Updating frontend to use bulk endpoint..."
|
||||
|
||||
# Create a sed script to replace the fetchInitialStatus function
|
||||
cat > /tmp/hosts_fix.sed << 'EOF'
|
||||
/const fetchInitialStatus = async/,\}/c\
|
||||
const fetchInitialStatus = async () => {\
|
||||
const apiIds = hosts\
|
||||
.filter((host) => host.api_id)\
|
||||
.map(host => host.api_id);\
|
||||
\
|
||||
if (apiIds.length === 0) return;\
|
||||
\
|
||||
try {\
|
||||
const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {\
|
||||
headers: {\
|
||||
Authorization: `Bearer ${token}`,\
|
||||
},\
|
||||
});\
|
||||
if (response.ok) {\
|
||||
const result = await response.json();\
|
||||
setWsStatusMap(result.data);\
|
||||
}\
|
||||
} catch (_error) {\
|
||||
// Silently handle errors\
|
||||
}\
|
||||
};
|
||||
EOF
|
||||
|
||||
# Apply the sed script (multiline replacement is tricky with sed, so we'll use a different approach)
|
||||
echo "✨ Using awk for multi-line replacement..."
|
||||
|
||||
# Create a temporary awk script
|
||||
cat > /tmp/update_hosts.awk << 'AWK_EOF'
|
||||
BEGIN { in_function=0; brace_count=0 }
|
||||
/store.fetchInitialStatus/ { printing=1 }
|
||||
/const fetchInitialStatus = async/ {
|
||||
print " // Fetch initial WebSocket status for all hosts";
|
||||
print " const fetchInitialStatus = async () => {";
|
||||
print " const apiIds = hosts";
|
||||
print " .filter((host) => host.api_id)";
|
||||
print " .map(host => host.api_id);";
|
||||
print "";
|
||||
print " if (apiIds.length === 0) return;";
|
||||
print "";
|
||||
print " try {";
|
||||
print " const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {";
|
||||
print " headers: {";
|
||||
print " Authorization: `Bearer ${token}`,";
|
||||
print " },";
|
||||
print " });";
|
||||
print " if (response.ok) {";
|
||||
print " const result = await response.json();";
|
||||
print " setWsStatusMap(result.data);";
|
||||
print " }";
|
||||
print " } catch (_error) {";
|
||||
print " // Silently handle errors";
|
||||
print " }";
|
||||
print " };";
|
||||
skipping=1;
|
||||
next
|
||||
}
|
||||
skipping && /^\t\t\}/ { skipping=0; next }
|
||||
skipping { next }
|
||||
{ print }
|
||||
AWK_EOF
|
||||
|
||||
awk -f /tmp/update_hosts.awk frontend/src/pages/Hosts.jsx.bak > frontend/src/pages/Hosts.jsx
|
||||
|
||||
# Clean up temp files
|
||||
rm /tmp/ws_routes_addition.txt /tmp/hosts_fix.sed /tmp/update_hosts.awk
|
||||
|
||||
echo "✅ Done! Files have been modified."
|
||||
echo ""
|
||||
echo "📝 Changes made:"
|
||||
echo " - backend/src/routes/wsRoutes.js: Added bulk status endpoint"
|
||||
echo " - frontend/src/pages/Hosts.jsx: Updated to use bulk endpoint"
|
||||
echo ""
|
||||
echo "💾 Backups saved as:"
|
||||
echo " - backend/src/routes/wsRoutes.js.bak"
|
||||
echo " - frontend/src/pages/Hosts.jsx.bak"
|
||||
Reference in New Issue
Block a user