From 5b77a1328d764d3c7028407d06938c2a0aa0f50e Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Wed, 15 Oct 2025 22:15:18 +0100 Subject: [PATCH] Removed js file for the update checker for github Added real-time feature for agent status made some ui improvements on the host details page --- backend/src/routes/automationRoutes.js | 19 ++ backend/src/routes/wsRoutes.js | 112 +++++++++ backend/src/server.js | 34 +-- backend/src/services/agentWs.js | 67 +++++- backend/src/services/updateScheduler.js | 295 ------------------------ frontend/src/pages/Automation.jsx | 8 + frontend/src/pages/HostDetail.jsx | 197 +++++++++++----- frontend/src/pages/Hosts.jsx | 134 +++++++++-- frontend/src/utils/api.js | 2 + 9 files changed, 467 insertions(+), 401 deletions(-) create mode 100644 backend/src/routes/wsRoutes.js delete mode 100644 backend/src/services/updateScheduler.js diff --git a/backend/src/routes/automationRoutes.js b/backend/src/routes/automationRoutes.js index c2d6265..a32c9a6 100644 --- a/backend/src/routes/automationRoutes.js +++ b/backend/src/routes/automationRoutes.js @@ -241,12 +241,15 @@ router.get("/health", authenticateToken, async (_req, res) => { router.get("/overview", authenticateToken, async (_req, res) => { try { const stats = await queueManager.getAllQueueStats(); + const { getSettings } = require("../services/settingsService"); + const settings = await getSettings(); // Get recent jobs for each queue to show last run times const recentJobs = await Promise.all([ queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1), queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), + queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1), ]); // Calculate overview metrics @@ -327,6 +330,22 @@ router.get("/overview", authenticateToken, async (_req, res) => { : "Never run", stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP], }, + { + name: "Collect Host Statistics", + queue: QUEUE_NAMES.AGENT_COMMANDS, + description: "Collects package statistics from all connected agents", + schedule: `Every ${settings.update_interval} minutes (Agent-driven)`, + lastRun: recentJobs[3][0]?.finishedOn + ? new Date(recentJobs[3][0].finishedOn).toLocaleString() + : "Never", + lastRunTimestamp: recentJobs[3][0]?.finishedOn || 0, + status: recentJobs[3][0]?.failedReason + ? "Failed" + : recentJobs[3][0] + ? "Success" + : "Never run", + stats: stats[QUEUE_NAMES.AGENT_COMMANDS], + }, ].sort((a, b) => { // Sort by last run timestamp (most recent first) // If both have never run (timestamp 0), maintain original order diff --git a/backend/src/routes/wsRoutes.js b/backend/src/routes/wsRoutes.js new file mode 100644 index 0000000..fccec1d --- /dev/null +++ b/backend/src/routes/wsRoutes.js @@ -0,0 +1,112 @@ +const express = require("express"); +const { authenticateToken } = require("../middleware/auth"); +const { + getConnectionInfo, + subscribeToConnectionChanges, +} = require("../services/agentWs"); + +const router = express.Router(); + +// Get WebSocket connection status by api_id (no database access - pure memory lookup) +router.get("/status/:apiId", authenticateToken, async (req, res) => { + try { + const { apiId } = req.params; + + // Direct in-memory check - no database query needed + const connectionInfo = getConnectionInfo(apiId); + + // Minimal response for maximum speed + res.json({ + success: true, + data: connectionInfo, + }); + } catch (error) { + console.error("Error fetching WebSocket status:", error); + res.status(500).json({ + success: false, + error: "Failed to fetch WebSocket status", + }); + } +}); + +// Server-Sent Events endpoint for real-time status updates (no polling needed!) +router.get("/status/:apiId/stream", async (req, res) => { + try { + const { apiId } = req.params; + + // Manual authentication for SSE (EventSource doesn't support custom headers) + const token = + req.query.token || req.headers.authorization?.replace("Bearer ", ""); + if (!token) { + return res.status(401).json({ error: "Authentication required" }); + } + + // Verify token manually + const jwt = require("jsonwebtoken"); + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; + } catch (_err) { + console.error("[SSE] Invalid token for api_id:", apiId); + return res.status(401).json({ error: "Invalid or expired token" }); + } + + console.log("[SSE] Client connected for api_id:", apiId); + + // Set headers for SSE + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering + + // Send initial status immediately + const initialInfo = getConnectionInfo(apiId); + res.write(`data: ${JSON.stringify(initialInfo)}\n\n`); + res.flushHeaders(); // Ensure headers are sent immediately + + // Subscribe to connection changes for this specific api_id + const unsubscribe = subscribeToConnectionChanges(apiId, (_connected) => { + try { + // Push update to client instantly when status changes + const connectionInfo = getConnectionInfo(apiId); + console.log( + `[SSE] Pushing status change for ${apiId}: connected=${connectionInfo.connected} secure=${connectionInfo.secure}`, + ); + res.write(`data: ${JSON.stringify(connectionInfo)}\n\n`); + } catch (err) { + console.error("[SSE] Error writing to stream:", err); + } + }); + + // Heartbeat to keep connection alive (every 30 seconds) + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n"); + } catch (err) { + console.error("[SSE] Error writing heartbeat:", err); + clearInterval(heartbeat); + } + }, 30000); + + // Cleanup on client disconnect + req.on("close", () => { + console.log("[SSE] Client disconnected for api_id:", apiId); + clearInterval(heartbeat); + unsubscribe(); + }); + + // Handle errors + req.on("error", (err) => { + console.error("[SSE] Request error:", err); + clearInterval(heartbeat); + unsubscribe(); + }); + } catch (error) { + console.error("[SSE] Unexpected error:", error); + if (!res.headersSent) { + res.status(500).json({ error: "Internal server error" }); + } + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index c69a456..121b312 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -66,9 +66,8 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); const gethomepageRoutes = require("./routes/gethomepageRoutes"); const automationRoutes = require("./routes/automationRoutes"); const dockerRoutes = require("./routes/dockerRoutes"); -const updateScheduler = require("./services/updateScheduler"); +const wsRoutes = require("./routes/wsRoutes"); const { initSettings } = require("./services/settingsService"); -const { cleanup_expired_sessions } = require("./utils/session_manager"); const { queueManager } = require("./services/automation"); const { authenticateToken, requireAdmin } = require("./middleware/auth"); const { createBullBoard } = require("@bull-board/api"); @@ -442,6 +441,7 @@ app.use( app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); app.use(`/api/${apiVersion}/automation`, automationRoutes); app.use(`/api/${apiVersion}/docker`, dockerRoutes); +app.use(`/api/${apiVersion}/ws`, wsRoutes); // Bull Board - will be populated after queue manager initializes let bullBoardRouter = null; @@ -580,10 +580,6 @@ process.on("SIGINT", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGINT received, shutting down gracefully"); } - if (app.locals.session_cleanup_interval) { - clearInterval(app.locals.session_cleanup_interval); - } - updateScheduler.stop(); await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); @@ -593,10 +589,6 @@ process.on("SIGTERM", async () => { if (process.env.ENABLE_LOGGING === "true") { logger.info("SIGTERM received, shutting down gracefully"); } - if (app.locals.session_cleanup_interval) { - clearInterval(app.locals.session_cleanup_interval); - } - updateScheduler.stop(); await queueManager.shutdown(); await disconnectPrisma(prisma); process.exit(0); @@ -891,21 +883,6 @@ async function startServer() { bullBoardRouter = serverAdapter.getRouter(); console.log("✅ Bull Board mounted at /admin/queues (secured)"); - // Initial session cleanup - await cleanup_expired_sessions(); - - // Schedule session cleanup every hour - const session_cleanup_interval = setInterval( - async () => { - try { - await cleanup_expired_sessions(); - } catch (error) { - console.error("Session cleanup error:", error); - } - }, - 60 * 60 * 1000, - ); // Every hour - // Initialize WS layer with the underlying HTTP server initAgentWs(server, prisma); @@ -913,15 +890,8 @@ async function startServer() { if (process.env.ENABLE_LOGGING === "true") { logger.info(`Server running on port ${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV}`); - logger.info("✅ Session cleanup scheduled (every hour)"); } - - // Start update scheduler - updateScheduler.start(); }); - - // Store interval for cleanup on shutdown - app.locals.session_cleanup_interval = session_cleanup_interval; } catch (error) { console.error("❌ Failed to start server:", error.message); process.exit(1); diff --git a/backend/src/services/agentWs.js b/backend/src/services/agentWs.js index 1aafa11..b66adba 100644 --- a/backend/src/services/agentWs.js +++ b/backend/src/services/agentWs.js @@ -7,6 +7,14 @@ const url = require("node:url"); // Connection registry by api_id const apiIdToSocket = new Map(); +// Connection metadata (secure/insecure) +// Map +const connectionMetadata = new Map(); + +// Subscribers for connection status changes (for SSE) +// Map> +const connectionChangeSubscribers = new Map(); + let wss; let prisma; @@ -46,11 +54,21 @@ function init(server, prismaClient) { wss.handleUpgrade(request, socket, head, (ws) => { ws.apiId = apiId; + + // Detect if connection is secure (wss://) or not (ws://) + const isSecure = + socket.encrypted || request.headers["x-forwarded-proto"] === "https"; + apiIdToSocket.set(apiId, ws); + connectionMetadata.set(apiId, { ws, secure: isSecure }); + console.log( - `[agent-ws] connected api_id=${apiId} total=${apiIdToSocket.size}`, + `[agent-ws] connected api_id=${apiId} protocol=${isSecure ? "wss" : "ws"} total=${apiIdToSocket.size}`, ); + // Notify subscribers of connection + notifyConnectionChange(apiId, true); + ws.on("message", () => { // Currently we don't need to handle agent->server messages }); @@ -59,6 +77,9 @@ function init(server, prismaClient) { const existing = apiIdToSocket.get(apiId); if (existing === ws) { apiIdToSocket.delete(apiId); + connectionMetadata.delete(apiId); + // Notify subscribers of disconnection + notifyConnectionChange(apiId, false); } console.log( `[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`, @@ -111,6 +132,39 @@ function pushSettingsUpdate(apiId, newInterval) { ); } +// Notify all subscribers when connection status changes +function notifyConnectionChange(apiId, connected) { + const subscribers = connectionChangeSubscribers.get(apiId); + if (subscribers) { + for (const callback of subscribers) { + try { + callback(connected); + } catch (err) { + console.error(`[agent-ws] error notifying subscriber:`, err); + } + } + } +} + +// Subscribe to connection status changes for a specific api_id +function subscribeToConnectionChanges(apiId, callback) { + if (!connectionChangeSubscribers.has(apiId)) { + connectionChangeSubscribers.set(apiId, new Set()); + } + connectionChangeSubscribers.get(apiId).add(callback); + + // Return unsubscribe function + return () => { + const subscribers = connectionChangeSubscribers.get(apiId); + if (subscribers) { + subscribers.delete(callback); + if (subscribers.size === 0) { + connectionChangeSubscribers.delete(apiId); + } + } + }; +} + module.exports = { init, broadcastSettingsUpdate, @@ -122,4 +176,15 @@ module.exports = { const ws = apiIdToSocket.get(apiId); return !!ws && ws.readyState === WebSocket.OPEN; }, + // Get connection info including protocol (ws/wss) + getConnectionInfo: (apiId) => { + const metadata = connectionMetadata.get(apiId); + if (!metadata) { + return { connected: false, secure: false }; + } + const connected = metadata.ws.readyState === WebSocket.OPEN; + return { connected, secure: metadata.secure }; + }, + // Subscribe to connection status changes (for SSE) + subscribeToConnectionChanges, }; diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js deleted file mode 100644 index 0e717ea..0000000 --- a/backend/src/services/updateScheduler.js +++ /dev/null @@ -1,295 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); -const { exec } = require("node:child_process"); -const { promisify } = require("node:util"); - -const prisma = new PrismaClient(); -const execAsync = promisify(exec); - -class UpdateScheduler { - constructor() { - this.isRunning = false; - this.intervalId = null; - this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - } - - // Start the scheduler - start() { - if (this.isRunning) { - console.log("Update scheduler is already running"); - return; - } - - console.log("🔄 Starting update scheduler..."); - this.isRunning = true; - - // Run initial check - this.checkForUpdates(); - - // Schedule regular checks - this.intervalId = setInterval(() => { - this.checkForUpdates(); - }, this.checkInterval); - - console.log( - `✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`, - ); - } - - // Stop the scheduler - stop() { - if (!this.isRunning) { - console.log("Update scheduler is not running"); - return; - } - - console.log("🛑 Stopping update scheduler..."); - this.isRunning = false; - - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - - console.log("✅ Update scheduler stopped"); - } - - // Check for updates - async checkForUpdates() { - try { - console.log("🔍 Checking for updates..."); - - // Get settings - const settings = await prisma.settings.findFirst(); - const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon"; - const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO; - let owner, repo; - - if (repoUrl.includes("git@github.com:")) { - const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/); - if (match) { - [, owner, repo] = match; - } - } else if (repoUrl.includes("github.com/")) { - const match = repoUrl.match( - /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/, - ); - if (match) { - [, owner, repo] = match; - } - } - - if (!owner || !repo) { - console.log( - "⚠️ Could not parse GitHub repository URL, skipping update check", - ); - return; - } - - let latestVersion; - const isPrivate = settings.repositoryType === "private"; - - if (isPrivate) { - // Use SSH for private repositories - latestVersion = await this.checkPrivateRepo(settings, owner, repo); - } else { - // Use GitHub API for public repositories - latestVersion = await this.checkPublicRepo(owner, repo); - } - - if (!latestVersion) { - console.log( - "⚠️ Could not determine latest version, skipping update check", - ); - return; - } - - // Read version from package.json dynamically - let currentVersion = "1.2.9"; // fallback - try { - const packageJson = require("../../package.json"); - if (packageJson?.version) { - currentVersion = packageJson.version; - } - } catch (packageError) { - console.warn( - "Could not read version from package.json, using fallback:", - packageError.message, - ); - } - const isUpdateAvailable = - this.compareVersions(latestVersion, currentVersion) > 0; - - // Update settings with check results - await prisma.settings.update({ - where: { id: settings.id }, - data: { - last_update_check: new Date(), - update_available: isUpdateAvailable, - latest_version: latestVersion, - }, - }); - - console.log( - `✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`, - ); - } catch (error) { - console.error("❌ Error checking for updates:", error.message); - - // Update last check time even on error - try { - const settings = await prisma.settings.findFirst(); - if (settings) { - await prisma.settings.update({ - where: { id: settings.id }, - data: { - last_update_check: new Date(), - update_available: false, - }, - }); - } - } catch (updateError) { - console.error( - "❌ Error updating last check time:", - updateError.message, - ); - } - } - } - - // Check private repository using SSH - async checkPrivateRepo(settings, owner, repo) { - try { - let sshKeyPath = settings.sshKeyPath; - - // Try to find SSH key if not configured - if (!sshKeyPath) { - const possibleKeyPaths = [ - "/root/.ssh/id_ed25519", - "/root/.ssh/id_rsa", - "/home/patchmon/.ssh/id_ed25519", - "/home/patchmon/.ssh/id_rsa", - "/var/www/.ssh/id_ed25519", - "/var/www/.ssh/id_rsa", - ]; - - for (const path of possibleKeyPaths) { - try { - require("node:fs").accessSync(path); - sshKeyPath = path; - break; - } catch { - // Key not found at this path, try next - } - } - } - - if (!sshKeyPath) { - throw new Error("No SSH deploy key found"); - } - - const sshRepoUrl = `git@github.com:${owner}/${repo}.git`; - const env = { - ...process.env, - GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`, - }; - - const { stdout: sshLatestTag } = await execAsync( - `git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`, - { - timeout: 10000, - env: env, - }, - ); - - return sshLatestTag.trim().replace("v", ""); - } catch (error) { - console.error("SSH Git error:", error.message); - throw error; - } - } - - // Check public repository using GitHub API - async checkPublicRepo(owner, repo) { - try { - const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; - - // Get current version for User-Agent - let currentVersion = "1.2.9"; // fallback - try { - const packageJson = require("../../package.json"); - if (packageJson?.version) { - currentVersion = packageJson.version; - } - } catch (packageError) { - console.warn( - "Could not read version from package.json for User-Agent, using fallback:", - packageError.message, - ); - } - - const response = await fetch(httpsRepoUrl, { - method: "GET", - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": `PatchMon-Server/${currentVersion}`, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - if ( - errorText.includes("rate limit") || - errorText.includes("API rate limit") - ) { - console.log( - "⚠️ GitHub API rate limit exceeded, skipping update check", - ); - return null; // Return null instead of throwing error - } - throw new Error( - `GitHub API error: ${response.status} ${response.statusText}`, - ); - } - - const releaseData = await response.json(); - return releaseData.tag_name.replace("v", ""); - } catch (error) { - console.error("GitHub API error:", error.message); - throw error; - } - } - - // Compare version strings (semantic versioning) - compareVersions(version1, version2) { - const v1parts = version1.split(".").map(Number); - const v2parts = version2.split(".").map(Number); - - const maxLength = Math.max(v1parts.length, v2parts.length); - - for (let i = 0; i < maxLength; i++) { - const v1part = v1parts[i] || 0; - const v2part = v2parts[i] || 0; - - if (v1part > v2part) return 1; - if (v1part < v2part) return -1; - } - - return 0; - } - - // Get scheduler status - getStatus() { - return { - isRunning: this.isRunning, - checkInterval: this.checkInterval, - nextCheck: this.isRunning - ? new Date(Date.now() + this.checkInterval) - : null, - }; - } -} - -// Create singleton instance -const updateScheduler = new UpdateScheduler(); - -module.exports = updateScheduler; diff --git a/frontend/src/pages/Automation.jsx b/frontend/src/pages/Automation.jsx index 6e3286e..8eef48a 100644 --- a/frontend/src/pages/Automation.jsx +++ b/frontend/src/pages/Automation.jsx @@ -126,6 +126,7 @@ const Automation = () => { const getNextRunTime = (schedule, _lastRun) => { if (schedule === "Manual only") return "Manual trigger only"; + if (schedule.includes("Agent-driven")) return "Agent-driven (automatic)"; if (schedule === "Daily at midnight") { const now = new Date(); const tomorrow = new Date(now); @@ -172,6 +173,7 @@ const Automation = () => { const getNextRunTimestamp = (schedule) => { if (schedule === "Manual only") return Number.MAX_SAFE_INTEGER; // Manual tasks go to bottom + if (schedule.includes("Agent-driven")) return Number.MAX_SAFE_INTEGER - 1; // Agent-driven tasks near bottom but above manual if (schedule === "Daily at midnight") { const now = new Date(); const tomorrow = new Date(now); @@ -218,6 +220,8 @@ const Automation = () => { endpoint = "/automation/trigger/session-cleanup"; } else if (jobType === "orphaned-repos") { endpoint = "/automation/trigger/orphaned-repo-cleanup"; + } else if (jobType === "agent-collection") { + endpoint = "/automation/trigger/agent-collection"; } const _response = await api.post(endpoint, data); @@ -527,6 +531,10 @@ const Automation = () => { automation.queue.includes("orphaned-repo") ) { triggerManualJob("orphaned-repos"); + } else if ( + automation.queue.includes("agent-commands") + ) { + triggerManualJob("agent-collection"); } }} className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200" diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 8cc0137..de60bfc 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -70,6 +70,63 @@ const HostDetail = () => { refetchOnWindowFocus: false, // Don't refetch when window regains focus }); + // WebSocket connection status using Server-Sent Events (SSE) for real-time push updates + const [wsStatus, setWsStatus] = useState(null); + + useEffect(() => { + if (!host?.api_id) return; + + const token = localStorage.getItem("token"); + if (!token) return; + + let eventSource = null; + let reconnectTimeout = null; + let isMounted = true; + + const connect = () => { + if (!isMounted) return; + + try { + // Create EventSource for SSE connection + eventSource = new EventSource( + `/api/v1/ws/status/${host.api_id}/stream?token=${encodeURIComponent(token)}`, + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + setWsStatus(data); + } catch (_err) { + // Silently handle parse errors + } + }; + + eventSource.onerror = (_err) => { + eventSource?.close(); + + // Automatic reconnection after 5 seconds + if (isMounted) { + reconnectTimeout = setTimeout(connect, 5000); + } + }; + } catch (_err) { + // Silently handle connection errors + } + }; + + // Initial connection + connect(); + + // Cleanup on unmount or when api_id changes + return () => { + isMounted = false; + if (reconnectTimeout) clearTimeout(reconnectTimeout); + if (eventSource) { + eventSource.close(); + } + }; + }, [host?.api_id]); + // Fetch repository count for this host const { data: repositories, isLoading: isLoadingRepos } = useQuery({ queryKey: ["host-repositories", hostId], @@ -249,49 +306,67 @@ const HostDetail = () => { return (
{/* Header */} -
-
+
+
-

- {host.friendly_name} -

- {host.system_uptime && ( -
- - Uptime: - {host.system_uptime} +
+ {/* Title row with friendly name, badge, and status */} +
+

+ {host.friendly_name} +

+ {wsStatus && ( + + {wsStatus.connected + ? wsStatus.secure + ? "WSS" + : "WS" + : "Offline"} + + )} +
0)}`} + > + {getStatusIcon(isStale, host.stats.outdated_packages > 0)} + {getStatusText(isStale, host.stats.outdated_packages > 0)} +
+
+ {/* Info row with uptime and last updated */} +
+ {host.system_uptime && ( +
+ + Uptime: + {host.system_uptime} +
+ )} +
+ + Last updated: + + {formatRelativeTime(host.last_update)} + +
- )} -
- - Last updated: - {formatRelativeTime(host.last_update)} -
-
0)}`} - > - {getStatusIcon(isStale, host.stats.outdated_packages > 0)} - {getStatusText(isStale, host.stats.outdated_packages > 0)}
- +
{/* Queue Summary */}
-
-
- +
+
+
-

+

Waiting

-

+

{waiting}

-
-
- +
+
+
-

+

Active

-

+

{active}

-
-
- +
+
+
-

+

Delayed

-

+

{delayed}

-
-
- +
+
+
-

+

Failed

-

+

{failed}

diff --git a/frontend/src/pages/Hosts.jsx b/frontend/src/pages/Hosts.jsx index 7c5f34d..820396a 100644 --- a/frontend/src/pages/Hosts.jsx +++ b/frontend/src/pages/Hosts.jsx @@ -328,22 +328,24 @@ const Hosts = () => { const defaultConfig = [ { id: "select", label: "Select", visible: true, order: 0 }, { id: "host", label: "Friendly Name", visible: true, order: 1 }, - { id: "ip", label: "IP Address", visible: false, order: 2 }, - { id: "group", label: "Group", visible: true, order: 3 }, - { id: "os", label: "OS", visible: true, order: 4 }, - { id: "os_version", label: "OS Version", visible: false, order: 5 }, - { id: "agent_version", label: "Agent Version", visible: true, order: 6 }, + { id: "hostname", label: "System Hostname", visible: true, order: 2 }, + { id: "ip", label: "IP Address", visible: false, order: 3 }, + { id: "group", label: "Group", visible: true, order: 4 }, + { id: "os", label: "OS", visible: true, order: 5 }, + { id: "os_version", label: "OS Version", visible: false, order: 6 }, + { id: "agent_version", label: "Agent Version", visible: true, order: 7 }, { id: "auto_update", label: "Agent Auto-Update", visible: true, - order: 7, + order: 8, }, - { id: "status", label: "Status", visible: true, order: 8 }, - { id: "updates", label: "Updates", visible: true, order: 9 }, - { id: "notes", label: "Notes", visible: false, order: 10 }, - { id: "last_update", label: "Last Update", visible: true, order: 11 }, - { id: "actions", label: "Actions", visible: true, order: 12 }, + { id: "ws_status", label: "Online", visible: true, order: 9 }, + { id: "status", label: "Status", visible: true, order: 10 }, + { id: "updates", label: "Updates", visible: true, order: 11 }, + { id: "notes", label: "Notes", visible: false, order: 12 }, + { id: "last_update", label: "Last Update", visible: true, order: 13 }, + { id: "actions", label: "Actions", visible: true, order: 14 }, ]; const saved = localStorage.getItem("hosts-column-config"); @@ -398,6 +400,70 @@ const Hosts = () => { queryFn: () => hostGroupsAPI.list().then((res) => res.data), }); + // Track WebSocket status for all hosts + const [wsStatusMap, setWsStatusMap] = useState({}); + + // Subscribe to WebSocket status changes for all hosts via SSE + useEffect(() => { + if (!hosts || hosts.length === 0) return; + + const token = localStorage.getItem("token"); + if (!token) return; + + const eventSources = new Map(); + let isMounted = true; + + const connectHost = (apiId) => { + if (!isMounted || eventSources.has(apiId)) return; + + try { + const es = new EventSource( + `/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`, + ); + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (isMounted) { + setWsStatusMap((prev) => ({ ...prev, [apiId]: data })); + } + } catch (_err) { + // Silently handle parse errors + } + }; + + es.onerror = () => { + es?.close(); + eventSources.delete(apiId); + if (isMounted) { + // Retry connection after 5 seconds + setTimeout(() => connectHost(apiId), 5000); + } + }; + + eventSources.set(apiId, es); + } catch (_err) { + // Silently handle connection errors + } + }; + + // Connect to all hosts + for (const host of hosts) { + if (host.api_id) { + connectHost(host.api_id); + } + } + + // Cleanup function + return () => { + isMounted = false; + for (const es of eventSources.values()) { + es.close(); + } + eventSources.clear(); + }; + }, [hosts]); + const bulkUpdateGroupMutation = useMutation({ mutationFn: ({ hostIds, hostGroupId }) => adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId), @@ -756,10 +822,19 @@ const Hosts = () => { { id: "group", label: "Group", visible: true, order: 4 }, { id: "os", label: "OS", visible: true, order: 5 }, { id: "os_version", label: "OS Version", visible: false, order: 6 }, - { id: "status", label: "Status", visible: true, order: 7 }, - { id: "updates", label: "Updates", visible: true, order: 8 }, - { id: "last_update", label: "Last Update", visible: true, order: 9 }, - { id: "actions", label: "Actions", visible: true, order: 10 }, + { id: "agent_version", label: "Agent Version", visible: true, order: 7 }, + { + id: "auto_update", + label: "Agent Auto-Update", + visible: true, + order: 8, + }, + { id: "ws_status", label: "Online", visible: true, order: 9 }, + { id: "status", label: "Status", visible: true, order: 10 }, + { id: "updates", label: "Updates", visible: true, order: 11 }, + { id: "notes", label: "Notes", visible: false, order: 12 }, + { id: "last_update", label: "Last Update", visible: true, order: 13 }, + { id: "actions", label: "Actions", visible: true, order: 14 }, ]; updateColumnConfig(defaultConfig); }; @@ -871,6 +946,32 @@ const Hosts = () => { falseLabel="No" /> ); + case "ws_status": { + const wsStatus = wsStatusMap[host.api_id]; + if (!wsStatus) { + return ( + + ... + + ); + } + return ( + + {wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"} + + ); + } case "status": return (
@@ -1026,13 +1127,12 @@ const Hosts = () => { type="button" onClick={() => refetch()} disabled={isFetching} - className="btn-outline flex items-center gap-2" + className="btn-outline flex items-center justify-center p-2" title="Refresh hosts data" > - {isFetching ? "Refreshing..." : "Refresh"}