From a8eb3ec21c3c0d39da6bfc46146d2924e29628f6 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 6 Nov 2025 22:08:00 +0000 Subject: [PATCH 1/5] fix docker error handling fix websocket routes Add timezone variable in code changed the env.example to suit --- backend/env.example | 5 + backend/src/routes/dockerRoutes.js | 39 ++---- backend/src/services/agentWs.js | 81 +++++++++++- .../automation/dockerImageUpdateCheck.js | 6 +- backend/src/services/automation/index.js | 13 +- backend/src/utils/timezone.js | 124 ++++++++++++++++++ 6 files changed, 228 insertions(+), 40 deletions(-) create mode 100644 backend/src/utils/timezone.js diff --git a/backend/env.example b/backend/env.example index e5a0eab..d1b608f 100644 --- a/backend/env.example +++ b/backend/env.example @@ -54,3 +54,8 @@ ENABLE_LOGGING=true TFA_REMEMBER_ME_EXPIRES_IN=30d TFA_MAX_REMEMBER_SESSIONS=5 TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3 + +# Timezone Configuration +# Set the timezone for timestamps and logs (e.g., 'UTC', 'America/New_York', 'Europe/London') +# Defaults to UTC if not set. This ensures consistent timezone handling across the application. +TZ=UTC diff --git a/backend/src/routes/dockerRoutes.js b/backend/src/routes/dockerRoutes.js index 7a90214..b28cef2 100644 --- a/backend/src/routes/dockerRoutes.js +++ b/backend/src/routes/dockerRoutes.js @@ -2,6 +2,7 @@ const express = require("express"); const { authenticateToken } = require("../middleware/auth"); const { getPrismaClient } = require("../config/prisma"); const { v4: uuidv4 } = require("uuid"); +const { get_current_time, parse_date } = require("../utils/timezone"); const prisma = getPrismaClient(); const router = express.Router(); @@ -537,14 +538,7 @@ router.post("/collect", async (req, res) => { return res.status(401).json({ error: "Invalid API credentials" }); } - 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; - }; + const now = get_current_time(); // Process containers if (containers && Array.isArray(containers)) { @@ -572,7 +566,7 @@ router.post("/collect", async (req, res) => { tag: containerData.image_tag, image_id: containerData.image_id || "unknown", source: containerData.image_source || "docker-hub", - created_at: parseDate(containerData.created_at), + created_at: parse_date(containerData.created_at, now), last_checked: now, updated_at: now, }, @@ -597,7 +591,7 @@ router.post("/collect", async (req, res) => { state: containerData.state, ports: containerData.ports || null, started_at: containerData.started_at - ? parseDate(containerData.started_at) + ? parse_date(containerData.started_at, null) : null, updated_at: now, last_checked: now, @@ -613,9 +607,9 @@ router.post("/collect", async (req, res) => { status: containerData.status, state: containerData.state, ports: containerData.ports || null, - created_at: parseDate(containerData.created_at), + created_at: parse_date(containerData.created_at, now), started_at: containerData.started_at - ? parseDate(containerData.started_at) + ? parse_date(containerData.started_at, null) : null, updated_at: now, }, @@ -651,7 +645,7 @@ router.post("/collect", async (req, res) => { ? BigInt(imageData.size_bytes) : null, source: imageData.source || "docker-hub", - created_at: parseDate(imageData.created_at), + created_at: parse_date(imageData.created_at, now), updated_at: now, }, }); @@ -780,14 +774,7 @@ router.post("/../integrations/docker", async (req, res) => { `[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; - }; + const now = get_current_time(); let containersProcessed = 0; let imagesProcessed = 0; @@ -822,7 +809,7 @@ router.post("/../integrations/docker", async (req, res) => { tag: containerData.image_tag, image_id: containerData.image_id || "unknown", source: containerData.image_source || "docker-hub", - created_at: parseDate(containerData.created_at), + created_at: parse_date(containerData.created_at, now), last_checked: now, updated_at: now, }, @@ -847,7 +834,7 @@ router.post("/../integrations/docker", async (req, res) => { state: containerData.state || containerData.status, ports: containerData.ports || null, started_at: containerData.started_at - ? parseDate(containerData.started_at) + ? parse_date(containerData.started_at, null) : null, updated_at: now, last_checked: now, @@ -863,9 +850,9 @@ router.post("/../integrations/docker", async (req, res) => { status: containerData.status, state: containerData.state || containerData.status, ports: containerData.ports || null, - created_at: parseDate(containerData.created_at), + created_at: parse_date(containerData.created_at, now), started_at: containerData.started_at - ? parseDate(containerData.started_at) + ? parse_date(containerData.started_at, null) : null, updated_at: now, }, @@ -911,7 +898,7 @@ router.post("/../integrations/docker", async (req, res) => { ? BigInt(imageData.size_bytes) : null, source: imageSource, - created_at: parseDate(imageData.created_at), + created_at: parse_date(imageData.created_at, now), last_checked: now, updated_at: now, }, diff --git a/backend/src/services/agentWs.js b/backend/src/services/agentWs.js index b5e3b1f..3f61d82 100644 --- a/backend/src/services/agentWs.js +++ b/backend/src/services/agentWs.js @@ -3,6 +3,7 @@ const WebSocket = require("ws"); const url = require("node:url"); +const { get_current_time } = require("../utils/timezone"); // Connection registry by api_id const apiIdToSocket = new Map(); @@ -49,7 +50,29 @@ function init(server, prismaClient) { wss.handleUpgrade(request, socket, head, (ws) => { ws.on("message", (message) => { // Echo back for Bull Board WebSocket - ws.send(message); + try { + ws.send(message); + } catch (err) { + // Ignore send errors (connection may be closed) + } + }); + + ws.on("error", (err) => { + // Handle WebSocket errors gracefully for Bull Board + if ( + err.code === "WS_ERR_INVALID_CLOSE_CODE" || + err.code === "ECONNRESET" || + err.code === "EPIPE" + ) { + // These are expected errors, just log quietly + console.log("[bullboard-ws] connection error:", err.code); + } else { + console.error("[bullboard-ws] error:", err.message || err); + } + }); + + ws.on("close", () => { + // Connection closed, no action needed }); }); return; @@ -117,7 +140,57 @@ function init(server, prismaClient) { } }); - ws.on("close", () => { + ws.on("error", (err) => { + // Handle WebSocket errors gracefully without crashing + // Common errors: invalid close codes (1006), connection resets, etc. + if ( + err.code === "WS_ERR_INVALID_CLOSE_CODE" || + err.message?.includes("invalid status code 1006") || + err.message?.includes("Invalid WebSocket frame") + ) { + // 1006 is a special close code indicating abnormal closure + // It cannot be sent in a close frame, but can occur when connection is lost + console.log( + `[agent-ws] connection error for ${apiId} (abnormal closure):`, + err.message || err.code, + ); + } else if ( + err.code === "ECONNRESET" || + err.code === "EPIPE" || + err.message?.includes("read ECONNRESET") + ) { + // Connection reset errors are common and expected + console.log( + `[agent-ws] connection reset for ${apiId}`, + ); + } else { + // Log other errors for debugging + console.error( + `[agent-ws] error for ${apiId}:`, + err.message || err.code || err, + ); + } + + // Clean up connection on error + const existing = apiIdToSocket.get(apiId); + if (existing === ws) { + apiIdToSocket.delete(apiId); + connectionMetadata.delete(apiId); + // Notify subscribers of disconnection + notifyConnectionChange(apiId, false); + } + + // Try to close the connection gracefully if still open + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + try { + ws.close(1000); // Normal closure + } catch { + // Ignore errors when closing + } + } + }); + + ws.on("close", (code, reason) => { const existing = apiIdToSocket.get(apiId); if (existing === ws) { apiIdToSocket.delete(apiId); @@ -126,7 +199,7 @@ function init(server, prismaClient) { notifyConnectionChange(apiId, false); } console.log( - `[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`, + `[agent-ws] disconnected api_id=${apiId} code=${code} reason=${reason || "none"} total=${apiIdToSocket.size}`, ); }); @@ -314,7 +387,7 @@ async function handleDockerStatusEvent(apiId, message) { status: status, state: status, updated_at: new Date(timestamp || Date.now()), - last_checked: new Date(), + last_checked: get_current_time(), }, }); diff --git a/backend/src/services/automation/dockerImageUpdateCheck.js b/backend/src/services/automation/dockerImageUpdateCheck.js index 2706768..a70db9f 100644 --- a/backend/src/services/automation/dockerImageUpdateCheck.js +++ b/backend/src/services/automation/dockerImageUpdateCheck.js @@ -139,15 +139,13 @@ class DockerImageUpdateCheck { console.log("๐Ÿณ Starting Docker image update check..."); try { - // Get all Docker images that have a digest and repository + // Get all Docker images that have a digest + // Note: repository is required (non-nullable) in schema, so we don't need to check it const images = await prisma.docker_images.findMany({ where: { digest: { not: null, }, - repository: { - not: null, - }, }, include: { docker_image_updates: true, diff --git a/backend/src/services/automation/index.js b/backend/src/services/automation/index.js index 1731e33..6274bbf 100644 --- a/backend/src/services/automation/index.js +++ b/backend/src/services/automation/index.js @@ -3,6 +3,7 @@ const { redis, redisConnection } = require("./shared/redis"); const { prisma } = require("./shared/prisma"); const agentWs = require("../agentWs"); const { v4: uuidv4 } = require("uuid"); +const { get_current_time } = require("../../utils/timezone"); // Import automation classes const GitHubUpdateCheck = require("./githubUpdateCheck"); @@ -216,8 +217,8 @@ class QueueManager { api_id: api_id, status: "active", attempt_number: job.attemptsMade + 1, - created_at: new Date(), - updated_at: new Date(), + created_at: get_current_time(), + updated_at: get_current_time(), }, }); console.log(`๐Ÿ“ Logged job to job_history: ${job.id} (${type})`); @@ -257,8 +258,8 @@ class QueueManager { where: { job_id: job.id }, data: { status: "completed", - completed_at: new Date(), - updated_at: new Date(), + completed_at: get_current_time(), + updated_at: get_current_time(), }, }); console.log(`โœ… Marked job as completed in job_history: ${job.id}`); @@ -271,8 +272,8 @@ class QueueManager { data: { status: "failed", error_message: error.message, - completed_at: new Date(), - updated_at: new Date(), + completed_at: get_current_time(), + updated_at: get_current_time(), }, }); console.log(`โŒ Marked job as failed in job_history: ${job.id}`); diff --git a/backend/src/utils/timezone.js b/backend/src/utils/timezone.js new file mode 100644 index 0000000..8eb783a --- /dev/null +++ b/backend/src/utils/timezone.js @@ -0,0 +1,124 @@ +/** + * Timezone utility functions for consistent timestamp handling + * + * This module provides timezone-aware timestamp functions that use + * the TZ environment variable for consistent timezone handling across + * the application. If TZ is not set, defaults to UTC. + */ + +/** + * Get the configured timezone from environment variable + * Defaults to UTC if not set + * @returns {string} Timezone string (e.g., 'UTC', 'America/New_York', 'Europe/London') + */ +function get_timezone() { + return process.env.TZ || process.env.TIMEZONE || "UTC"; +} + +/** + * Get current date/time in the configured timezone + * Returns a Date object that represents the current time in the configured timezone + * @returns {Date} Current date/time + */ +function get_current_time() { + const tz = get_timezone(); + + // If UTC, use Date.now() which is always UTC + if (tz === "UTC" || tz === "Etc/UTC") { + return new Date(); + } + + // For other timezones, we need to create a date string with timezone info + // and parse it. This ensures the date represents the correct time in that timezone. + const now = new Date(); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + const parts = formatter.formatToParts(now); + const date_str = `${parts.find((p) => p.type === "year").value}-${parts.find((p) => p.type === "month").value}-${parts.find((p) => p.type === "day").value}T${parts.find((p) => p.type === "hour").value}:${parts.find((p) => p.type === "minute").value}:${parts.find((p) => p.type === "second").value}`; + + // Create date in UTC, then adjust to represent the same moment in the target timezone + // This is a bit tricky - we'll use a simpler approach: store as UTC but display in timezone + // For database storage, we always store UTC timestamps + return new Date(); +} + +/** + * Get current timestamp in milliseconds (UTC) + * This is always UTC for database storage consistency + * @returns {number} Current timestamp in milliseconds + */ +function get_current_timestamp() { + return Date.now(); +} + +/** + * Format a date to ISO string in the configured timezone + * @param {Date} date - Date to format (defaults to now) + * @returns {string} ISO formatted date string + */ +function format_date_iso(date = null) { + const d = date || get_current_time(); + return d.toISOString(); +} + +/** + * Parse a date string and return a Date object + * Handles various date formats and timezone conversions + * @param {string} date_string - Date string to parse + * @param {Date} fallback - Fallback date if parsing fails (defaults to now) + * @returns {Date} Parsed date or fallback + */ +function parse_date(date_string, fallback = null) { + if (!date_string) { + return fallback || get_current_time(); + } + + try { + const date = new Date(date_string); + if (Number.isNaN(date.getTime())) { + return fallback || get_current_time(); + } + return date; + } catch (error) { + return fallback || get_current_time(); + } +} + +/** + * Convert a date to the configured timezone for display + * @param {Date} date - Date to convert + * @returns {string} Formatted date string in configured timezone + */ +function format_date_for_display(date) { + const tz = get_timezone(); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + return formatter.format(date); +} + +module.exports = { + get_timezone, + get_current_time, + get_current_timestamp, + format_date_iso, + parse_date, + format_date_for_display, +}; + From 8e5eb54e021abf4ee385a67ed4ccd064f63cabfd Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Thu, 6 Nov 2025 22:16:35 +0000 Subject: [PATCH 2/5] fixed code quality --- backend/src/services/agentWs.js | 21 +++++++++++---------- backend/src/utils/timezone.js | 27 +++++---------------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/backend/src/services/agentWs.js b/backend/src/services/agentWs.js index 3f61d82..939c6a0 100644 --- a/backend/src/services/agentWs.js +++ b/backend/src/services/agentWs.js @@ -49,12 +49,12 @@ function init(server, prismaClient) { // Accept the WebSocket connection for Bull Board wss.handleUpgrade(request, socket, head, (ws) => { ws.on("message", (message) => { - // Echo back for Bull Board WebSocket - try { - ws.send(message); - } catch (err) { - // Ignore send errors (connection may be closed) - } + // Echo back for Bull Board WebSocket + try { + ws.send(message); + } catch (_err) { + // Ignore send errors (connection may be closed) + } }); ws.on("error", (err) => { @@ -160,9 +160,7 @@ function init(server, prismaClient) { err.message?.includes("read ECONNRESET") ) { // Connection reset errors are common and expected - console.log( - `[agent-ws] connection reset for ${apiId}`, - ); + console.log(`[agent-ws] connection reset for ${apiId}`); } else { // Log other errors for debugging console.error( @@ -181,7 +179,10 @@ function init(server, prismaClient) { } // Try to close the connection gracefully if still open - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + if ( + ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING + ) { try { ws.close(1000); // Normal closure } catch { diff --git a/backend/src/utils/timezone.js b/backend/src/utils/timezone.js index 8eb783a..3a5718d 100644 --- a/backend/src/utils/timezone.js +++ b/backend/src/utils/timezone.js @@ -1,6 +1,6 @@ /** * Timezone utility functions for consistent timestamp handling - * + * * This module provides timezone-aware timestamp functions that use * the TZ environment variable for consistent timezone handling across * the application. If TZ is not set, defaults to UTC. @@ -22,32 +22,16 @@ function get_timezone() { */ function get_current_time() { const tz = get_timezone(); - + // If UTC, use Date.now() which is always UTC if (tz === "UTC" || tz === "Etc/UTC") { return new Date(); } - + // For other timezones, we need to create a date string with timezone info // and parse it. This ensures the date represents the correct time in that timezone. - const now = new Date(); - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - - const parts = formatter.formatToParts(now); - const date_str = `${parts.find((p) => p.type === "year").value}-${parts.find((p) => p.type === "month").value}-${parts.find((p) => p.type === "day").value}T${parts.find((p) => p.type === "hour").value}:${parts.find((p) => p.type === "minute").value}:${parts.find((p) => p.type === "second").value}`; - - // Create date in UTC, then adjust to represent the same moment in the target timezone - // This is a bit tricky - we'll use a simpler approach: store as UTC but display in timezone // For database storage, we always store UTC timestamps + // The timezone is primarily used for display purposes return new Date(); } @@ -88,7 +72,7 @@ function parse_date(date_string, fallback = null) { return fallback || get_current_time(); } return date; - } catch (error) { + } catch (_error) { return fallback || get_current_time(); } } @@ -121,4 +105,3 @@ module.exports = { parse_date, format_date_for_display, }; - From 63831caba348b2a2d45afeb84f6c130d8a8c3569 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Fri, 7 Nov 2025 08:20:42 +0000 Subject: [PATCH 3/5] fixed tfa route for handling insertion of tfa number Better handling of existing systems already enrolled, done via checking if the config.yml file exists and ping through its credentials as opposed to checking for machine_ID UI justification improvements on repositories pages --- agents/patchmon_install.sh | 58 +++++++++++++++-------------- agents/proxmox_auto_enroll.sh | 44 ++++++++++++++++++++-- backend/src/routes/tfaRoutes.js | 15 ++++++-- frontend/src/pages/Repositories.jsx | 22 +++++++---- package-lock.json | 8 ++-- 5 files changed, 101 insertions(+), 46 deletions(-) diff --git a/agents/patchmon_install.sh b/agents/patchmon_install.sh index 315f117..c41ba0b 100644 --- a/agents/patchmon_install.sh +++ b/agents/patchmon_install.sh @@ -311,6 +311,37 @@ else mkdir -p /etc/patchmon fi +# Check if agent is already configured and working (before we overwrite anything) +info "๐Ÿ” Checking if agent is already configured..." + +if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then + if [[ -f /usr/local/bin/patchmon-agent ]]; then + info "๐Ÿ“‹ Found existing agent configuration" + info "๐Ÿงช Testing existing configuration with ping..." + + if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then + success "โœ… Agent is already configured and ping successful" + info "๐Ÿ“‹ Existing configuration is working - skipping installation" + info "" + info "If you want to reinstall, remove the configuration files first:" + info " sudo rm -f /etc/patchmon/config.yml /etc/patchmon/credentials.yml" + echo "" + exit 0 + else + warning "โš ๏ธ Agent configuration exists but ping failed" + warning "โš ๏ธ Will move existing configuration and reinstall" + echo "" + fi + else + warning "โš ๏ธ Configuration files exist but agent binary is missing" + warning "โš ๏ธ Will move existing configuration and reinstall" + echo "" + fi +else + success "โœ… Agent not yet configured - proceeding with installation" + echo "" +fi + # Step 2: Create configuration files info "๐Ÿ” Creating configuration files..." @@ -426,33 +457,6 @@ if [[ -f "/etc/patchmon/logs/patchmon-agent.log" ]]; then fi # Step 4: Test the configuration -# Check if this machine is already enrolled -info "๐Ÿ” Checking if machine is already enrolled..." -existing_check=$(curl $CURL_FLAGS -s -X POST \ - -H "X-API-ID: $API_ID" \ - -H "X-API-KEY: $API_KEY" \ - -H "Content-Type: application/json" \ - -d "{\"machine_id\": \"$MACHINE_ID\"}" \ - "$PATCHMON_URL/api/v1/hosts/check-machine-id" \ - -w "\n%{http_code}" 2>&1) - -http_code=$(echo "$existing_check" | tail -n 1) -response_body=$(echo "$existing_check" | sed '$d') - -if [[ "$http_code" == "200" ]]; then - already_enrolled=$(echo "$response_body" | jq -r '.exists' 2>/dev/null || echo "false") - if [[ "$already_enrolled" == "true" ]]; then - warning "โš ๏ธ This machine is already enrolled in PatchMon" - info "Machine ID: $MACHINE_ID" - info "Existing host: $(echo "$response_body" | jq -r '.host.friendly_name' 2>/dev/null)" - info "" - info "The agent will be reinstalled/updated with existing credentials." - echo "" - else - success "โœ… Machine not yet enrolled - proceeding with installation" - fi -fi - info "๐Ÿงช Testing API credentials and connectivity..." if /usr/local/bin/patchmon-agent ping; then success "โœ… TEST: API credentials are valid and server is reachable" diff --git a/agents/proxmox_auto_enroll.sh b/agents/proxmox_auto_enroll.sh index 01c4158..04516f4 100755 --- a/agents/proxmox_auto_enroll.sh +++ b/agents/proxmox_auto_enroll.sh @@ -230,6 +230,40 @@ while IFS= read -r line; do info " โœ“ Host enrolled successfully: $api_id" + # Check if agent is already installed and working + info " Checking if agent is already configured..." + config_check=$(timeout 10 pct exec "$vmid" -- bash -c " + if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then + if [[ -f /usr/local/bin/patchmon-agent ]]; then + # Try to ping using existing configuration + if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then + echo 'ping_success' + else + echo 'ping_failed' + fi + else + echo 'binary_missing' + fi + else + echo 'not_configured' + fi + " 2>/dev/null /dev/null 2>&1 && echo 'installed' || echo 'missing'" 2>/dev/null { try { @@ -71,7 +76,11 @@ router.post( return res.status(400).json({ errors: errors.array() }); } - const { token } = req.body; + // Ensure token is a string (convert if needed) + let { token } = req.body; + if (typeof token !== "string") { + token = String(token); + } const userId = req.user.id; // Get user's TFA secret diff --git a/frontend/src/pages/Repositories.jsx b/frontend/src/pages/Repositories.jsx index 5bcf845..77d0300 100644 --- a/frontend/src/pages/Repositories.jsx +++ b/frontend/src/pages/Repositories.jsx @@ -237,8 +237,14 @@ const Repositories = () => { // Handle special cases if (sortField === "security") { - aValue = a.isSecure ? "Secure" : "Insecure"; - bValue = b.isSecure ? "Secure" : "Insecure"; + // Use the same logic as filtering to determine isSecure + const aIsSecure = + a.isSecure !== undefined ? a.isSecure : a.url.startsWith("https://"); + const bIsSecure = + b.isSecure !== undefined ? b.isSecure : b.url.startsWith("https://"); + // Sort by boolean: true (Secure) comes before false (Insecure) when ascending + aValue = aIsSecure ? 1 : 0; + bValue = bIsSecure ? 1 : 0; } else if (sortField === "status") { aValue = a.is_active ? "Active" : "Inactive"; bValue = b.is_active ? "Active" : "Inactive"; @@ -535,12 +541,12 @@ const Repositories = () => { {visibleColumns.map((column) => ( +
+
+ {/* Refresh Button */} + - {/* Period Selector */} - + {/* Period Selector */} + - {/* Host Selector */} - { + setPackageTrendsHost(e.target.value); + // Clear job ID message when host selection changes + setSystemStatsJobId(null); + }} + className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + > + + {packageTrendsData?.hosts?.length > 0 ? ( + packageTrendsData.hosts.map((host) => ( + + )) + ) : ( + - )) - ) : ( - - )} - + )} + +
+ {/* Job ID Message */} + {systemStatsJobId && packageTrendsHost === "all" && ( +

+ Ran collection job #{systemStatsJobId} +

+ )}
@@ -1167,13 +1221,40 @@ const Dashboard = () => { title: (context) => { const label = context[0].label; + // Handle "Now" label + if (label === "Now") { + return "Now"; + } + // Handle empty or invalid labels if (!label || typeof label !== "string") { return "Unknown Date"; } + // Check if it's a full ISO timestamp (for "Last 24 hours") + // Format: "2025-01-15T14:30:00.000Z" or "2025-01-15T14:30:00.000" + if (label.includes("T") && label.includes(":")) { + try { + const date = new Date(label); + // Check if date is valid + if (Number.isNaN(date.getTime())) { + return label; // Return original label if date is invalid + } + // Format full ISO timestamp with date and time + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + } catch (_error) { + return label; // Return original label if parsing fails + } + } + // Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM") - if (label.includes("T")) { + if (label.includes("T") && !label.includes(":")) { try { const date = new Date(`${label}:00:00`); // Check if date is valid @@ -1233,13 +1314,41 @@ const Dashboard = () => { callback: function (value, _index, _ticks) { const label = this.getLabelForValue(value); + // Handle "Now" label + if (label === "Now") { + return "Now"; + } + // Handle empty or invalid labels if (!label || typeof label !== "string") { return "Unknown"; } + // Check if it's a full ISO timestamp (for "Last 24 hours") + // Format: "2025-01-15T14:30:00.000Z" or "2025-01-15T14:30:00.000" + if (label.includes("T") && label.includes(":")) { + try { + const date = new Date(label); + // Check if date is valid + if (Number.isNaN(date.getTime())) { + return label; // Return original label if date is invalid + } + // Extract hour from full ISO timestamp + const hourNum = date.getHours(); + return hourNum === 0 + ? "12 AM" + : hourNum < 12 + ? `${hourNum} AM` + : hourNum === 12 + ? "12 PM" + : `${hourNum - 12} PM`; + } catch (_error) { + return label; // Return original label if parsing fails + } + } + // Format hourly labels (e.g., "2025-10-07T14" -> "2 PM") - if (label.includes("T")) { + if (label.includes("T") && !label.includes(":")) { try { const hour = label.split("T")[1]; const hourNum = parseInt(hour, 10); diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 7b943b2..a625269 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -281,6 +281,67 @@ const HostDetail = () => { }, }); + // Fetch integration status + const { + data: integrationsData, + isLoading: isLoadingIntegrations, + refetch: refetchIntegrations, + } = useQuery({ + queryKey: ["host-integrations", hostId], + queryFn: () => + adminHostsAPI.getIntegrations(hostId).then((res) => res.data), + staleTime: 30 * 1000, // 30 seconds + refetchOnWindowFocus: false, + enabled: !!hostId && activeTab === "integrations", + }); + + // Refetch integrations when WebSocket status changes (e.g., after agent restart) + useEffect(() => { + if ( + wsStatus?.connected && + activeTab === "integrations" && + integrationsData?.data?.connected === false + ) { + // Agent just reconnected, refetch integrations to get updated connection status + refetchIntegrations(); + } + }, [ + wsStatus?.connected, + activeTab, + integrationsData?.data?.connected, + refetchIntegrations, + ]); + + // Toggle integration mutation + const toggleIntegrationMutation = useMutation({ + mutationFn: ({ integrationName, enabled }) => + adminHostsAPI + .toggleIntegration(hostId, integrationName, enabled) + .then((res) => res.data), + onSuccess: (data) => { + // Optimistically update the cache with the new state + queryClient.setQueryData(["host-integrations", hostId], (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + data: { + ...oldData.data, + integrations: { + ...oldData.data.integrations, + [data.data.integration]: data.data.enabled, + }, + }, + }; + }); + // Also invalidate to ensure we get fresh data + queryClient.invalidateQueries(["host-integrations", hostId]); + }, + onError: () => { + // On error, refetch to get the actual state + refetchIntegrations(); + }, + }); + const handleDeleteHost = async () => { if ( window.confirm( @@ -666,6 +727,17 @@ const HostDetail = () => { > Notes +
@@ -1446,6 +1518,101 @@ const HostDetail = () => { {/* Agent Queue */} {activeTab === "queue" && } + + {/* Integrations */} + {activeTab === "integrations" && ( +
+ {isLoadingIntegrations ? ( +
+ +
+ ) : ( +
+ {/* Docker Integration */} +
+
+
+
+ +

+ Docker +

+ {integrationsData?.data?.integrations?.docker ? ( + + Enabled + + ) : ( + + Disabled + + )} +
+

+ Monitor Docker containers, images, volumes, and + networks. Collects real-time container status + events. +

+
+
+ +
+
+ {!wsStatus?.connected && ( +

+ Agent must be connected via WebSocket to toggle + integrations +

+ )} + {toggleIntegrationMutation.isPending && ( +

+ Updating integration... +

+ )} +
+ + {/* Future integrations can be added here with the same pattern */} +
+ )} +
+ )}
@@ -1639,7 +1806,8 @@ const CredentialsModal = ({ host, isOpen, onClose }) => { > - + +

Select the architecture of the target host diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index f2bf4aa..59642de 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -99,6 +99,8 @@ export const dashboardAPI = { }, getRecentUsers: () => api.get("/dashboard/recent-users"), getRecentCollection: () => api.get("/dashboard/recent-collection"), + triggerSystemStatistics: () => + api.post("/automation/trigger/system-statistics"), }; // Admin Hosts API (for management interface) @@ -129,6 +131,11 @@ export const adminHostsAPI = { api.patch(`/hosts/${hostId}/notes`, { notes: notes, }), + getIntegrations: (hostId) => api.get(`/hosts/${hostId}/integrations`), + toggleIntegration: (hostId, integrationName, enabled) => + api.post(`/hosts/${hostId}/integrations/${integrationName}/toggle`, { + enabled, + }), }; // Host Groups API diff --git a/setup.sh b/setup.sh index bb2501e..920a9c1 100755 --- a/setup.sh +++ b/setup.sh @@ -443,7 +443,7 @@ generate_redis_password() { # Find next available Redis database find_next_redis_db() { - print_info "Finding next available Redis database..." + print_info "Finding next available Redis database..." >&2 # Start from database 0 and keep checking until we find an empty one local db_num=0 @@ -463,11 +463,11 @@ find_next_redis_db() { # Try to load admin credentials if ACL file exists if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then # Redis is configured with ACL - try to extract admin password - print_info "Redis requires authentication, attempting with admin credentials..." + print_info "Redis requires authentication, attempting with admin credentials..." >&2 # For multi-instance setups, we can't know the admin password yet # So we'll just use database 0 as default - print_info "Using database 0 (Redis ACL already configured)" + print_info "Using database 0 (Redis ACL already configured)" >&2 echo "0" return 0 fi @@ -484,7 +484,7 @@ find_next_redis_db() { # Check for authentication errors if echo "$redis_output" | grep -q "NOAUTH\|WRONGPASS"; then # If we hit auth errors and haven't configured yet, use database 0 - print_info "Redis requires authentication, defaulting to database 0" + print_info "Redis requires authentication, defaulting to database 0" >&2 echo "0" return 0 fi @@ -492,10 +492,10 @@ find_next_redis_db() { # Check for other errors if echo "$redis_output" | grep -q "ERR"; then if echo "$redis_output" | grep -q "invalid DB index"; then - print_warning "Reached maximum database limit at database $db_num" + print_warning "Reached maximum database limit at database $db_num" >&2 break else - print_error "Error checking database $db_num: $redis_output" + print_error "Error checking database $db_num: $redis_output" >&2 return 1 fi fi @@ -504,17 +504,17 @@ find_next_redis_db() { # If database is empty, use it if [ "$key_count" = "0" ] || [ "$key_count" = "(integer) 0" ]; then - print_status "Found available Redis database: $db_num (empty)" + print_status "Found available Redis database: $db_num (empty)" >&2 echo "$db_num" return 0 fi - print_info "Database $db_num has $key_count keys, checking next..." + print_info "Database $db_num has $key_count keys, checking next..." >&2 db_num=$((db_num + 1)) done - print_warning "No available Redis databases found (checked 0-$max_attempts)" - print_info "Using database 0 (may have existing data)" + print_warning "No available Redis databases found (checked 0-$max_attempts)" >&2 + print_info "Using database 0 (may have existing data)" >&2 echo "0" return 0 }