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
This commit is contained in:
Muhammad Ibrahim
2025-10-15 22:15:18 +01:00
parent 9a40d5e6ee
commit 5b77a1328d
9 changed files with 467 additions and 401 deletions

View File

@@ -241,12 +241,15 @@ router.get("/health", authenticateToken, async (_req, res) => {
router.get("/overview", authenticateToken, async (_req, res) => { router.get("/overview", authenticateToken, async (_req, res) => {
try { try {
const stats = await queueManager.getAllQueueStats(); 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 // Get recent jobs for each queue to show last run times
const recentJobs = await Promise.all([ const recentJobs = await Promise.all([
queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1), queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1),
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.AGENT_COMMANDS, 1),
]); ]);
// Calculate overview metrics // Calculate overview metrics
@@ -327,6 +330,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
: "Never run", : "Never run",
stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP], 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((a, b) => {
// Sort by last run timestamp (most recent first) // Sort by last run timestamp (most recent first)
// If both have never run (timestamp 0), maintain original order // If both have never run (timestamp 0), maintain original order

View File

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

View File

@@ -66,9 +66,8 @@ 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 updateScheduler = require("./services/updateScheduler"); const wsRoutes = require("./routes/wsRoutes");
const { initSettings } = require("./services/settingsService"); const { initSettings } = require("./services/settingsService");
const { cleanup_expired_sessions } = require("./utils/session_manager");
const { queueManager } = require("./services/automation"); const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth"); const { authenticateToken, requireAdmin } = require("./middleware/auth");
const { createBullBoard } = require("@bull-board/api"); const { createBullBoard } = require("@bull-board/api");
@@ -442,6 +441,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}/ws`, wsRoutes);
// Bull Board - will be populated after queue manager initializes // Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null; let bullBoardRouter = null;
@@ -580,10 +580,6 @@ process.on("SIGINT", async () => {
if (process.env.ENABLE_LOGGING === "true") { if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGINT received, shutting down gracefully"); 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 queueManager.shutdown();
await disconnectPrisma(prisma); await disconnectPrisma(prisma);
process.exit(0); process.exit(0);
@@ -593,10 +589,6 @@ process.on("SIGTERM", async () => {
if (process.env.ENABLE_LOGGING === "true") { if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGTERM received, shutting down gracefully"); 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 queueManager.shutdown();
await disconnectPrisma(prisma); await disconnectPrisma(prisma);
process.exit(0); process.exit(0);
@@ -891,21 +883,6 @@ async function startServer() {
bullBoardRouter = serverAdapter.getRouter(); bullBoardRouter = serverAdapter.getRouter();
console.log("✅ Bull Board mounted at /admin/queues (secured)"); 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 // Initialize WS layer with the underlying HTTP server
initAgentWs(server, prisma); initAgentWs(server, prisma);
@@ -913,15 +890,8 @@ async function startServer() {
if (process.env.ENABLE_LOGGING === "true") { if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`); logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`); 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) { } catch (error) {
console.error("❌ Failed to start server:", error.message); console.error("❌ Failed to start server:", error.message);
process.exit(1); process.exit(1);

View File

@@ -7,6 +7,14 @@ const url = require("node:url");
// Connection registry by api_id // Connection registry by api_id
const apiIdToSocket = new Map(); const apiIdToSocket = new Map();
// Connection metadata (secure/insecure)
// Map<api_id, { ws: WebSocket, secure: boolean }>
const connectionMetadata = new Map();
// Subscribers for connection status changes (for SSE)
// Map<api_id, Set<callback>>
const connectionChangeSubscribers = new Map();
let wss; let wss;
let prisma; let prisma;
@@ -46,11 +54,21 @@ function init(server, prismaClient) {
wss.handleUpgrade(request, socket, head, (ws) => { wss.handleUpgrade(request, socket, head, (ws) => {
ws.apiId = apiId; 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); apiIdToSocket.set(apiId, ws);
connectionMetadata.set(apiId, { ws, secure: isSecure });
console.log( 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", () => { ws.on("message", () => {
// Currently we don't need to handle agent->server messages // Currently we don't need to handle agent->server messages
}); });
@@ -59,6 +77,9 @@ function init(server, prismaClient) {
const existing = apiIdToSocket.get(apiId); const existing = apiIdToSocket.get(apiId);
if (existing === ws) { if (existing === ws) {
apiIdToSocket.delete(apiId); apiIdToSocket.delete(apiId);
connectionMetadata.delete(apiId);
// Notify subscribers of disconnection
notifyConnectionChange(apiId, false);
} }
console.log( console.log(
`[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`, `[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 = { module.exports = {
init, init,
broadcastSettingsUpdate, broadcastSettingsUpdate,
@@ -122,4 +176,15 @@ module.exports = {
const ws = apiIdToSocket.get(apiId); const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN; 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,
}; };

View File

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

View File

@@ -126,6 +126,7 @@ const Automation = () => {
const getNextRunTime = (schedule, _lastRun) => { const getNextRunTime = (schedule, _lastRun) => {
if (schedule === "Manual only") return "Manual trigger only"; if (schedule === "Manual only") return "Manual trigger only";
if (schedule.includes("Agent-driven")) return "Agent-driven (automatic)";
if (schedule === "Daily at midnight") { if (schedule === "Daily at midnight") {
const now = new Date(); const now = new Date();
const tomorrow = new Date(now); const tomorrow = new Date(now);
@@ -172,6 +173,7 @@ const Automation = () => {
const getNextRunTimestamp = (schedule) => { const getNextRunTimestamp = (schedule) => {
if (schedule === "Manual only") return Number.MAX_SAFE_INTEGER; // Manual tasks go to bottom 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") { if (schedule === "Daily at midnight") {
const now = new Date(); const now = new Date();
const tomorrow = new Date(now); const tomorrow = new Date(now);
@@ -218,6 +220,8 @@ const Automation = () => {
endpoint = "/automation/trigger/session-cleanup"; endpoint = "/automation/trigger/session-cleanup";
} else if (jobType === "orphaned-repos") { } else if (jobType === "orphaned-repos") {
endpoint = "/automation/trigger/orphaned-repo-cleanup"; endpoint = "/automation/trigger/orphaned-repo-cleanup";
} else if (jobType === "agent-collection") {
endpoint = "/automation/trigger/agent-collection";
} }
const _response = await api.post(endpoint, data); const _response = await api.post(endpoint, data);
@@ -527,6 +531,10 @@ const Automation = () => {
automation.queue.includes("orphaned-repo") automation.queue.includes("orphaned-repo")
) { ) {
triggerManualJob("orphaned-repos"); 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" 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"

View File

@@ -70,6 +70,63 @@ const HostDetail = () => {
refetchOnWindowFocus: false, // Don't refetch when window regains focus 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 // Fetch repository count for this host
const { data: repositories, isLoading: isLoadingRepos } = useQuery({ const { data: repositories, isLoading: isLoadingRepos } = useQuery({
queryKey: ["host-repositories", hostId], queryKey: ["host-repositories", hostId],
@@ -249,49 +306,67 @@ const HostDetail = () => {
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-secondary-200 dark:border-secondary-600"> <div className="flex items-start justify-between mb-4 pb-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-3"> <div className="flex items-start gap-3">
<Link <Link
to="/hosts" to="/hosts"
className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200" className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 mt-1"
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Link> </Link>
<h1 className="text-xl font-semibold text-secondary-900 dark:text-white"> <div className="flex flex-col gap-2">
{host.friendly_name} {/* Title row with friendly name, badge, and status */}
</h1> <div className="flex items-center gap-3">
{host.system_uptime && ( <h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400"> {host.friendly_name}
<Clock className="h-4 w-4" /> </h1>
<span className="text-xs font-medium">Uptime:</span> {wsStatus && (
<span>{host.system_uptime}</span> <span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 animate-pulse"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
title={
wsStatus.connected
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS"}`
: "Agent not connected"
}
>
{wsStatus.connected
? wsStatus.secure
? "WSS"
: "WS"
: "Offline"}
</span>
)}
<div
className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}
>
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div>
</div>
{/* Info row with uptime and last updated */}
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400">
{host.system_uptime && (
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Uptime:</span>
<span className="text-xs">{host.system_uptime}</span>
</div>
)}
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Last updated:</span>
<span className="text-xs">
{formatRelativeTime(host.last_update)}
</span>
</div>
</div> </div>
)}
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span className="text-xs font-medium">Last updated:</span>
<span>{formatRelativeTime(host.last_update)}</span>
</div>
<div
className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}
>
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2 text-sm"
title="Refresh host data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
<button <button
type="button" type="button"
onClick={() => setShowCredentialsModal(true)} onClick={() => setShowCredentialsModal(true)}
@@ -300,6 +375,17 @@ const HostDetail = () => {
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />
Deploy Agent Deploy Agent
</button> </button>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center justify-center p-2 text-sm"
title="Refresh host data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
<button <button
type="button" type="button"
onClick={() => setShowDeleteModal(true)} onClick={() => setShowDeleteModal(true)}
@@ -1765,7 +1851,7 @@ const AgentQueueTab = ({ hostId }) => {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> <h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Agent Queue Status Live Agent Queue Status
</h3> </h3>
<button <button
type="button" type="button"
@@ -1774,62 +1860,61 @@ const AgentQueueTab = ({ hostId }) => {
title="Refresh queue data" title="Refresh queue data"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
Refresh
</button> </button>
</div> </div>
{/* Queue Summary */} {/* Queue Summary */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4"> <div className="card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center">
<Server className="h-8 w-8 text-blue-600 dark:text-blue-400" /> <Server className="h-5 w-5 text-blue-600 mr-2" />
<div> <div>
<p className="text-sm text-blue-600 dark:text-blue-400 font-medium"> <p className="text-sm text-secondary-500 dark:text-white">
Waiting Waiting
</p> </p>
<p className="text-2xl font-bold text-blue-700 dark:text-blue-300"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{waiting} {waiting}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4"> <div className="card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center">
<Clock3 className="h-8 w-8 text-yellow-600 dark:text-yellow-400" /> <Clock3 className="h-5 w-5 text-warning-600 mr-2" />
<div> <div>
<p className="text-sm text-yellow-600 dark:text-yellow-400 font-medium"> <p className="text-sm text-secondary-500 dark:text-white">
Active Active
</p> </p>
<p className="text-2xl font-bold text-yellow-700 dark:text-yellow-300"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{active} {active}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4"> <div className="card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center">
<Clock className="h-8 w-8 text-purple-600 dark:text-purple-400" /> <Clock className="h-5 w-5 text-primary-600 mr-2" />
<div> <div>
<p className="text-sm text-purple-600 dark:text-purple-400 font-medium"> <p className="text-sm text-secondary-500 dark:text-white">
Delayed Delayed
</p> </p>
<p className="text-2xl font-bold text-purple-700 dark:text-purple-300"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{delayed} {delayed}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4"> <div className="card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center">
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" /> <AlertCircle className="h-5 w-5 text-danger-600 mr-2" />
<div> <div>
<p className="text-sm text-red-600 dark:text-red-400 font-medium"> <p className="text-sm text-secondary-500 dark:text-white">
Failed Failed
</p> </p>
<p className="text-2xl font-bold text-red-700 dark:text-red-300"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{failed} {failed}
</p> </p>
</div> </div>

View File

@@ -328,22 +328,24 @@ const Hosts = () => {
const defaultConfig = [ const defaultConfig = [
{ id: "select", label: "Select", visible: true, order: 0 }, { id: "select", label: "Select", visible: true, order: 0 },
{ id: "host", label: "Friendly Name", visible: true, order: 1 }, { id: "host", label: "Friendly Name", visible: true, order: 1 },
{ id: "ip", label: "IP Address", visible: false, order: 2 }, { id: "hostname", label: "System Hostname", visible: true, order: 2 },
{ id: "group", label: "Group", visible: true, order: 3 }, { id: "ip", label: "IP Address", visible: false, order: 3 },
{ id: "os", label: "OS", visible: true, order: 4 }, { id: "group", label: "Group", visible: true, order: 4 },
{ id: "os_version", label: "OS Version", visible: false, order: 5 }, { id: "os", label: "OS", visible: true, order: 5 },
{ id: "agent_version", label: "Agent Version", visible: true, order: 6 }, { id: "os_version", label: "OS Version", visible: false, order: 6 },
{ id: "agent_version", label: "Agent Version", visible: true, order: 7 },
{ {
id: "auto_update", id: "auto_update",
label: "Agent Auto-Update", label: "Agent Auto-Update",
visible: true, visible: true,
order: 7, order: 8,
}, },
{ id: "status", label: "Status", visible: true, order: 8 }, { id: "ws_status", label: "Online", visible: true, order: 9 },
{ id: "updates", label: "Updates", visible: true, order: 9 }, { id: "status", label: "Status", visible: true, order: 10 },
{ id: "notes", label: "Notes", visible: false, order: 10 }, { id: "updates", label: "Updates", visible: true, order: 11 },
{ id: "last_update", label: "Last Update", visible: true, order: 11 }, { id: "notes", label: "Notes", visible: false, order: 12 },
{ id: "actions", label: "Actions", visible: true, 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"); const saved = localStorage.getItem("hosts-column-config");
@@ -398,6 +400,70 @@ const Hosts = () => {
queryFn: () => hostGroupsAPI.list().then((res) => res.data), 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({ const bulkUpdateGroupMutation = useMutation({
mutationFn: ({ hostIds, hostGroupId }) => mutationFn: ({ hostIds, hostGroupId }) =>
adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId), adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
@@ -756,10 +822,19 @@ const Hosts = () => {
{ id: "group", label: "Group", visible: true, order: 4 }, { id: "group", label: "Group", visible: true, order: 4 },
{ id: "os", label: "OS", visible: true, order: 5 }, { id: "os", label: "OS", visible: true, order: 5 },
{ id: "os_version", label: "OS Version", visible: false, order: 6 }, { id: "os_version", label: "OS Version", visible: false, order: 6 },
{ id: "status", label: "Status", visible: true, order: 7 }, { id: "agent_version", label: "Agent Version", visible: true, order: 7 },
{ id: "updates", label: "Updates", visible: true, order: 8 }, {
{ id: "last_update", label: "Last Update", visible: true, order: 9 }, id: "auto_update",
{ id: "actions", label: "Actions", visible: true, order: 10 }, 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); updateColumnConfig(defaultConfig);
}; };
@@ -871,6 +946,32 @@ const Hosts = () => {
falseLabel="No" falseLabel="No"
/> />
); );
case "ws_status": {
const wsStatus = wsStatusMap[host.api_id];
if (!wsStatus) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400">
...
</span>
);
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold uppercase ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 shadow-lg shadow-green-500/50 dark:shadow-green-500/30 animate-pulse"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
}`}
title={
wsStatus.connected
? `Agent connected via ${wsStatus.secure ? "WSS (secure)" : "WS"}`
: "Agent not connected"
}
>
{wsStatus.connected ? (wsStatus.secure ? "WSS" : "WS") : "Offline"}
</span>
);
}
case "status": case "status":
return ( return (
<div className="text-sm text-secondary-900 dark:text-white"> <div className="text-sm text-secondary-900 dark:text-white">
@@ -1026,13 +1127,12 @@ const Hosts = () => {
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
disabled={isFetching} disabled={isFetching}
className="btn-outline flex items-center gap-2" className="btn-outline flex items-center justify-center p-2"
title="Refresh hosts data" title="Refresh hosts data"
> >
<RefreshCw <RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/> />
{isFetching ? "Refreshing..." : "Refresh"}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -61,6 +61,8 @@ export const dashboardAPI = {
const url = `/dashboard/hosts/${hostId}/queue${queryString ? `?${queryString}` : ""}`; const url = `/dashboard/hosts/${hostId}/queue${queryString ? `?${queryString}` : ""}`;
return api.get(url); return api.get(url);
}, },
getHostWsStatus: (hostId) => api.get(`/dashboard/hosts/${hostId}/ws-status`),
getWsStatusByApiId: (apiId) => api.get(`/ws/status/${apiId}`),
getPackageTrends: (params = {}) => { getPackageTrends: (params = {}) => {
const queryString = new URLSearchParams(params).toString(); const queryString = new URLSearchParams(params).toString();
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`; const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;