Building Docker compatibilty within the Agent

This commit is contained in:
Muhammad Ibrahim
2025-10-26 14:10:01 +00:00
parent 61523c9a44
commit ae6afb0ef4
25 changed files with 1478 additions and 302 deletions

View File

@@ -3,6 +3,13 @@ DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patch
PM_DB_CONN_MAX_ATTEMPTS=30 PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2 PM_DB_CONN_WAIT_INTERVAL=2
# 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 Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h JWT_EXPIRES_IN=1h

View File

@@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() {
// Parse the URL // Parse the URL
const url = new URL(originalUrl); const url = new URL(originalUrl);
// Add connection pooling parameters for multiple instances // Add connection pooling parameters - configurable via environment variables
url.searchParams.set("connection_limit", "5"); // Reduced from default 10 const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
url.searchParams.set("pool_timeout", "10"); // 10 seconds const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
url.searchParams.set("connect_timeout", "10"); // 10 seconds const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
url.searchParams.set("idle_timeout", "300"); // 5 minutes const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
url.searchParams.set("max_lifetime", "1800"); // 30 minutes 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(); return url.toString();
} }

View File

@@ -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 // Get queue health status
router.get("/health", authenticateToken, async (_req, res) => { router.get("/health", authenticateToken, async (_req, res) => {
try { try {
@@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_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), 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.GITHUB_UPDATE_CHECK].delayed +
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_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: runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active + stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_REPO_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: failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed + stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_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) => { totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
return ( return (
@@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => {
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP], stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
}, },
{ {
name: "Collect Host Statistics", name: "Docker Inventory Cleanup",
queue: QUEUE_NAMES.AGENT_COMMANDS, queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
description: "Collects package statistics from connected agents only", description:
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`, "Removes Docker containers and images for non-existent hosts",
schedule: "Daily at 4 AM",
lastRun: recentJobs[4][0]?.finishedOn lastRun: recentJobs[4][0]?.finishedOn
? new Date(recentJobs[4][0].finishedOn).toLocaleString() ? new Date(recentJobs[4][0].finishedOn).toLocaleString()
: "Never", : "Never",
@@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
: recentJobs[4][0] : recentJobs[4][0]
? "Success" ? "Success"
: "Never run", : "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], stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
}, },
].sort((a, b) => { ].sort((a, b) => {

View File

@@ -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) => { router.post("/collect", async (req, res) => {
try { try {
const { apiId, apiKey, containers, images, updates } = req.body; 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,
hostname,
machine_id,
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 // GET /api/v1/docker/agent - Serve the Docker agent installation script
router.get("/agent", async (_req, res) => { router.get("/agent", async (_req, res) => {
try { try {

View File

@@ -356,6 +356,29 @@ router.post(
}); });
} catch (error) { } catch (error) {
console.error("Host creation error:", 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" }); res.status(500).json({ error: "Failed to create host" });
} }
}, },
@@ -786,19 +809,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
// Ping endpoint for health checks (now uses API credentials) // Ping endpoint for health checks (now uses API credentials)
router.post("/ping", validateApiCredentials, async (req, res) => { router.post("/ping", validateApiCredentials, async (req, res) => {
try { 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({ await prisma.hosts.update({
where: { id: req.hostRecord.id }, where: { id: req.hostRecord.id },
data: { data: {
last_update: new Date(), last_update: now,
updated_at: new Date(), updated_at: now,
status: "active",
}, },
}); });
const response = { const response = {
message: "Ping successful", message: "Ping successful",
timestamp: new Date().toISOString(), timestamp: now.toISOString(),
friendlyName: req.hostRecord.friendly_name, friendlyName: req.hostRecord.friendly_name,
agentStartup: isStartup,
}; };
// Check if this is a crontab update trigger // Check if this is a crontab update trigger

View 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,
hostname,
machine_id,
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;

View File

@@ -14,13 +14,16 @@ const router = express.Router();
function getCurrentVersion() { function getCurrentVersion() {
try { try {
const packageJson = require("../../package.json"); 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) { } catch (packageError) {
console.warn( console.error(
"Could not read version from package.json, using fallback:", "Could not read version from package.json:",
packageError.message, packageError.message,
); );
return "1.3.0"; return "unknown";
} }
} }

View File

@@ -66,6 +66,7 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
const gethomepageRoutes = require("./routes/gethomepageRoutes"); const gethomepageRoutes = require("./routes/gethomepageRoutes");
const automationRoutes = require("./routes/automationRoutes"); const automationRoutes = require("./routes/automationRoutes");
const dockerRoutes = require("./routes/dockerRoutes"); const dockerRoutes = require("./routes/dockerRoutes");
const integrationRoutes = require("./routes/integrationRoutes");
const wsRoutes = require("./routes/wsRoutes"); const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes"); const agentVersionRoutes = require("./routes/agentVersionRoutes");
const { initSettings } = require("./services/settingsService"); const { initSettings } = require("./services/settingsService");
@@ -471,6 +472,7 @@ app.use(
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
app.use(`/api/${apiVersion}/automation`, automationRoutes); app.use(`/api/${apiVersion}/automation`, automationRoutes);
app.use(`/api/${apiVersion}/docker`, dockerRoutes); app.use(`/api/${apiVersion}/docker`, dockerRoutes);
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
app.use(`/api/${apiVersion}/ws`, wsRoutes); app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);

View File

@@ -428,26 +428,29 @@ class AgentVersionService {
async getVersionInfo() { async getVersionInfo() {
let hasUpdate = false; let hasUpdate = false;
let updateStatus = "unknown"; 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 // Latest version should ALWAYS come from GitHub, not from local binaries
if (this.currentVersion) { // currentVersion = what's installed locally
effectiveLatestVersion = this.currentVersion; // latestVersion = what's available on GitHub
if (this.latestVersion) {
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
} else {
console.log( console.log(
`🔄 Using local agent version ${this.currentVersion} as latest`, `⚠️ No GitHub release version available (API may be unavailable)`,
);
} 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}`,
); );
} }
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( const comparison = compareVersions(
this.currentVersion, this.currentVersion,
effectiveLatestVersion, this.latestVersion,
); );
if (comparison < 0) { if (comparison < 0) {
hasUpdate = true; hasUpdate = true;
@@ -459,25 +462,25 @@ class AgentVersionService {
hasUpdate = false; hasUpdate = false;
updateStatus = "up-to-date"; updateStatus = "up-to-date";
} }
} else if (effectiveLatestVersion && !this.currentVersion) { } else if (this.latestVersion && !this.currentVersion) {
hasUpdate = true; hasUpdate = true;
updateStatus = "no-agent"; 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) // We have a current version but no latest version (GitHub API unavailable)
hasUpdate = false; hasUpdate = false;
updateStatus = "github-unavailable"; updateStatus = "github-unavailable";
} else if (!this.currentVersion && !effectiveLatestVersion) { } else if (!this.currentVersion && !this.latestVersion) {
updateStatus = "no-data"; updateStatus = "no-data";
} }
return { return {
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
latestVersion: effectiveLatestVersion, latestVersion: this.latestVersion, // Always return GitHub version, not local
hasUpdate: hasUpdate, hasUpdate: hasUpdate,
updateStatus: updateStatus, updateStatus: updateStatus,
lastChecked: this.lastChecked, lastChecked: this.lastChecked,
supportedArchitectures: this.supportedArchitectures, supportedArchitectures: this.supportedArchitectures,
status: effectiveLatestVersion ? "ready" : "no-releases", status: this.latestVersion ? "ready" : "no-releases",
}; };
} }

