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, +}; +