mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 22:13:21 +00:00
Building Docker compatibilty within the Agent
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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,
|
||||||
|
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;
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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
|
// 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 =
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
// Trigger refresh of all queries
|
onClick={() => {
|
||||||
window.location.reload();
|
// Trigger refresh based on active tab
|
||||||
}}
|
if (activeTab === "containers") refetchContainers();
|
||||||
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"
|
else if (activeTab === "images") refetchImages();
|
||||||
>
|
else window.location.reload();
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
}}
|
||||||
Refresh
|
className="btn-outline flex items-center justify-center p-2"
|
||||||
</button>
|
title="Refresh data"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</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">
|
||||||
<Link
|
<div className="flex items-center justify-center gap-3">
|
||||||
to={`/docker/containers/${container.id}`}
|
<Link
|
||||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
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"
|
||||||
View
|
title="View details"
|
||||||
<ExternalLink className="ml-1 h-4 w-4" />
|
>
|
||||||
</Link>
|
<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>
|
</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">
|
||||||
<Link
|
<div className="flex items-center justify-center gap-3">
|
||||||
to={`/docker/images/${image.id}`}
|
<Link
|
||||||
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
|
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
|
title="View details"
|
||||||
<ExternalLink className="ml-1 h-4 w-4" />
|
>
|
||||||
</Link>
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
72
package-lock.json
generated
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
7
setup.sh
7
setup.sh
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user