View File

@@ -99,8 +99,22 @@ function init(server, prismaClient) {
// Notify subscribers of connection // Notify subscribers of connection
notifyConnectionChange(apiId, true); notifyConnectionChange(apiId, true);
ws.on("message", () => { ws.on("message", async (data) => {
// Currently we don't need to handle agent->server messages // 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", () => { ws.on("close", () => {
@@ -255,6 +269,62 @@ function subscribeToConnectionChanges(apiId, callback) {
}; };
} }
// Handle Docker container status events from agent
async function handleDockerStatusEvent(apiId, message) {
try {
const { 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 = { module.exports = {
init, init,
broadcastSettingsUpdate, broadcastSettingsUpdate,

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

View File

@@ -52,17 +52,24 @@ class GitHubUpdateCheck {
} }
// Read version from package.json // Read version from package.json
let currentVersion = "1.3.0"; // fallback let currentVersion = null;
try { try {
const packageJson = require("../../../package.json"); const packageJson = require("../../../package.json");
if (packageJson?.version) { if (packageJson?.version) {
currentVersion = packageJson.version; currentVersion = packageJson.version;
} }
} catch (packageError) { } catch (packageError) {
console.warn( console.error(
"Could not read version from package.json:", "Could not read version from package.json:",
packageError.message, 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 = const isUpdateAvailable =

View File

@@ -8,6 +8,7 @@ const GitHubUpdateCheck = require("./githubUpdateCheck");
const SessionCleanup = require("./sessionCleanup"); const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup"); const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const OrphanedPackageCleanup = require("./orphanedPackageCleanup"); const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
// Queue names // Queue names
const QUEUE_NAMES = { const QUEUE_NAMES = {
@@ -15,6 +16,7 @@ const QUEUE_NAMES = {
SESSION_CLEANUP: "session-cleanup", SESSION_CLEANUP: "session-cleanup",
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup", ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup", ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
AGENT_COMMANDS: "agent-commands", AGENT_COMMANDS: "agent-commands",
}; };
@@ -91,6 +93,8 @@ class QueueManager {
new OrphanedRepoCleanup(this); new OrphanedRepoCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
new OrphanedPackageCleanup(this); new OrphanedPackageCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
new DockerInventoryCleanup(this);
console.log("✅ All automation classes initialized"); console.log("✅ All automation classes initialized");
} }
@@ -149,6 +153,15 @@ class QueueManager {
workerOptions, 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,
);
// Agent Commands Worker // Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker( this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS, QUEUE_NAMES.AGENT_COMMANDS,
@@ -205,6 +218,7 @@ class QueueManager {
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule(); await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_REPO_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.ORPHANED_PACKAGE_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
} }
/** /**
@@ -228,6 +242,12 @@ class QueueManager {
].triggerManual(); ].triggerManual();
} }
async triggerDockerInventoryCleanup() {
return this.automations[
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
].triggerManual();
}
/** /**
* Get queue statistics * Get queue statistics
*/ */

View File

@@ -33,7 +33,8 @@ async function checkPublicRepo(owner, repo) {
try { try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; 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 { try {
const packageJson = require("../../../package.json"); const packageJson = require("../../../package.json");
if (packageJson?.version) { if (packageJson?.version) {
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
} }
} catch (packageError) { } catch (packageError) {
console.warn( 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, packageError.message,
); );
} }

View File

@@ -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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": {
"includes": ["**", "!**/*.css"]
},
"formatter": { "formatter": {
"enabled": true "enabled": true
}, },

View File

@@ -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_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` | | `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 ##### Redis Configuration
| Variable | Description | Default | | Variable | Description | Default |

View File

@@ -50,6 +50,10 @@ services:
SERVER_HOST: localhost SERVER_HOST: localhost
SERVER_PORT: 3000 SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000 CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
# Rate Limiting (times in milliseconds) # Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000 RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000 RATE_LIMIT_MAX: 5000

View File

@@ -56,6 +56,10 @@ services:
SERVER_HOST: localhost SERVER_HOST: localhost
SERVER_PORT: 3000 SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000 CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
# Rate Limiting (times in milliseconds) # Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000 RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000 RATE_LIMIT_MAX: 5000

View File

@@ -54,7 +54,7 @@ const UsersTab = () => {
}); });
// Update user mutation // Update user mutation
const _updateUserMutation = useMutation({ const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data), mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(["users"]); queryClient.invalidateQueries(["users"]);

View File

@@ -169,6 +169,20 @@ const Automation = () => {
year: "numeric", 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") { if (schedule === "Every hour") {
const now = new Date(); const now = new Date();
const nextHour = new Date(now); const nextHour = new Date(now);
@@ -209,6 +223,13 @@ const Automation = () => {
tomorrow.setHours(3, 0, 0, 0); tomorrow.setHours(3, 0, 0, 0);
return tomorrow.getTime(); 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") { if (schedule === "Every hour") {
const now = new Date(); const now = new Date();
const nextHour = new Date(now); const nextHour = new Date(now);
@@ -269,6 +290,8 @@ const Automation = () => {
endpoint = "/automation/trigger/orphaned-repo-cleanup"; endpoint = "/automation/trigger/orphaned-repo-cleanup";
} else if (jobType === "orphaned-packages") { } else if (jobType === "orphaned-packages") {
endpoint = "/automation/trigger/orphaned-package-cleanup"; endpoint = "/automation/trigger/orphaned-package-cleanup";
} else if (jobType === "docker-inventory") {
endpoint = "/automation/trigger/docker-inventory-cleanup";
} else if (jobType === "agent-collection") { } else if (jobType === "agent-collection") {
endpoint = "/automation/trigger/agent-collection"; endpoint = "/automation/trigger/agent-collection";
} }
@@ -584,6 +607,10 @@ const Automation = () => {
automation.queue.includes("orphaned-package") automation.queue.includes("orphaned-package")
) { ) {
triggerManualJob("orphaned-packages"); triggerManualJob("orphaned-packages");
} else if (
automation.queue.includes("docker-inventory")
) {
triggerManualJob("docker-inventory");
} else if ( } else if (
automation.queue.includes("agent-commands") automation.queue.includes("agent-commands")
) { ) {

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertTriangle, AlertTriangle,
ArrowDown, ArrowDown,
@@ -11,6 +11,7 @@ import {
Search, Search,
Server, Server,
Shield, Shield,
Trash2,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
@@ -18,12 +19,15 @@ import { Link } from "react-router-dom";
import api from "../utils/api"; import api from "../utils/api";
const Docker = () => { const Docker = () => {
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("containers"); const [activeTab, setActiveTab] = useState("containers");
const [sortField, setSortField] = useState("status"); const [sortField, setSortField] = useState("status");
const [sortDirection, setSortDirection] = useState("asc"); const [sortDirection, setSortDirection] = useState("asc");
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [sourceFilter, setSourceFilter] = useState("all"); const [sourceFilter, setSourceFilter] = useState("all");
const [deleteContainerModal, setDeleteContainerModal] = useState(null);
const [deleteImageModal, setDeleteImageModal] = useState(null);
// Fetch Docker dashboard data // Fetch Docker dashboard data
const { data: dashboard, isLoading: dashboardLoading } = useQuery({ const { data: dashboard, isLoading: dashboardLoading } = useQuery({
@@ -36,7 +40,11 @@ const Docker = () => {
}); });
// Fetch containers // Fetch containers
const { data: containersData, isLoading: containersLoading } = useQuery({ const {
data: containersData,
isLoading: containersLoading,
refetch: refetchContainers,
} = useQuery({
queryKey: ["docker", "containers", statusFilter], queryKey: ["docker", "containers", statusFilter],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -49,7 +57,11 @@ const Docker = () => {
}); });
// Fetch images // Fetch images
const { data: imagesData, isLoading: imagesLoading } = useQuery({ const {
data: imagesData,
isLoading: imagesLoading,
refetch: refetchImages,
} = useQuery({
queryKey: ["docker", "images", sourceFilter], queryKey: ["docker", "images", sourceFilter],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -81,6 +93,42 @@ const Docker = () => {
enabled: activeTab === "updates", 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 // Filter and sort containers
const filteredContainers = useMemo(() => { const filteredContainers = useMemo(() => {
if (!containersData?.containers) return []; if (!containersData?.containers) return [];
@@ -288,32 +336,36 @@ const Docker = () => {
}; };
return ( return (
<div className="space-y-6"> <div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center"> <div className="flex items-center justify-between mb-6">
<div> <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 Docker Inventory
</h1> </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 Monitor containers, images, and updates across your infrastructure
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
// Trigger refresh of all queries // Trigger refresh based on active tab
window.location.reload(); if (activeTab === "containers") refetchContainers();
else if (activeTab === "images") refetchImages();
else 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" className="btn-outline flex items-center justify-center p-2"
title="Refresh data"
> >
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4" />
Refresh
</button> </button>
</div> </div>
</div>
{/* Dashboard Cards */} {/* Stats Summary */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -400,11 +452,11 @@ const Docker = () => {
</div> </div>
</div> </div>
{/* Tabs and Content */} {/* Docker List */}
<div className="card"> <div className="card flex-1 flex flex-col overflow-hidden min-h-0">
{/* Tab Navigation */} {/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-700"> <div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs"> <nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
{[ {[
{ id: "containers", label: "Containers", icon: Container }, { id: "containers", label: "Containers", icon: Container },
{ id: "images", label: "Images", icon: Package }, { id: "images", label: "Images", icon: Package },
@@ -443,7 +495,7 @@ const Docker = () => {
</div> </div>
{/* Filters and Search */} {/* 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 flex-col sm:flex-row gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="relative"> <div className="relative">
@@ -498,7 +550,7 @@ const Docker = () => {
</div> </div>
{/* Tab Content */} {/* Tab Content */}
<div className="p-6"> <div className="p-4 flex-1 overflow-auto">
{/* Containers Tab */} {/* Containers Tab */}
{activeTab === "containers" && ( {activeTab === "containers" && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -522,83 +574,80 @@ const Docker = () => {
</p> </p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> <table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-900"> <thead className="bg-secondary-50 dark:bg-secondary-700">
<tr> <tr>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("name")} onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Container Name Container Name
{getSortIcon("name")} {getSortIcon("name")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("image")} onClick={() => handleSort("image")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Image Image
{getSortIcon("image")} {getSortIcon("image")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("status")} onClick={() => handleSort("status")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Status Status
{getSortIcon("status")} {getSortIcon("status")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("host")} onClick={() => handleSort("host")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Host Host
{getSortIcon("host")} {getSortIcon("host")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </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) => ( {filteredContainers.map((container) => (
<tr <tr
key={container.id} 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"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Container className="h-5 w-5 text-secondary-400 mr-3" /> <Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link <Link
to={`/docker/containers/${container.id}`} 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} {container.name}
</Link> </Link>
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-4 py-2">
<div className="text-sm text-secondary-900 dark:text-white"> <div className="text-sm text-secondary-900 dark:text-white">
{container.image_name}:{container.image_tag} {container.image_name}:{container.image_tag}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap text-center">
{getStatusBadge(container.status)} {getStatusBadge(container.status)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap">
<Link <Link
to={`/hosts/${container.host_id}`} to={`/hosts/${container.host_id}`}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300" 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"} "Unknown"}
</Link> </Link>
</td> </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">
<div className="flex items-center justify-center gap-3">
<Link <Link
to={`/docker/containers/${container.id}`} 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" 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="h-4 w-4" />
<ExternalLink className="ml-1 h-4 w-4" />
</Link> </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> </td>
</tr> </tr>
))} ))}
@@ -648,88 +707,79 @@ const Docker = () => {
</p> </p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> <table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-900"> <thead className="bg-secondary-50 dark:bg-secondary-700">
<tr> <tr>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("repository")} onClick={() => handleSort("repository")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Repository Repository
{getSortIcon("repository")} {getSortIcon("repository")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("tag")} onClick={() => handleSort("tag")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Tag Tag
{getSortIcon("tag")} {getSortIcon("tag")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Source Source
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("containers")} onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Containers Containers
{getSortIcon("containers")} {getSortIcon("containers")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Updates Updates
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </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) => ( {filteredImages.map((image) => (
<tr <tr
key={image.id} 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"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Package className="h-5 w-5 text-secondary-400 mr-3" /> <Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link <Link
to={`/docker/images/${image.id}`} 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} {image.repository}
</Link> </Link>
</div> </div>
</td> </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"> <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} {image.tag}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap text-center">
{getSourceBadge(image.source)} {getSourceBadge(image.source)}
</td> </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} {image._count?.docker_containers || 0}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-4 py-2 whitespace-nowrap text-center">
{image.hasUpdates ? ( {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"> <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" /> <AlertTriangle className="h-3 w-3 mr-1" />
@@ -741,14 +791,24 @@ const Docker = () => {
</span> </span>
)} )}
</td> </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">
<div className="flex items-center justify-center gap-3">
<Link <Link
to={`/docker/images/${image.id}`} 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" className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
title="View details"
> >
View <ExternalLink className="h-4 w-4" />
<ExternalLink className="ml-1 h-4 w-4" />
</Link> </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> </td>
</tr> </tr>
))} ))}
@@ -781,86 +841,80 @@ const Docker = () => {
</p> </p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> <table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-900"> <thead className="bg-secondary-50 dark:bg-secondary-700">
<tr> <tr>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("name")} onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Host Name Host Name
{getSortIcon("name")} {getSortIcon("name")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("containers")} onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Containers Containers
{getSortIcon("containers")} {getSortIcon("containers")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Running Running
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col" <button
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" type="button"
onClick={() => handleSort("images")} onClick={() => handleSort("images")}
className="flex items-center gap-2 hover:text-secondary-700"
> >
<div className="flex items-center gap-2">
Images Images
{getSortIcon("images")} {getSortIcon("images")}
</div> </button>
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </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) => ( {filteredHosts.map((host) => (
<tr <tr
key={host.id} 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"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Server className="h-5 w-5 text-secondary-400 mr-3" /> <Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link <Link
to={`/docker/hosts/${host.id}`} 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} {host.friendly_name || host.hostname}
</Link> </Link>
</div> </div>
</td> </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} {host.dockerStats?.totalContainers || 0}
</td> </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} {host.dockerStats?.runningContainers || 0}
</td> </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} {host.dockerStats?.totalImages || 0}
</td> </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 <Link
to={`/docker/hosts/${host.id}`} 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="h-4 w-4" />
<ExternalLink className="ml-1 h-4 w-4" />
</Link> </Link>
</td> </td>
</tr> </tr>
@@ -892,82 +946,64 @@ const Docker = () => {
</p> </p>
</div> </div>
) : ( ) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700"> <table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-900"> <thead className="bg-secondary-50 dark:bg-secondary-700">
<tr> <tr>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Image Image
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Tag Tag
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Detection Method Detection Method
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Status Status
</th> </th>
<th <th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Affected Affected
</th> </th>
<th <th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
Actions Actions
</th> </th>
</tr> </tr>
</thead> </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) => ( {updatesData.updates.map((update) => (
<tr <tr
key={update.id} 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"> <td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center"> <div className="flex items-center gap-2">
<Package className="h-5 w-5 text-secondary-400 mr-3" /> <Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link <Link
to={`/docker/images/${update.image_id}`} 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} {update.docker_images?.repository}
</Link> </Link>
</div> </div>
</td> </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"> <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} {update.current_tag}
</span> </span>
</td> </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"> <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" /> <Package className="h-3 w-3 mr-1" />
Digest Comparison Digest
</span> </span>
</td> </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"> <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" /> <AlertTriangle className="h-3 w-3 mr-1" />
Update Available Available
</span> </span>
</td> </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} container
{update.affectedContainersCount !== 1 ? "s" : ""} {update.affectedContainersCount !== 1 ? "s" : ""}
{update.affectedHosts?.length > 0 && ( {update.affectedHosts?.length > 0 && (
@@ -978,13 +1014,13 @@ const Docker = () => {
</span> </span>
)} )}
</td> </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 <Link
to={`/docker/images/${update.image_id}`} 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="h-4 w-4" />
<ExternalLink className="ml-1 h-4 w-4" />
</Link> </Link>
</td> </td>
</tr> </tr>
@@ -996,6 +1032,141 @@ const Docker = () => {
)} )}
</div> </div>
</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> </div>
); );
}; };

View File

@@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
// Create axios instance with default config // Create axios instance with default config
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 30000, // Increased from 10000ms to 30000ms (30 seconds) to handle larger operations
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

72
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"frontend" "frontend"
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "^2.3.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"lefthook": "^1.13.4" "lefthook": "^1.13.4"
}, },
@@ -404,9 +404,9 @@
} }
}, },
"node_modules/@biomejs/biome": { "node_modules/@biomejs/biome": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.0.tgz",
"integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", "integrity": "sha512-shdUY5H3S3tJVUWoVWo5ua+GdPW5lRHf+b0IwZ4OC1o2zOKQECZ6l2KbU6t89FNhtd3Qx5eg5N7/UsQWGQbAFw==",
"dev": true, "dev": true,
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"bin": { "bin": {
@@ -420,20 +420,20 @@
"url": "https://opencollective.com/biome" "url": "https://opencollective.com/biome"
}, },
"optionalDependencies": { "optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-arm64": "2.3.0",
"@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-darwin-x64": "2.3.0",
"@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64": "2.3.0",
"@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.3.0",
"@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64": "2.3.0",
"@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.3.0",
"@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-arm64": "2.3.0",
"@biomejs/cli-win32-x64": "2.2.4" "@biomejs/cli-win32-x64": "2.3.0"
} }
}, },
"node_modules/@biomejs/cli-darwin-arm64": { "node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.0.tgz",
"integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==", "integrity": "sha512-3cJVT0Z5pbTkoBmbjmDZTDFYxIkRcrs9sYVJbIBHU8E6qQxgXAaBfSVjjCreG56rfDuQBr43GzwzmaHPcu4vlw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -448,9 +448,9 @@
} }
}, },
"node_modules/@biomejs/cli-darwin-x64": { "node_modules/@biomejs/cli-darwin-x64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.0.tgz",
"integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==", "integrity": "sha512-6LIkhglh3UGjuDqJXsK42qCA0XkD1Ke4K/raFOii7QQPbM8Pia7Qj2Hji4XuF2/R78hRmEx7uKJH3t/Y9UahtQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -465,9 +465,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64": { "node_modules/@biomejs/cli-linux-arm64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.0.tgz",
"integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==", "integrity": "sha512-uhAsbXySX7xsXahegDg5h3CDgfMcRsJvWLFPG0pjkylgBb9lErbK2C0UINW52zhwg0cPISB09lxHPxCau4e2xA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -482,9 +482,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-arm64-musl": { "node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.0.tgz",
"integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==", "integrity": "sha512-nDksoFdwZ2YrE7NiYDhtMhL2UgFn8Kb7Y0bYvnTAakHnqEdb4lKindtBc1f+xg2Snz0JQhJUYO7r9CDBosRU5w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -499,9 +499,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64": { "node_modules/@biomejs/cli-linux-x64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.0.tgz",
"integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==", "integrity": "sha512-uxa8reA2s1VgoH8MhbGlCmMOt3JuSE1vJBifkh1ulaPiuk0SPx8cCdpnm9NWnTe2x/LfWInWx4sZ7muaXTPGGw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -516,9 +516,9 @@
} }
}, },
"node_modules/@biomejs/cli-linux-x64-musl": { "node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.0.tgz",
"integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==", "integrity": "sha512-+i9UcJwl99uAhtRQDz9jUAh+Xkb097eekxs/D9j4deWDg5/yB/jPWzISe1nBHvlzTXsdUSj0VvB4Go2DSpKIMw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -533,9 +533,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-arm64": { "node_modules/@biomejs/cli-win32-arm64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.0.tgz",
"integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==", "integrity": "sha512-ynjmsJLIKrAjC3CCnKMMhzcnNy8dbQWjKfSU5YA0mIruTxBNMbkAJp+Pr2iV7/hFou+66ZSD/WV8hmLEmhUaXA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -550,9 +550,9 @@
} }
}, },
"node_modules/@biomejs/cli-win32-x64": { "node_modules/@biomejs/cli-win32-x64": {
"version": "2.2.4", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz", "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.0.tgz",
"integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==", "integrity": "sha512-zOCYmCRVkWXc9v8P7OLbLlGGMxQTKMvi+5IC4v7O8DkjLCOHRzRVK/Lno2pGZNo0lzKM60pcQOhH8HVkXMQdFg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],

View File

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

View File

@@ -1131,6 +1131,13 @@ DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
PM_DB_CONN_MAX_ATTEMPTS=30 PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2 PM_DB_CONN_WAIT_INTERVAL=2
# 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
# JWT Configuration # JWT Configuration
JWT_SECRET="$JWT_SECRET" JWT_SECRET="$JWT_SECRET"
JWT_EXPIRES_IN=1h JWT_EXPIRES_IN=1h